前阵子在某款 OpenHarmony 开发板上用 Flutter 做计时器 Demo,遇到一个让我印象极深的现象:我按Timer.periodic(100ms)写了 100 毫秒的刷新逻辑,页面看起来一切正常,可跑了十几分钟以后,它比手机系统秒表慢了将近两秒。一开始我以为是开发板性能不行,后来把实现换成了“Stopwatch 做时间基准 + Ticker 做 UI 刷新触发”,误差直接压到了几十毫秒以内。这中间踩到的坑,正好把 Flutter 计时器背后最值得讲的三件事串起来了:Timer 到底准不准、计时状态怎么跨组件同步、以及毫秒级刷新时 UI 怎么避免无谓重建。
这篇文章就以“高精度 Flutter 计时器”为例,把从时基选型、核心算法到状态同步、UI 响应式设计的过程完整拆开讲一遍。适合两类人看:一类是刚把 Flutter 跑到 OpenHarmony 上、想确认跨端计时方案是否可靠的开发者;另一类是已经在写 Flutter 应用,但被 Timer 精度、页面卡顿、状态不同步折腾过的朋友。
1. 高精度计时器在 OpenHarmony 上到底难在哪
很多人第一反应是:计时器不就是一个Timer加一个setState吗?在高精度场景下,这个直觉恰恰是最大的坑。难点不在于“怎么写”,而在于“时间从哪来”。你要先想清楚,一个计时器显示的数值,是来自“每次回调累加固定时长”,还是来自“某一刻真实读取的时间基准”。
1.1 为什么不能直接拿 Timer 当秒表用
Timer在 Dart 里的语义更像一个“闹钟提示”:它负责在事件循环里安排一个回调,等时间差不多到了就通知你。它不是一块持续走动的表。你用Timer.periodic(Duration(milliseconds: 100), callback)时,系统真正做的是把回调排进事件队列,等待下一次 event loop 轮转到它。问题就在这里:如果主 isolate 正在忙,比如在进行图片解码、列表布局、GC 垃圾回收,那个回调不会准时到达,而是会延后。
更麻烦的是,Timer.periodic的默认补偿策略是“根据当前时间校正下一次触发点”,但它并不会补偿“上一次回调已经晚到”这件事。我实测过,在 Flutter 页面里同时放一个列表滚动和一个秒表,普通 Timer 计时跑 10 分钟能累计偏差到 1 秒以上。
放到 OpenHarmony 上,情况会更明显。当时我用的是一套社区维护的 Flutter 适配层,它在系统事件循环、平台通道调度上跟标准 Android/iOS 环境还有差异,尤其是首帧渲染时主 isolate 的负载会明显偏高。如果计时逻辑依赖 Timer 回调次数来累加,那从第一秒开始误差就被固化了。
1.2 需求定位:不同的“准”对应不同方案
在动手之前,我建议先把“准”定义清楚。我把它分成三档:
| 精度档位 | 典型场景 | 适用方案 |
|---|---|---|
| 显示级 | 番茄钟、煮蛋倒数,肉眼别差太多 | 普通Timer.periodic就可以,甚至用 1 秒刷新 |
| 业务级 | 运动计时、答题倒计时、抢购倒计时 | 需要单调时钟做时间基准,Timer 只负责触发刷新 |
| 测量级 | 秒表、竞速测试、动画时间戳 | 需要 Stopwatch/Ticker 对齐帧周期,毫秒级读值 |
我要做的是“业务级偏测量级”的计时器,因此在最终方案里同时用了Stopwatch和Ticker:Stopwatch负责回答“到底过了多久”,Ticker负责回答“什么时候刷新界面”。后面几章就围绕这套分工展开。
2. 时基选型:Timer 是节拍提示,Stopwatch 才是时间源
这一章先把三个容易混淆的类彻底理清楚。很多人把Timer、Stopwatch、Ticker混着用,因为它们表面上都有“时间”两个字,但底层机制完全不同。
2.1 Timer.periodic 的误差从何而来
Timer的精度受限于 Dart 事件循环和底层定时器实现。它保证的是“回调不会早于指定时间被调用”,但不保证“正好在那个时间点被调用”。也就是说,它允许晚到。
晚到的原因主要有三个:
- 主 isolate 被长任务占用,event loop 根本没机会轮转;
- GC 触发时暂停所有 Dart 代码执行;
- OpenHarmony 适配层做平台通道编解码时,阻塞了事件循环。
在单线程界面模型里,这些几乎无法避免。所以高精度计时器绝不能把“次数”当“时间”——你不能说“我回调了 100 次,所以过了 10 秒”。这个逻辑在低负载机器上勉强成立,一旦页面复杂化就崩了。
2.2 Stopwatch 与单调时钟
Stopwatch的意义完全不同。它内部不依赖事件循环,而是直接读取系统的单调时钟,也就是monotonic clock。单调时钟的特点是:它只往前走,不会因为用户手动改了系统时间、设备连上 NTP 校时、或者跨时区切换而跳变。这对计时器来说至关重要。
用代码来理解:
final Stopwatch sw = Stopwatch(); void start() { sw.start(); } Duration elapsed() { return sw.elapsed; // 这一刻真实经过的时间 }注意sw.elapsed是“你调用它那一刻”的真实时间差,不是某个回调告诉你的估算值。也就是说,无论你多久读取一次,读到的都是准的。Stopwatch在 Dart 里的底层实现,在支持高精度时钟的平台上会使用微秒级时间源,对于我们的场景绰绰有余。
2.3 Ticker 解决的是刷新对齐问题
那Ticker又是什么?它是 Flutter scheduler 里的一把“节拍器”。Ticker的每一次回调都对应一帧 VSync 信号,也就是说它跟屏幕刷新是天然对齐的。用它来驱动 UI 更新,可以做到“每个刷新率周期只更新一次界面”,不会多刷也不会漏刷。
而且Ticker回调带有一个Duration时间戳,其实是帧调度发生时的单调时间。理论上你也可以用它直接算差值,但我不建议把这个时间戳当计时基准,因为它仍然可能受帧间隔抖动影响。更好的分工是:Ticker负责“到点了,该更新界面了”,然后界面上剩余时间依然从Stopwatch读取真值。
三个类各司其职,整条链路就清晰了:
Stopwatch:真实的时间累积依据;Ticker:帧对齐的 UI 刷新触发;Timer:作为兜底,处理后台到点、非 UI 场景。
3. 核心算法拆解:用 Stopwatch 计算剩余,用 Ticker 刷新显示
按上面的分工,我实现了一个倒计时器模型。它的核心思想只有一句话:每次刷新都重新计算“当前真实剩余时间”,而不是在旧值上减1ms或100ms。这样累加误差就失去了积累的土壤。
3.1 核心思路转变
先看两种实现方式的反差。
错误方式:
int _remainMs = 30000; Timer.periodic(const Duration(milliseconds: 100), (_) { _remainMs -= 100; // 假设每次回调真的过了 100ms // 如果回调晚到 20ms,这 20ms 就被吞掉了 });正确方式:
Duration _remain() { final left = total - stopwatch.elapsed; return left.isNegative ? Duration.zero : left; }第一种是“按次数推测时间”,第二种是“按真实时间计算结果”。哪怕刷新频率掉到一秒一次,只要Stopwatch在走,剩余时间依然精确。
3.2 计时器模型实现
我把它封装成一个ChangeNotifier,方便后面跟 UI 解耦:
class CountdownModel extends ChangeNotifier { CountdownModel(this.total); final Duration total; final Stopwatch _stopwatch = Stopwatch(); bool _isRunning = false; bool _isFinished = false; bool get isRunning => _isRunning; bool get isFinished => _isFinished; Duration get remaining { final left = total - _stopwatch.elapsed; return left.isNegative ? Duration.zero : left; } void start() { if (_isRunning || _isFinished) return; _stopwatch.start(); _isRunning = true; notifyListeners(); } void pause() { if (!_isRunning) return; _stopwatch.stop(); _isRunning = false; notifyListeners(); } void reset() { _stopwatch ..stop() ..reset(); _isRunning = false; _isFinished = false; notifyListeners(); } void checkFinish() { if (_isFinished) return; if (_stopwatch.elapsed >= total) { _isFinished = true; _isRunning = false; _stopwatch.stop(); notifyListeners(); } } }注意checkFinish是单独的方法,不前贴在remaininggetter 里。因为 getter 会被 UI 构建频繁调用,不应该在里面做带副作用的状态变更。
3.3 页面帧循环接入
页面端我用SingleTickerProviderStateMixin拿到 Ticker,并让它与模型状态联动:
class CountdownPage extends StatefulWidget { const CountdownPage({super.key, required this.model}); final CountdownModel model; @override State<CountdownPage> createState() => _CountdownPageState(); } class _CountdownPageState extends State<CountdownPage> with SingleTickerProviderStateMixin { late final Ticker _ticker; late final ValueNotifier<String> _display; @override void initState() { super.initState(); _display = ValueNotifier(_format(widget.model.remaining)); _ticker = createTicker(_onTick); widget.model.addListener(_syncFromModel); _syncFromModel(); } void _syncFromModel() { if (!mounted) return; _display.value = _format(widget.model.remaining); if (widget.model.isRunning && !_ticker.isActive) { _ticker.start(); } else if (!widget.model.isRunning && _ticker.isActive) { _ticker.stop(); } } void _onTick(Duration timeStamp) { if (!mounted) return; widget.model.checkFinish(); _display.value = _format(widget.model.remaining); } String _format(Duration d) { final h = d.inHours.toString().padLeft(2, '0'); final m = (d.inMinutes % 60).toString().padLeft(2, '0'); final s = (d.inSeconds % 60).toString().padLeft(2, '0'); final ms = (d.inMilliseconds % 1000).toString().padLeft(3, '0'); return '$h:$m:$s.$ms'; } @override void dispose() { widget.model.removeListener(_syncFromModel); _ticker.dispose(); _display.dispose(); super.dispose(); } }这里有个细节:_syncFromModel既监听模型开始/暂停/重置,也承担了 Ticker 的启停职责。这样 UI 不需要关心什么时候开始刷帧,模型一notifyListeners,刷帧自然跟着跑。
3.4 暂停、恢复与到点归零
暂停时_stopwatch.stop()会把 elapsed 冻结在原地,恢复时再次start()会从冻结点继续累计。这是Stopwatch自带的能力,所以暂停/恢复本身不会引入误差。
到点归零则要处理一个边界:剩余时间可能在两次刷新之间已经变成了负数。所以在_onTick里先调用checkFinish(),再把剩余时间 clamp 到零。这样即使 Ticker 偶发停了一帧,最终界面也不会出现-00:00:01.230这种鬼东西。
3.5 后台切换补偿
Ticker 在应用退到后台时会停止回调,但Stopwatch会继续走。用户切回前台时,如果只靠 Ticker 恢复后的第一帧更新,可能会看到数字“跳”了一下。为了体验平滑,我在页面里监听生命周期:
class _CountdownPageState extends State<CountdownPage> with SingleTickerProviderStateMixin, WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _syncFromModel(); // 回前台立即对表 } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } }这个“回前台对表”的动作成本极低,但能避免用户切回来看到半秒级跳变。
4. 状态同步:计时器模型与 Widget 生命周期如何相处
计时器最大的特点是“状态在时间轴上持续变化”。如果这个状态只属于某个 Widget,页面一切换就丢了;如果放在全局变量里,又到处都是监听和清理问题。所以状态同步的设计,决定了计时器能不能跨页面、跨组件稳定工作。
4.1 为什么不把状态藏在 setState 里
把Stopwatch和remaining放在 State 内部,写起来最省事,但有两个致命问题。
第一,页面一旦被路由 pop 销毁,计时器跟着没了。如果你想从详情页切到设置页,再回来计时还在继续,就必须把模型提升到页面之外。
第二,setState会重建当前 State 对应的整个 Widget 子树。显示毫秒的计时器每秒要刷新 60 次,每次刷新都重建页面里的按钮、背景、装饰组件,完全没必要。
所以我把CountdownModel放在页面之外创建,通过构造函数或Provider传入。它的变化通过ChangeNotifier广播,页面只需要订阅自己关心的部分。
4.2 单实例还是多实例
这取决于业务逻辑。如果整个应用只允许一个计时器在跑,比如全局打卡倒计时,那就在应用顶层创建单例模型,页面都拿同一个实例。如果每个卡片各自计时,比如多个任务并行倒计时,那就在每个卡片创建独立模型。
我建议不要硬套全局单例。全局单例虽然方便,但会带来测试和生命周期管理的麻烦。更合理的做法是“谁需要谁创建,谁拥有谁销毁”,页面生命周期跟模型生命周期尽量一致。
4.3 安全的监听、取消与销毁
ChangeNotifier的监听坑,主要体现在两点:一是在dispose后依然收到通知;二是忘记 removeListener 导致内存泄漏。我的处理规则很简单:
- 所有
addListener的地方,配对的removeListener必须写进dispose; - 回调第一行都检查
mounted; - 模型对象如果是由页面创建的,在页面 dispose 时一并销毁;
- 模型对象如果是应用级共享的,页面只 removeListener,不负责 dispose。
4.4 多页面共享与回前台同步
多页面共享同一个模型时,其它页面不需要跑 Ticker,但需要在重新可见时读一次最新值。我通常让每个展示计时器的页面都监听模型,在initState里先同步一次。这样即使某个页面之前处于不可见状态、Ticker 没跑,它重新出现时也能立刻拿到当前准确的剩余时间。
另外,多个ValueListenableBuilder同时监听同一个ValueNotifier<String>是安全的。模型一次notifyListeners,所有监听者都会回调,Flutter 内部会合并到同一帧里处理,不会出现同一时间多个页面的数字进度不一致。
if (mounted) { // 这里更新某个文本 _display.value = _format(remaining); }代码不多,但这是整个状态同步链路里最容易漏的一环。
5. UI 响应式设计:毫秒级刷新下的重建边界控制
高精度计时器的 UI 压力在于:数据更新频率高,但实际变化的元素很少。最好的响应式设计,不是让整个页面响应,而是让“应该变化的节点”响应。
5.1 每一帧都 setState 的代价
假设页面结构是:
- 外层 SafeArea
- 标题栏
- 一个大的背景图
- 中间的时间文本
- 底部一排按钮
如果在根 State 里每帧setState,背景图和按钮都会跟着进入 build。哪怕 Flutter 的 widget 树 diff 很快,对于复杂页面来说也是一种浪费。放到 OpenHarmony 的 Flutter 适配场景里,自绘渲染引擎在纹理上传、平台通道上的开销比传统 Flutter 环境更大,这种浪费更容易放大成肉眼可见的卡顿。
5.2 用 ValueListenableBuilder 把刷新钉在时间文本上
我前面的实现里已经用了ValueNotifier<String>保存格式化后的时间文本。UI 侧,只有时间文本组件监听这个 notifier:
ValueListenableBuilder<String>( valueListenable: _display, builder: (context, value, child) { return Text( value, style: const TextStyle( fontSize: 48, fontWeight: FontWeight.w600, ), ); }, )这样 Ticker 每次回调只改变_display.value,Flutter 会精准地只重建这个Text组件。页面的按钮、背景、标题全部不参与重建。
模型层面的事件仍然通过ListenableBuilder监听。比如“开始”按钮的文案要从“开始”变成“暂停”,就只监听模型状态,不监听毫秒刷新:
ListenableBuilder( listenable: widget.model, builder: (context, child) { return FilledButton( onPressed: widget.model.isRunning ? widget.model.pause : widget.model.start, child: Text(widget.model.isRunning ? '暂停' : '开始'), ); }, )至此,页面上同时存在两条更新链路:一条是高频“显示数据”,一条是低频“模型状态”。两者互不干扰。
5.3 数字跳动与字体特性
时间文本每帧都在变,位数不同会导致数字水平跳动。比如00:10.050变到00:10.049,最后一位变了,前面宽度也可能变化。解决办法是给Text加上等宽数字特性:
Text( value, style: TextStyle( fontSize: 48, fontFeatures: const [FontFeature.tabularFigures()], ), )这个细节会让倒计时显示稳如老狗,不会像普通数字字体那样左右抖。
5.4 用 RepaintBoundary 隔离绘制
即使 widget 树重建范围控制住了,Flutter 在绘制阶段仍然可能重绘整个图层。给时间文本区域套一层RepaintBoundary,可以把绘制边界隔离出来:
RepaintBoundary( child: ValueListenableBuilder<String>( valueListenable: _display, builder: (context, value, child) { return Text(value, style: ...); }, ), )这样页面其它部分的内容如果产生变化,不会跟计时文本飙到同一个重绘区域里。实测在 OpenHarmony 平台上,这个隔离对降低 GPU 负载是有帮助的,尤其是页面还有其他复杂组件时。
5.5 刷新频率的工程取舍
最后一个问题是:到底要不要每帧刷新?
如果只显示到秒,每帧刷新纯属浪费。我一般这么定:
- 显示秒级:每 200ms 刷新一次;
- 显示百毫秒:每 100ms 刷新一次;
- 显示毫秒级且用于测量/竞速:每帧刷新。
刷新触发可以用 Timer,也可以用 Ticker。用 Ticker 可以获得跟 VSync 对齐的节奏,代价是后台不跑;用 Timer 的好处是可以控制在后台也继续触发,但不适合驱动跟帧同步的动画。我的选择是“显示精度需要毫秒级就帧刷新,否则用低频 Timer 配合 Stopwatch 读数”,这样把功耗和流畅度平衡好。
6. 真机实测对比与那些值得记住的坑
理论说了那么多,最后还是得看数据。我在同一台 OpenHarmony 开发板上、同一个 Flutter 适配版本下,分别用三种方案跑了 10 分钟倒计时,对比它们跟系统秒表的偏差。
6.1 三套方案的实测对比
| 实现方案 | 10 分钟偏差 | UI 流畅度 | 后台行为 |
|---|---|---|---|
| 纯 Timer.periodic 累加 | 慢约 1.2~1.8 秒 | 偶见卡顿、文字跳变 | 后台回调可能被挂起 |
| Timer + Stopwatch 读数 | 约 100ms 以内 | 基本稳定,偶发掉帧 | 到点回调仍可触发 |
| Ticker + Stopwatch 读数 | 约 30ms 以内 | 帧对齐,视觉最平滑 | 后台 Ticker 停,回前台对表 |
这组数据不是要说明 Ticker 方案“永远最好”,而是想说明:精度提升主要来源是 Stopwatch,UI 流畅度提升主要来源是 Ticker。两者加在一起,才是我要的体验。
6.2 坑案例一:连续点击开始按钮导致 Ticker 断言
第一次接入 Ticker 时,我直接在start()监听里无条件调用_ticker.start()。结果快速连点开始按钮时,Flutter 直接抛了 assertion。原因是 Ticker 在isActive状态下不能重复start()。
修复很简单,就是那句if (!_ticker.isActive):
if (widget.model.isRunning && !_ticker.isActive) { _ticker.start(); } else if (!widget.model.isRunning && _ticker.isActive) { _ticker.stop(); }6.3 坑案例二:到点回调触发两次
一开始我把到点判断同时放在了 Ticker 回调和Timer兜底里。假设用户设 3 秒倒计时,最后一帧还没走完时 Timer 先触发了,随后 Ticker 又触发一次,导致完成事件被执行两次,页面弹了两个提示框。
解决办法是给模型加_isFinished守卫。所有触发完成的入口,都先检查这个标记:
void checkFinish() { if (_isFinished) return; if (_stopwatch.elapsed >= total) { _isFinished = true; _isRunning = false; _stopwatch.stop(); notifyListeners(); } }如果一个状态变更只有一处是真相来源,其它地方最多只能“建议”它改变,不能“命令”它改变。
6.4 坑案例三:首次启动头帧延迟
在 OpenHarmony 上我还遇到一个现象:计时器刚开始跑的那一两秒,数字变化之间有轻微停顿。排查后发现,是因为 Flutter engine 在冷启动后的前几帧要做大量初始化工作,Ticker 回调间隔被拉长到 50ms 以上。
处理方式没有魔法,就是在真正开始计时前先“预热”一帧空转:
_preheat() { final ticker = createTicker((_) { ticker.dispose(); widget.model.start(); }); ticker.start(); }预热一帧后再启动 Stopwatch,保证用户按下开始键的那一刻,Ticker 调度已经稳定在正常帧节奏上。你未必每次都需要这个操作,但如果设备性能偏弱或者适配层开销偏大,值得加上。
6.5 我最终建议的完整组合
如果你让我总结一套可以直接复用的组合,我会这样搭:
- 时间基准一律用
Stopwatch,不要自己数 Tick; - 界面要帧对齐刷新时用
Ticker,不要裸用setState; - 模型用
ChangeNotifier,页面只管订阅和销毁; - UI 更新用
ValueListenableBuilder钉在最小显示节点上; - 到点通知用
Timer兜底,但结果状态以模型为准; - 回前台或页面重建时,第一时间从模型同步最新值。
这套组合在 OpenHarmony 开发板上跑出来的效果,已经能满足我那个计时器产品原型的需求。从一个“看起来能用”的 Timer 版本,换成“真实可信”的 Stopwatch 版本,代码改动量并不大,但跑完十分钟之后的信任感完全不一样。
最后再分享一个小技巧:如果你要做的是秒表而不是倒计时,核心逻辑完全一样,只是把remaining换成elapsed,把checkFinish换成checkResetThreshold之类的业务判断。时间源、刷新驱动、状态模型和 UI 边界这四层架构,是一套可以复用的骨架。