一、实战目标:番茄钟

番茄钟是检验"提醒能力"的最好载体:倒计时走到 00:00,铃声要一直响,直到人回来点一下确认。这正好把 flutter_ringtone_player 在鸿蒙上的三个关键 API 点完整串起来——fromAsset 自定义音源、looping: true 循环播放、stop() 主动停止。

做完后的效果:

  • 专注 / 短休息 / 长休息三种模式,时长可调(默认 25 / 5 / 15 分钟);
  • 倒计时大字盘,支持开始、暂停、继续、重置;
  • 归零瞬间弹出"番茄完成 🍅"弹窗,铃声循环响个不停,点"停止铃声"或"停止并开始休息"才会停;
  • 专注完成后自动建议下一个阶段(每 4 个番茄进入长休息),AppBar 实时累计 🍅 数。

在这里插入图片描述

先说清一个平台差异,它决定了实现姿势:playAlarm() / playNotification() / playRingtone() 这类系统铃声枚举方法在鸿蒙上不可用(SDK 未向三方开放系统铃声播放,适配版会安全降级为无声,不会崩溃),所以提醒音必须走 fromAsset 打包在应用里的音源。这也是鸿蒙上使用这个插件的推荐姿势。适配的来龙去脉(为什么降级、原生层怎么解析资产)写在配套的《Flutter 三方库 flutter_ringtone_player 的鸿蒙化适配指南》里,本文只管把它用成真实功能。

本文的验证环境:

版本
Flutter SDK3.41.10-ohos-1.0.1(Dart 3.11.5)
DevEco Studio / CLI1.3.0-stable
验证设备HUAWEI PSN-AL00 真机,OpenHarmony 7.0.0.105(API 26)
插件flutter_ringtone_player 4.0.0+4 鸿蒙适配版(feat/ohos-adaptation 分支)

二、准备:环境、工程与音源

2.1 环境搭建

本文不展开环境安装,请按官方文档完成 Flutter 鸿蒙化环境搭建:

Flutter for OpenHarmony 环境搭建

完成后 flutter doctor 确认鸿蒙工具链无缺失项,即可继续。

2.2 创建工程并引入适配版插件

flutter create pomodoro_demo
cd pomodoro_demo

pubspec.yaml 中以 git 依赖引入适配分支:

dependencies:
  flutter:
    sdk: flutter
  flutter_ringtone_player:
    git:
      url: https://atomgit.com/oh-flutter/flutter_ringtone_player.git
      ref: feat/ohos-adaptation

不需要任何鸿蒙工程改动,也不需要声明任何权限——铃声用的是应用自带资产,播放走系统 AVPlayer。

2.3 准备一个提示音文件

pubspec.yaml 声明资产目录并放入音频文件(mp3 / wav 均可):

flutter:
  assets:
    - assets/sounds/

demo 里放了一个 assets/sounds/alarm.mp3(直接复用插件 example 自带的音频,读者换成任意提示音都行)。资产漏声明是最常见的翻车点:不声明或路径写错,运行时会报 ASSET_LOAD_FAILED(排查清单见 FAQ)。

三、实现:倒计时状态机 + 循环铃声

3.1 状态设计

两个维度把逻辑拆干净:

  • 模式 PomodoroMode:专注 / 短休息 / 长休息,决定时长与下一步走向;
  • 阶段 TimerPhaseidle → running → paused → ringing,其中 ringing 是本 demo 的灵魂——倒计时归零后应用进入"响铃待确认"状态,这一态与"运行中"分开建模,铃声的播与停就有了明确的挂靠点。

3.2 完整代码

lib/main.dart 全部内容如下,可直接替换新建工程的同名文件:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';

void main() => runApp(const PomodoroApp());

class PomodoroApp extends StatelessWidget {
  const PomodoroApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '番茄钟',
      theme: ThemeData(colorSchemeSeed: Colors.deepOrange),
      home: const PomodoroPage(),
    );
  }
}

enum PomodoroMode { focus, shortBreak, longBreak }

extension PomodoroModeLabel on PomodoroMode {
  String get label => switch (this) {
        PomodoroMode.focus => '专注',
        PomodoroMode.shortBreak => '短休息',
        PomodoroMode.longBreak => '长休息',
      };
}

enum TimerPhase { idle, running, paused, ringing }

class PomodoroPage extends StatefulWidget {
  const PomodoroPage({super.key});

  
  State<PomodoroPage> createState() => _PomodoroPageState();
}

class _PomodoroPageState extends State<PomodoroPage> {
  static const Map<PomodoroMode, int> _defaultMinutes = {
    PomodoroMode.focus: 25,
    PomodoroMode.shortBreak: 5,
    PomodoroMode.longBreak: 15,
  };
  static const List<int> _presetMinutes = [1, 5, 10, 15, 25, 30, 45, 60];

  final FlutterRingtonePlayer _player = FlutterRingtonePlayer();
  final Map<PomodoroMode, int> _minutes = Map.of(_defaultMinutes);

  PomodoroMode _mode = PomodoroMode.focus;
  TimerPhase _phase = TimerPhase.idle;
  int _remainingSeconds = _defaultMinutes[PomodoroMode.focus]! * 60;
  int _completedFocus = 0;
  Timer? _ticker;

  bool get _isBreak => _mode != PomodoroMode.focus;

  String get _timeText {
    final m = _remainingSeconds ~/ 60;
    final s = _remainingSeconds % 60;
    return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
  }

  void _start() {
    setState(() => _phase = TimerPhase.running);
    _startTicker();
  }

  void _startTicker() {
    _ticker?.cancel();
    _ticker = Timer.periodic(const Duration(seconds: 1), _tick);
  }

  void _tick(Timer timer) {
    if (_remainingSeconds > 0) {
      setState(() => _remainingSeconds--);
    }
    if (_remainingSeconds == 0) {
      timer.cancel();
      _onFinished();
    }
  }

  /// 倒计时归零:循环播放提示音,直到用户确认
  Future<void> _onFinished() async {
    setState(() => _phase = TimerPhase.ringing);
    if (_mode == PomodoroMode.focus) _completedFocus++;
    _showRingingDialog();
    try {
      // 关键一行:looping: true 循环播放,直到 stop() 才停
      await _player.play(
        fromAsset: 'assets/sounds/alarm.mp3',
        volume: 1.0,
        looping: true,
      );
    } catch (_) {
      // 上游对播放异常静默处理;这里兜底防崩溃
    }
  }

  void _showRingingDialog() {
    final nextLabel = _isBreak
        ? '开始下一个专注'
        : (_completedFocus % 4 == 0 ? '开始长休息' : '开始短休息');
    showDialog<void>(
      context: context,
      barrierDismissible: false,
      builder: (context) => PopScope(
        canPop: false,
        child: AlertDialog(
          title: Text(_isBreak ? '休息结束 🎉' : '番茄完成 🍅'),
          content: Text(_isBreak ? '准备回到专注吧!' : '铃声会一直响,点下面的按钮停止。'),
          actions: [
            TextButton(
              onPressed: () => _dismissRinging(startNext: false),
              child: const Text('停止铃声'),
            ),
            FilledButton(
              onPressed: () => _dismissRinging(startNext: true),
              child: Text('停止并$nextLabel'),
            ),
          ],
        ),
      ),
    );
  }

  void _dismissRinging({required bool startNext}) {
    _player.stop();
    Navigator.of(context).pop();
    if (startNext) {
      _switchMode(_nextMode());
      _start();
    } else {
      setState(() => _phase = TimerPhase.idle);
    }
  }

  PomodoroMode _nextMode() {
    if (_isBreak) return PomodoroMode.focus;
    return _completedFocus % 4 == 0
        ? PomodoroMode.longBreak
        : PomodoroMode.shortBreak;
  }

  void _switchMode(PomodoroMode mode) {
    setState(() {
      _mode = mode;
      _remainingSeconds = _minutes[mode]! * 60;
      _phase = TimerPhase.idle;
    });
  }

  void _pause() {
    _ticker?.cancel();
    setState(() => _phase = TimerPhase.paused);
  }

  void _resume() {
    setState(() => _phase = TimerPhase.running);
    _startTicker();
  }

  void _reset() {
    _ticker?.cancel();
    setState(() {
      _remainingSeconds = _minutes[_mode]! * 60;
      _phase = TimerPhase.idle;
    });
  }

  Future<void> _pickMinutes() async {
    final picked = await showModalBottomSheet<int>(
      context: context,
      builder: (context) => SafeArea(
        child: Wrap(
          children: [
            Padding(
              padding: const EdgeInsets.all(16),
              child: Text('「${_mode.label}」时长', style: Theme.of(context).textTheme.titleMedium),
            ),
            Wrap(
              children: _presetMinutes
                  .map((m) => Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 4),
                        child: ChoiceChip(
                          label: Text('$m 分钟'),
                          selected: m == _minutes[_mode],
                          onSelected: (v) => Navigator.of(context).pop(m),
                        ),
                      ))
                  .toList(),
            ),
            const SizedBox(height: 16),
          ],
        ),
      ),
    );
    if (picked == null) return;
    setState(() {
      _minutes[_mode] = picked;
      _remainingSeconds = picked * 60;
    });
  }

  
  void dispose() {
    _ticker?.cancel();
    _player.stop();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('番茄钟'),
        actions: [
          Padding(
            padding: const EdgeInsets.only(right: 16),
            child: Center(child: Text('🍅 $_completedFocus')),
          ),
        ],
      ),
      body: Column(
        children: [
          const SizedBox(height: 16),
          SegmentedButton<PomodoroMode>(
            segments: PomodoroMode.values
                .map((m) => ButtonSegment(value: m, label: Text(m.label)))
                .toList(),
            selected: {_mode},
            showSelectedIcon: false,
            onSelectionChanged: _phase == TimerPhase.idle
                ? (selection) => _switchMode(selection.first)
                : null,
          ),
          const SizedBox(height: 32),
          GestureDetector(
            onTap: _phase == TimerPhase.idle ? _pickMinutes : null,
            child: Column(
              children: [
                Text(_timeText,
                    style: Theme.of(context)
                        .textTheme
                        .displayLarge
                        ?.copyWith(fontWeight: FontWeight.w600)),
                if (_phase == TimerPhase.idle)
                  Text('${_minutes[_mode]} 分钟 · 点按可调整',
                      style: Theme.of(context).textTheme.bodySmall),
              ],
            ),
          ),
          const SizedBox(height: 32),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              switch (_phase) {
                TimerPhase.idle => FilledButton.icon(
                    onPressed: _start,
                    icon: const Icon(Icons.play_arrow),
                    label: const Text('开始'),
                  ),
                TimerPhase.running => FilledButton.icon(
                    onPressed: _pause,
                    icon: const Icon(Icons.pause),
                    label: const Text('暂停'),
                  ),
                TimerPhase.paused => FilledButton.icon(
                    onPressed: _resume,
                    icon: const Icon(Icons.play_arrow),
                    label: const Text('继续'),
                  ),
                TimerPhase.ringing => const CircularProgressIndicator(),
              },
              const SizedBox(width: 16),
              OutlinedButton(
                onPressed: _phase == TimerPhase.idle ? null : _reset,
                child: const Text('重置'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

3.3 四个值得注意的实现点

第一,循环铃声的完整三件套是"fromAsset + looping + stop"。 play(fromAsset: ..., looping: true) 让铃声无限循环,唯一的停止方式是显式调用 stop()——demo 里把它放在弹窗按钮的回调里:点"停止铃声"只停声音回空闲,点"停止并开始短休息"则先 stop() 再自动切入下一阶段。play() 本身在整个播放链 prepared 后才会正常返回,所以"先弹窗、后响铃"的顺序不会互相卡住。

第二,系统铃声枚举一个都别用。 playAlarm() / playRingtone() 在 Android/iOS 上是"播系统默认铃声"的便捷方法,但在鸿蒙上会静默降级为无声。提醒音请全部走 fromAsset 自带音源——这不是插件的缺陷,而是鸿蒙未向三方开放系统铃声播放,适配版按平台能力如实降级。

第三,页面退出要收尾。 dispose() 里取消 Timer 并调用 _player.stop(),防止页面销毁后铃声还在响、计时器还在跑。音频类功能这是底线动作。

第四,弹窗挡住返回手势。 响铃弹窗用 PopScope(canPop: false) + barrierDismissible: false 锁住——否则用户顺手一划返回,弹窗没了,铃声还在循环响,找不到停止入口。时长选择的底部弹层(_pickMinutes)里放了 1~60 分钟的预设档位,既方便真实使用,也让本文的真机验证能在 1 分钟内跑完一轮。

四、在鸿蒙真机上跑起来

先构建:

flutter build hap --debug

然后用 DevEco CLI 签名、安装、启动(也可以直接用 DevEco Studio 运行)。真机第一次跑需要生成一次调试签名(模拟器可跳过):

cd ohos
devecocli signature generate    # 仅首次:生成调试签名材料并写入 build-profile.json5
devecocli run --build-mode debug --device <设备序列号>

不签名直接装会报 9568320 no signature file——模拟器不校验签名,真机强制校验。

本文的实测流程(HUAWEI PSN-AL00,OpenHarmony 7.0.0.105):

  1. 应用启动,默认专注 25:00;点一下时间盘,在底部弹层把「专注」时长调成 1 分钟(这档位也是为验证准备的);

在这里插入图片描述

  1. 点"开始",倒计时逐秒递减,按钮变成"暂停";

在这里插入图片描述

  1. 归零瞬间弹出"番茄完成 🍅"弹窗,铃声循环响起(真机实测可闻),AppBar 的 🍅 计数 +1;hilog 里的逐字段证据——looping=true 说明循环生效,缓存文件名里的 _0_1 是插件按"时间戳 + 自增计数器"生成的,连续两次触发文件不互相覆盖:
RingtonePlayerPlugin: Playing /data/storage/el2/base/cache/ringtone_1789364329031_0.mp3 (looping=true, volume=1)
RingtonePlayerPlugin: Playing /data/storage/el2/base/cache/ringtone_1789364426887_1.mp3 (looping=true, volume=1)

在这里插入图片描述

  1. 点"停止铃声"回到空闲;再跑一轮后点"停止并开始短休息",应用自动停铃、切到短休息并立即开始 04:59 倒计时——番茄钟的完整循环就转起来了。

在这里插入图片描述

另外两个顺手验证过的行为:运行中点"暂停"再点"继续",倒计时无缝衔接;连点两次开始不会重复起计时器(阶段状态机挡住了)。

五、FAQ:使用中的常见问题

Q1:熄屏或切到后台,倒计时还会继续吗?
不一定。Dart 的 Timer 依赖 Flutter 引擎回调,应用被系统挂起后计时可能暂停,这是所有跨平台框架的共同约束。正式上线建议:会话内保持亮屏(接 wakelock 类插件),跨熄屏提醒接鸿蒙后台长时任务 + 通知。demo 阶段亮屏使用即可。

Q2:循环铃声怎么停?
唯一的停止方式是 stop()。注意 play(looping: true) 会一直响到 stop() 为止——它就是为"响到人为止"设计的,业务上一定要把 stop() 挂在明确的用户动作上;需要"切后台自动静音"就监听生命周期,在 AppLifecycleState.paused 时调用。

Q3:专注结束和休息结束想要不同的铃声?
放多个音频进 assets/sounds/,按当前模式传不同的 fromAsset 即可,接口不变。

Q4:playAlarm 为什么在鸿蒙上没声音也不报错?
系统铃声播放未向三方开放,适配版返回 SYSTEM_SOUND_UNAVAILABLE,上游 Dart 按既有容错静默处理——结果就是"无声但不崩"。提醒音请走 fromAsset(原因与适配细节见适配指南)。

Q5:播放报 ASSET_LOAD_FAILED?
按顺序检查:音频文件是否真的在 pubspec.yamlassets 里声明(最常见原因)→ 路径是否与声明一致(含 assets/ 前缀)→ 修改资产后是否重新构建安装(资产打进包里,热重载不更新)→ 文件名大小写是否完全一致。

Q6:音量参数设 1.5 会怎样?
被收敛到 1.0。鸿蒙侧把音量约束在 0.0–1.0 区间,超出部分自动钳位。

Q7:完整工程在哪里?
本文示例与文件导出示例一起托管在 AtomGit:https://atomgit.com/qq_15502821/flutter_demos (pomodoro_demo 目录,代码与本文逐行一致)。

Q8:发现问题如何提 issue / 提 PR?
到适配仓库提 issue,附设备型号、API 版本、Flutter/DevEco 版本、复现步骤与 RingtonePlayerPlugin 标签日志:https://atomgit.com/oh-flutter/flutter_ringtone_player/issues 。PR 基于 feat/ohos-adaptation 分支,本地过 flutter analyze / flutter test / 构建三关,真机实测播放与停止后发起。

六、总结

这个番茄钟把 flutter_ringtone_player 在鸿蒙上最有价值的一条链路跑圆了:自带音源 + 循环播放 + 显式停止。业务侧没有写一行平台判断代码,同样的 main.dart 在 Android / iOS 上也是成立的;唯一的平台知识是"系统铃声枚举在鸿蒙无声,提醒音走 fromAsset"——理解了这一点,剩下的就是普通 Flutter 开发。把番茄钟的铃声换成你业务的提示场景(订单提醒、队列叫号、定时巡检),这个模式可以直接复用。

欢迎加入 Flutter 鸿蒙化社区(CPF-Flutter 组织):https://atomgit.com/CPF-Flutter
本文所用适配版插件仓库:https://atomgit.com/oh-flutter/flutter_ringtone_player
本文示例工程:https://atomgit.com/qq_15502821/flutter_demos

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐