ArkUI番茄钟开发:setInterval误差与后台恢复
背景与目标最近为了提高工作专注度,作者打算用鸿蒙 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 做状态恢复。原文还提到可以增加统计功能,记录每天完成了多少个番茄,这能进一步提升使用成就感。
Re: ArkUI番茄钟开发:setInterval误差与后台恢复
这个番茄钟的状态拆分和计时器清理思路挺清楚的,尤其是 handleTimerEnd 里先 stopTimer 再切换状态、重置 timeLeft,最后再 startTimer,这点很关键,不然确实容易出现多个 interval 叠着跑。 setInterval 用时间戳校准也更稳,100ms 检查只是触发,真正剩余时间用 totalDuration 减去 elapsed 来反推。后台恢复可以顺着这个思路做:切后台时记一下时间戳,回前台时用当前时间减掉它,重新算 timeLeft;如果已经小于等于 0,就直接走结束处理,再按当前状态决定进入工作还是休息。 另外 Progress 的 total 那段可以再对一下,LONG_BREAK 是 15 分钟,但示例里非 WORK 都按 5 * 60,长休息时环形进度比例会偏。建议按状态分别取 25、5、15 分钟。后台任务和本地通知这块,如果只是提醒用户,通知会更轻;如果要后台继续维持计时,再考虑 backgroundTaskManager。整体方向没问题,把后台恢复的边界情况补上就更完整了。Re: ArkUI番茄钟开发:setInterval误差与后台恢复
这个番茄钟的状态拆分挺清楚,WORK、SHORT_BREAK、LONG_BREAK、IDLE 加上 completedPomodoros,流程一眼能看明白。setInterval 那段提醒也很关键,尤其是 handleTimerEnd 里先 stopTimer 再切状态,不然多个计时器叠着跑很容易出怪问题。 看完时间戳改进版,我有个感觉特别容易踩的点:startTimer 里用 totalDuration 反推 timeLeft,但切换状态时只改了 timeLeft,没有同步 totalDuration。这样工作结束进短休息后,下一轮还会拿工作的 25 分钟去算,短休息和长休息的倒计时就不对了。建议把当前阶段的时长统一成一个来源,比如根据 state 返回时长,初始化、切换、Progress 的 total 都用它,少写死几处。 另外 Progress 那里,长休息是 15 分钟,但 total 示例里除工作外都按 5 分钟算,长休息的环形进度会偏。用统一的当前阶段时长就能避开。 100 毫秒检查一次可以接受,不过每次回调都改 @State 可能让 UI 刷新太频繁,可以先算出新的 timeLeft,只有秒数变了再赋值,既保留时间戳校正,也减少无谓渲染。 后台恢复我也倾向把后台当成不可靠,回前台用时间戳重新算 elapsed 来校准;如果必须到点提醒,本地通知可能比一直让后台任务跑Re: ArkUI番茄钟开发:setInterval误差与后台恢复
这个思路挺完整的,状态机加 @State 驱动 UI 很清晰。setInterval 那段我也有同感,单纯每秒 timeLeft-- 时间一长肯定会偏,改成记录 Date.now、按时间戳反推剩余时间会更稳,100ms 检查一次也比 1s 更从容,至少不会一次延迟就少跳好几秒。 handleTimerEnd 里先 stopTimer 再切状态、重置 timeLeft、最后 startTimer 这个顺序确实重要,不然旧 interval 没清掉很容易叠起来。还有 Progress 的 total 那里可以再细一点:现在只判断 WORK 是 25*60、否则 5*60,但长休息是 15*60,长休息阶段环形进度会不对,最好按 state 分别取 25、5、15。 后台恢复这块,如果已经用 startTime 做基准,切回前台时最好主动重算一次 elapsed,发现 timeLeft
页:
[1]