【Flutter x HarmonyOS 6】魔方计时APP——计时逻辑实现

这篇我们讲解一个魔方计时应用的案例。

我们就单纯讲一件事:计时逻辑是怎么跑起来的

在这个项目里,计时的核心并不复杂,主要由三部分组成:

  1. TimerController:负责保存当前计时状态。
  2. Stopwatch:负责真正计算经过了多久。
  3. 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

TickerinitState() 里创建。

dart 复制代码
@override
void initState() {
  super.initState();
  _ticker = createTicker(_handleTick);
}

销毁页面时,也要释放它。

dart 复制代码
@override
void dispose() {
  _inspectionTimer?.cancel();
  _ticker.dispose();
  _keyboardFocusNode.dispose();
  super.dispose();
}

这里有两个注意点:

  1. Ticker 是页面生命周期资源,创建后必须 dispose()
  2. 观察倒计时的 _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();
  }
}

这里有两个分支:

  1. 如果开启观察倒计时:先进入 inspection
  2. 如果没有开启观察倒计时:直接开始正式计时。

这也是为什么前面状态机里会有 inspection 这个阶段。


十一、正式开始计时

正式计时的核心代码很短。

dart 复制代码
void _startTiming() {
  final controller = _controller;
  if (controller == null) {
    return;
  }
  controller.startTiming();
  _stopwatch
    ..reset()
    ..start();
  _ticker.start();
}

这里按顺序做了三件事:

  1. controller.startTiming():把状态切到 running
  2. _stopwatch.reset() + _stopwatch.start():从 0 开始计时。
  3. _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();

顺序可以这样理解:

  1. 先停掉 Ticker,不用继续刷新 UI。
  2. 再停掉 Stopwatch,冻结当前用时。
  3. 读取 _stopwatch.elapsed,得到本次成绩。
  4. 最后 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 为准。

TickerTimer.periodic 都可以用来触发刷新,但不要把它们当成真实时间来源。

18.2 停止时先保存结果再重置

错误写法:

dart 复制代码
_stopwatch.stop();
_stopwatch.reset();
final result = _stopwatch.elapsed;

这样拿到的就是 0

正确顺序是:

dart 复制代码
_stopwatch.stop();
final result = _stopwatch.elapsed;
_stopwatch.reset();

18.3 页面销毁时要清理资源

TickerTimer 都不是普通变量。

页面销毁时要处理:

dart 复制代码
_inspectionTimer?.cancel();
_ticker.dispose();

否则可能出现页面退出后仍然回调的问题。

18.4 状态变化集中到 Controller

不要在 UI 里到处写 isRunningisHoldingcurrentDuration

统一交给 TimerController,后面维护会轻松很多。


十九、总结

这篇我们只看了这个魔方计时器应用中最核心的计时逻辑。

整体可以总结成一句话:

TimerPhase 管流程,用 Stopwatch 管真实时间,用 Ticker 管页面刷新。

这样拆开以后,计时器本身其实并不复杂。

复杂的是后续围绕它扩展出来的模块:成绩保存、分组统计、PB、训练计划、挑战模式等。

但只要底层计时逻辑稳定,后面的功能都可以在这个基础上继续叠加。

相关推荐
用户游民2 小时前
Flutter Widget、Element、RenderObject 关联以及实现原理
flutter
用户95421573334852 小时前
彻底告别 `.w/.h/.sp`!Flutter 屏幕适配的底层玩法,一次接入全局生效
flutter
liulian09162 小时前
Flutter for OpenHarmony 跨平台开发:密码生成器功能实战指南
flutter
可有道理2 小时前
Flutter 抽象类、接口与mixin
flutter
MonkeyKing71553 小时前
Flutter路由高级管理实战:守卫、深链、多栈与Tab路由全解析
flutter
AlbertZein14 小时前
ImageKnifePro 源码解读:鸿蒙图片加载框架全貌
harmonyos
AlbertZein15 小时前
鸿蒙工程化:build-profile.json5 逐字段解析
harmonyos
weixin_4171970516 小时前
DeepSeek V4绑定华为:一场飞行中换引擎的国产算力革命
人工智能·华为
前端技术18 小时前
鸿蒙ArkTS 自定义底部导航栏(Tabs+@Builder 极简实现)
harmonyos·鸿蒙