背景与目标
最近为了提高工作专注度,作者打算用鸿蒙 ArkUI 做一个番茄钟。目标不是只显示一个倒计时,而是把工作、短休息、长休息和循环切换串起来。鸿蒙提供的计时器能力里,setInterval 适合周期性执行,setTimeout 适合延迟执行一次,requestAnimationFrame 用于动画帧回调;这个番茄钟选择 setInterval,按秒更新剩余时间。
状态与流程设计
先把番茄钟状态拆成 WORK、SHORT_BREAK、LONG_BREAK、IDLE 四种。需要用 @State 驱动 UI:state 表示当前阶段,timeLeft 表示剩余秒数,completedPomodoros 记录已完成番茄数,timerId 保存计时器句柄。
- enum PomodoroState {
- WORK, // 工作中
- SHORT_BREAK, // 短休息
- LONG_BREAK, // 长休息
- IDLE // 空闲
- }
- @State state: PomodoroState = PomodoroState.IDLE;
- @State timeLeft: number = 25 * 60; // 剩余秒数
- @State completedPomodoros: number = 0;
- private timerId: number = -1;
复制代码
这里的 25 * 60 对应一个标准工作时段的 25 分钟,短休息为 5 分钟,长休息为 15 分钟。流程上每完成一个工作番茄就累加 completedPomodoros;每完成 4 个番茄进入长休息,否则进入短休息;休息结束后再回到工作状态。
用 setInterval 驱动倒计时
核心计时逻辑是启动一个每秒执行一次的 setInterval。剩余时间大于 0 就递减;递减到 0 时调用结束处理,并停止当前计时器。
- private startTimer(): void {
- this.timerId = setInterval(() => {
- if (this.timeLeft > 0) {
- this.timeLeft--;
- } else {
- this.handleTimerEnd();
- }
- }, 1000);
- }
- private stopTimer(): void {
- if (this.timerId !== -1) {
- clearInterval(this.timerId);
- this.timerId = -1;
- }
- }
复制代码
需要特别注意,handleTimerEnd 中要先 stopTimer,再切换状态和重置 timeLeft,最后重新 startTimer。否则会出现旧计时器未清理、多个计时器同时跑的问题。原文的切换逻辑如下:
- private handleTimerEnd(): void {
- this.stopTimer();
- if (this.state === PomodoroState.WORK) {
- this.completedPomodoros++;
- if (this.completedPomodoros % 4 === 0) {
- this.state = PomodoroState.LONG_BREAK;
- this.timeLeft = 15 * 60;
- } else {
- this.state = PomodoroState.SHORT_BREAK;
- this.timeLeft = 5 * 60;
- }
- } else {
- this.state = PomodoroState.WORK;
- this.timeLeft = 25 * 60;
- }
- this.startTimer();
- }
复制代码
时间格式化与环形进度
剩余秒数需要展示成 mm:ss。原文用 formatTime 做转换,并用 padStart 补零。
- private formatTime(seconds: number): string {
- const mins = Math.floor(seconds / 60);
- const secs = seconds % 60;
- return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
- }
复制代码
UI 侧用 Progress 组件显示进度,类型选择 ProgressType.Ring,宽高各 200。value 绑定 timeLeft,total 则根据当前状态取工作时长或休息时长。原文示例里工作状态按 25 * 60 计算,其余按 5 * 60 计算。
- Progress({
- value: this.timeLeft,
- total: this.state === PomodoroState.WORK ? 25 * 60 : 5 * 60,
- type: ProgressType.Ring
- })
- .width(200)
- .height(200)
复制代码
问题一:setInterval 不精确,长时间累积误差
直接用 setInterval 每秒 timeLeft-- 看起来简单,但它并不精确。回调触发时间会受主线程任务、系统调度影响,长时间运行后可能累积误差。对番茄钟这类需要持续几十秒到几分钟的倒计时,更稳妥的方式是用时间戳计算已经过去的时间,而不是只依赖回调次数。原文给出的改进方式是在启动时记录 Date.now(),定时器按更短间隔检查,并用当前时间戳减去 startTime 得到 elapsed,再反推 timeLeft。
- private startTime: number = 0;
- private totalDuration: number = 25 * 60;
- private startTimer(): void {
- this.startTime = Date.now();
- this.timerId = setInterval(() => {
- const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
- this.timeLeft = this.totalDuration - elapsed;
- if (this.timeLeft <= 0) {
- this.handleTimerEnd();
- }
- }, 100);
- }
复制代码
这段改造体现了倒计时类应用的一个常见原则:setInterval 只负责触发检查,真正的时间基准要来自系统时间戳。这样即使某次回调被延迟,下一次也能根据真实时间差校正显示。
问题二:后台运行与状态恢复
应用切到后台后,setInterval 可能会变慢,倒计时更新不再可靠。原文提到两种方向:一是用 backgroundTaskManager 申请后台任务,二是用本地通知提醒。前者偏向让应用在后台继续执行必要任务,后者偏向在关键时间点提醒用户,具体选择要看番茄钟希望达到的后台能力。
另一个容易被忽略的问题是状态持久化。应用进程被杀死后,当前状态、剩余时间、已完成番茄数都会丢失。原文建议用 Preferences 保存当前状态和时间戳,下次启动时恢复。这里需要保存当前处于哪个阶段、该阶段的总时长、开始时间或剩余时间,否则恢复后无法准确续算。
小结
这个番茄钟的实现并不复杂:用 ArkUI 的 @State 管理状态,用 setInterval 驱动刷新,用 Progress 展示环形进度,再按完成数量切换工作、短休息和长休息。真正影响体验的是细节:计时精度要用时间戳校正,后台运行要处理 setInterval 变慢,进程被杀后要用 Preferences 做状态恢复。原文还提到可以增加统计功能,记录每天完成了多少个番茄,这能进一步提升使用成就感。 |