这篇我们讲解一个魔方计时应用的案例。
我们就单纯讲一件事:计时逻辑是怎么跑起来的。

在这个项目里,计时的核心并不复杂,主要由三部分组成:
TimerController:负责保存当前计时状态。Stopwatch:负责真正计算经过了多久。Ticker:负责让页面持续刷新,把Stopwatch.elapsed显示出来。
简单理解就是:
Stopwatch管时间,Ticker管刷新,TimerController管状态。
一、先看整体流程
普通计时的完整流程大概是这样:
text
空闲 idle
↓ 长按屏幕
准备 holding
↓ 松手
正式计时 running
↓ 轻触屏幕
停止计时 idle
如果开启了观察倒计时,中间会多一步:
text
空闲 idle
↓ 长按屏幕
准备 holding
↓ 松手
观察 inspection
↓ 轻触屏幕
正式计时 running
↓ 轻触屏幕
停止计时 idle
二、计时状态机
状态定义在 lib/features/timer/state/timer_controller.dart 里。
dart
enum TimerPhase { idle, holding, inspection, running }
这里一共定义了 4 个状态:
| 状态 | 含义 |
|---|---|
idle |
空闲状态,页面正常显示 |
holding |
用户正在长按,准备开始 |
inspection |
观察倒计时中 |
running |
正式计时中 |
这里没有单独设计 paused 暂停状态。
因为魔方计时器的核心场景通常是:开始一把、结束一把、记录成绩。中途暂停反而会破坏计时,所以当前项目只保留这几个必要状态。
三、UI 状态对象
除了当前阶段,页面还需要知道当前显示的时间、观察剩余时间、上一次成绩等信息。
所以项目里用 TimerUiState 统一收口。
dart
class TimerUiState {
const TimerUiState({
required this.scramble,
required this.phase,
required this.liveDuration,
required this.variant,
required this.inspectionRemaining,
this.lastEntry,
this.inspectionPenalty,
});
final String scramble;
final TimerPhase phase;
final Duration liveDuration;
final PuzzleVariant variant;
final Duration inspectionRemaining;
final SolveEntry? lastEntry;
final SolvePenalty? inspectionPenalty;
}
这里跟计时直接相关的主要是这几个字段:
| 字段 | 作用 |
|---|---|
phase |
当前处于哪个计时阶段 |
liveDuration |
正式计时中的实时用时 |
inspectionRemaining |
观察倒计时剩余时间 |
inspectionPenalty |
观察超时后的自动判罚 |
也就是说,页面并不会自己到处存一堆零散变量,而是统一从 TimerUiState 里面读状态。
这样后面 UI 刷新就会比较清晰。
四、TimerController 只负责状态变化
TimerController 继承自 ChangeNotifier。
它的职责不是"真正计时",而是把状态改掉,并通知页面刷新。
例如开始准备:
dart
void startHold() {
if (_state.phase == TimerPhase.running) {
return;
}
_state = _state.copyWith(
phase: TimerPhase.holding,
liveDuration: Duration.zero,
);
notifyListeners();
}
正式开始计时:
dart
void startTiming() {
_state = _state.copyWith(
phase: TimerPhase.running,
liveDuration: Duration.zero,
inspectionRemaining: Duration.zero,
);
notifyListeners();
}
更新实时用时:
dart
void updateLiveDuration(Duration duration) {
if (_state.liveDuration == duration) {
return;
}
_state = _state.copyWith(liveDuration: duration);
notifyListeners();
}
完成一把:
dart
void completeSolve() {
_state = _state.copyWith(
phase: TimerPhase.idle,
liveDuration: Duration.zero,
inspectionRemaining: Duration.zero,
inspectionPenalty: null,
);
notifyListeners();
}
这里有一个很重要的点:
TimerController不直接持有Stopwatch。
它只保存"现在应该显示什么状态"。真正的计时对象放在页面的 State 里。
五、页面里的核心计时对象
计时页在 _TimerViewState 里定义了几个关键对象。
dart
class _TimerViewState extends State<_TimerView>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
final Stopwatch _stopwatch = Stopwatch();
Timer? _inspectionTimer;
DateTime? _inspectionStart;
bool _inspectionPenaltyNotified = false;
static const Duration _inspectionLimit = Duration(seconds: 15);
static const Duration _inspectionPlusTwoLimit = Duration(seconds: 17);
TimerController? _controller;
}
这几个对象分工很明确。
| 对象 | 作用 |
|---|---|
_stopwatch |
真正记录正式计时经过了多久 |
_ticker |
每一帧触发一次回调,用来刷新实时显示 |
_inspectionTimer |
观察倒计时使用的定时器 |
_inspectionStart |
观察倒计时开始时间 |
_controller |
更新页面状态 |
这里最关键的是:正式计时没有用 Timer.periodic 来累加毫秒数,而是使用 Stopwatch。
六、为什么正式计时用 Stopwatch
很多刚开始写计时器时,可能会这样写:
dart
Timer.periodic(const Duration(milliseconds: 10), (_) {
duration += const Duration(milliseconds: 10);
});
这种写法看起来简单,但有一个问题:
定时器回调不一定严格按 10ms 触发。
如果页面卡顿、系统调度延迟,回调可能晚一点执行。这样一直累加固定时间,误差就会越来越明显。
所以项目里正式计时的思路是:
text
时间来源:Stopwatch.elapsed
刷新来源:Ticker
Ticker 只是告诉页面"现在可以刷新了"。
真正显示多少时间,要从 _stopwatch.elapsed 读取。
七、初始化 Ticker
Ticker 在 initState() 里创建。
dart
@override
void initState() {
super.initState();
_ticker = createTicker(_handleTick);
}
销毁页面时,也要释放它。
dart
@override
void dispose() {
_inspectionTimer?.cancel();
_ticker.dispose();
_keyboardFocusNode.dispose();
super.dispose();
}
这里有两个注意点:
Ticker是页面生命周期资源,创建后必须dispose()。- 观察倒计时的
_inspectionTimer也要在退出页面时取消。
八、每一帧如何刷新时间
Ticker 每次触发时,会调用 _handleTick()。
dart
void _handleTick(Duration elapsed) {
final controller = _controller;
if (controller == null || !_stopwatch.isRunning) {
return;
}
controller.updateLiveDuration(_stopwatch.elapsed);
}
这里传进来的 elapsed 参数并没有直接使用。
原因是我们真正关心的是 _stopwatch.elapsed。
这样就能保证:
- 页面刷新频率可以不稳定。
- 但显示的时间始终以
Stopwatch的真实经过时间为准。
九、长按进入准备状态
用户长按屏幕时,会触发 _handleLongPressStart()。
dart
void _handleLongPressStart(LongPressStartDetails details) {
final controller = _controller;
if (controller == null) {
return;
}
final phase = controller.state.phase;
if (phase != TimerPhase.idle && phase != TimerPhase.holding) {
return;
}
controller.startHold();
}
这里做了一个保护:
dart
if (phase != TimerPhase.idle && phase != TimerPhase.holding) {
return;
}
也就是说,只有空闲状态或者已经在准备状态时,长按才有效。
如果正在计时,就不会因为误触长按而重新开始。
十、松手后开始计时
长按结束时,会进入 _handleLongPressEnd()。
dart
void _handleLongPressEnd(LongPressEndDetails details) {
final controller = _controller;
if (controller == null) {
return;
}
if (!controller.isHolding) {
return;
}
if (_inspectionEnabled) {
_startInspectionCountdown();
} else {
_startTiming();
}
}
这里有两个分支:
- 如果开启观察倒计时:先进入
inspection。 - 如果没有开启观察倒计时:直接开始正式计时。
这也是为什么前面状态机里会有 inspection 这个阶段。
十一、正式开始计时
正式计时的核心代码很短。
dart
void _startTiming() {
final controller = _controller;
if (controller == null) {
return;
}
controller.startTiming();
_stopwatch
..reset()
..start();
_ticker.start();
}
这里按顺序做了三件事:
controller.startTiming():把状态切到running。_stopwatch.reset()+_stopwatch.start():从 0 开始计时。_ticker.start():开始持续刷新页面显示。
这个顺序比较重要。
先切状态,再启动计时和刷新,这样 UI 层能根据 running 状态切换到全屏计时界面。
十二、从观察阶段开始计时
如果用户开启了观察倒计时,那么正式计时不是从松手直接开始,而是观察阶段点击屏幕后开始。
dart
void _startTimingFromInspection() {
final controller = _controller;
if (controller == null || !controller.isInspection) {
return;
}
_stopInspectionTimer();
controller.startTiming();
_stopwatch
..reset()
..start();
_ticker.start();
}
相比 _startTiming(),这里多了一步:
dart
_stopInspectionTimer();
因为一旦正式计时开始,观察倒计时就应该停止。
十三、观察倒计时怎么做
观察倒计时用的是 Timer.periodic。
dart
void _startInspectionCountdown() {
final controller = _controller;
if (controller == null || controller.isRunning || !controller.isHolding) {
return;
}
_inspectionTimer?.cancel();
_inspectionPenaltyNotified = false;
_inspectionStart = DateTime.now();
controller.startInspection(_inspectionLimit);
_inspectionTimer = Timer.periodic(
const Duration(milliseconds: 100),
_handleInspectionTick,
);
}
这里每 100ms 检查一次观察剩余时间。
不过它也不是简单地每次减 100ms,而是用开始时间计算真实经过时间。
dart
void _handleInspectionTick(Timer timer) {
final controller = _controller;
final start = _inspectionStart;
if (controller == null || start == null || !controller.isInspection) {
timer.cancel();
return;
}
final elapsed = DateTime.now().difference(start);
final remaining = _inspectionLimit - elapsed;
controller.updateInspectionRemaining(
remaining.isNegative ? Duration.zero : remaining,
);
if (elapsed > _inspectionPlusTwoLimit) {
_applyInspectionPenalty(SolvePenalty.dnf);
} else if (elapsed > _inspectionLimit) {
_applyInspectionPenalty(SolvePenalty.plusTwo);
}
}
这里的思路和正式计时类似:
定时器只是触发检查,真实时间差用
DateTime.now().difference(start)计算。
这样即使某一次 Timer.periodic 回调晚了一点,剩余时间也不会一直累计误差。
十四、观察超时判罚
WCA 规则里,观察时间有两个关键节点:
| 时间 | 结果 |
|---|---|
≤ 15s |
正常 |
15s - 17s |
+2 |
> 17s |
DNF |
项目里对应两个常量:
dart
static const Duration _inspectionLimit = Duration(seconds: 15);
static const Duration _inspectionPlusTwoLimit = Duration(seconds: 17);
当观察时间超过限制时,会写入 inspectionPenalty。
dart
void _applyInspectionPenalty(SolvePenalty penalty) {
final controller = _controller;
if (controller == null) {
return;
}
if (controller.state.inspectionPenalty == penalty) {
return;
}
controller.setInspectionPenalty(penalty);
}
这里有一个小细节:
dart
if (controller.state.inspectionPenalty == penalty) {
return;
}
避免同一种判罚重复触发,尤其是 Timer.periodic 会不断回调。
十五、停止计时
正式计时过程中,轻触屏幕会触发停止逻辑。
核心代码在 _handleTapStop() 里。
dart
Future<void> _handleTapStop() async {
final controller = _controller;
if (controller == null || !controller.isRunning) {
return;
}
_ticker.stop();
_stopwatch.stop();
final result = _stopwatch.elapsed;
_stopwatch.reset();
final inspectionPenalty = controller.state.inspectionPenalty;
controller.completeSolve();
SolvePenalty? penalty = inspectionPenalty;
final scramble = controller.state.scramble;
// 后面再做成绩确认、保存记录、刷新打乱等逻辑
}
这里最重要的是前几行:
dart
_ticker.stop();
_stopwatch.stop();
final result = _stopwatch.elapsed;
_stopwatch.reset();
顺序可以这样理解:
- 先停掉
Ticker,不用继续刷新 UI。 - 再停掉
Stopwatch,冻结当前用时。 - 读取
_stopwatch.elapsed,得到本次成绩。 - 最后
reset(),为下一把做准备。
拿到 result 后,计时这部分其实就结束了。
至于后面的弹窗确认、保存成绩、刷新打乱,属于记录模块和业务模块,这篇先不展开。
十六、计时显示层
计时过程中的全屏显示由 _FullScreenTimerOverlay 负责。
它不自己算时间,只根据 TimerUiState 决定显示什么。
dart
if (state.phase == TimerPhase.running) {
primaryText = _formatDuration(state.liveDuration);
secondaryText = '轻触屏幕任意位置即可结束';
} else if (state.phase == TimerPhase.inspection) {
final remainingSeconds = state.inspectionRemaining.inMilliseconds / 1000;
final isPlusTwo = state.inspectionPenalty == SolvePenalty.plusTwo;
final isDnf = state.inspectionPenalty == SolvePenalty.dnf;
if (isDnf) {
primaryText = 'DNF';
} else if (isPlusTwo) {
primaryText = '+2';
} else {
primaryText = remainingSeconds.clamp(0, 17.0).toStringAsFixed(1);
}
} else {
primaryText = '松手立即开始';
secondaryText = '保持长按以准备';
}
也就是说:
running:显示实时计时。inspection:显示观察倒计时或判罚。holding:显示"松手立即开始"。idle:不显示全屏遮罩。
这就是前面状态机的好处。
UI 不需要关心"这个状态是怎么来的",只需要根据状态显示对应内容。
十七、时间格式化
最后看一下显示格式。
dart
String _formatDuration(Duration? duration) {
if (duration == null) {
return '0.000';
}
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
final milliseconds = duration.inMilliseconds % 1000;
if (minutes > 0) {
final minutePart = minutes.toString().padLeft(2, '0');
final secondPart = seconds.toString().padLeft(2, '0');
final milliPart = milliseconds.toString().padLeft(3, '0');
return '$minutePart:$secondPart.$milliPart';
}
final value = duration.inMilliseconds / 1000;
return value.toStringAsFixed(3);
}
这里做了两种格式:
| 用时 | 显示格式 |
|---|---|
| 小于 1 分钟 | 12.345 |
| 大于等于 1 分钟 | 01:12.345 |
这样短成绩看起来更简洁,长成绩又不会丢掉分钟信息。
十八、几个容易踩坑的点
18.1 不要用 Timer.periodic 累加正式计时
正式计时建议以 Stopwatch.elapsed 为准。
Ticker 或 Timer.periodic 都可以用来触发刷新,但不要把它们当成真实时间来源。
18.2 停止时先保存结果再重置
错误写法:
dart
_stopwatch.stop();
_stopwatch.reset();
final result = _stopwatch.elapsed;
这样拿到的就是 0。
正确顺序是:
dart
_stopwatch.stop();
final result = _stopwatch.elapsed;
_stopwatch.reset();
18.3 页面销毁时要清理资源
Ticker 和 Timer 都不是普通变量。
页面销毁时要处理:
dart
_inspectionTimer?.cancel();
_ticker.dispose();
否则可能出现页面退出后仍然回调的问题。
18.4 状态变化集中到 Controller
不要在 UI 里到处写 isRunning、isHolding、currentDuration。
统一交给 TimerController,后面维护会轻松很多。
十九、总结
这篇我们只看了这个魔方计时器应用中最核心的计时逻辑。
整体可以总结成一句话:
用
TimerPhase管流程,用Stopwatch管真实时间,用Ticker管页面刷新。
这样拆开以后,计时器本身其实并不复杂。
复杂的是后续围绕它扩展出来的模块:成绩保存、分组统计、PB、训练计划、挑战模式等。
但只要底层计时逻辑稳定,后面的功能都可以在这个基础上继续叠加。