Flutter for OpenHarmony 实战:做一个番茄钟提醒器——在鸿蒙上播放循环提示音(flutter_ringtone_player 上手)
一、实战目标:番茄钟
番茄钟是检验"提醒能力"的最好载体:倒计时走到 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 SDK | 3.41.10-ohos-1.0.1(Dart 3.11.5) |
| DevEco Studio / CLI | 1.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 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:专注 / 短休息 / 长休息,决定时长与下一步走向; - 阶段
TimerPhase:idle → 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):
- 应用启动,默认专注 25:00;点一下时间盘,在底部弹层把「专注」时长调成 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)

- 点"停止铃声"回到空闲;再跑一轮后点"停止并开始短休息",应用自动停铃、切到短休息并立即开始 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.yaml 的 assets 里声明(最常见原因)→ 路径是否与声明一致(含 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
更多推荐



所有评论(0)