烹饪计时器看起来只是每秒减一,但它比普通数字动画更接近一个时间系统。用户会同时煮鸡蛋、炖排骨和蒸鱼;计时期间可能锁屏、切到菜谱步骤页、暂停某一个任务,或者在应用后台时到期。只依赖页面中的setInterval(),界面在前台短时间演示通常正常,一旦经历调度延迟和页面生命周期,剩余时间就可能漂移、冻结或丢失。
本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。CookingTimerPage.ets提供 8 个预设、自定义分钟数、多个并行计时器、暂停/恢复、删除和全部清除。当前实现为每个计时器创建独立 interval,每秒把remainingSeconds减一;到 0 后 interval 没有清除,暂停时 interval 也持续运行。页面离开时统一 clearInterval,但没有保存目标截止时间或恢复调度,重新进入后计时器可能被销毁,也可能保留状态却不再走动。
可靠倒计时的真源不是 interval 被调用了多少次,而是 timerId、运行状态、目标截止时间和暂停剩余量共同描述的 TimerSnapshot。
HEALTHY-RECIPE-ONE7-COOKING-TIMER-20260730
本文将完成以下内容:
- 还原 8 个预设、多计时器和当前 interval 链路。
- 解释逐秒减一为什么会产生时间漂移。
- 用
targetEndAt计算前台和生命周期恢复后的剩余时间。 - 设计 running、paused、completed 和 removed 状态。
- 用一个调度器更新多个计时器,及时停掉无效 tick。
- 让暂停、恢复、删除、全部清除具有幂等语义。
- 设计到期事件去重与可感知反馈。
- 覆盖非法分钟数、重复点击、前后台切换和多设备布局。
一、真实工程中的预设与状态
页面内置 8 个预设:
| 预设 | 秒数 | 展示时长 |
|---|---|---|
| 焯水 | 180 | 03:00 |
| 煮鸡蛋 | 480 | 08:00 |
| 蒸鱼 | 600 | 10:00 |
| 腌制 | 900 | 15:00 |
| 蒸米饭 | 1200 | 20:00 |
| 烤肉 | 1800 | 30:00 |
| 炖排骨 | 2400 | 40:00 |
| 煲汤 | 3600 | 01:00:00 |
活跃计时器模型如下:
interface ActiveTimer { id: number name: string emoji: string color: string totalSeconds: number remainingSeconds: number isRunning: boolean }页面状态还包括:
@State activeTimers: ActiveTimer[] = [] @State showCustom: boolean = false @State customMinutes: string = '' @State customName: string = '' private nextId: number = 1 private timerIntervalIds: Map<number, number> = new Map()这是一份适合渲染的模型,却没有描述“何时到期”。
二、当前 addTimer 的运行方式
每添加一个计时器就创建一个 interval:
const intervalId = setInterval(() => { const newTimers: ActiveTimer[] = [] for (const t of this.activeTimers) { if (t.id === timerId && t.isRunning && t.remainingSeconds > 0) { const remaining = t.remainingSeconds - 1 newTimers.push({ ...t, remainingSeconds: remaining, isRunning: remaining > 0 }) } else { newTimers.push(t) } } this.activeTimers = newTimers }, 1000)如果用户启动 6 个计时器,就会存在 6 个 interval;每个 interval 又遍历全部计时器。数据量不大时 CPU 压力仍可接受,但逻辑复杂度和生命周期管理已经不必要地放大。
三、到期后 interval 仍在运行
当 remaining 变成 0 时,模型的isRunning变成 false,并显示一次 Toast;但 intervalId 仍保留在 Map 中。后续每秒仍遍历 activeTimers,只是不再减一。
暂停状态也一样:interval 不会暂停,只是每秒把原对象推回新数组。页面看起来静止,后台仍有周期工作。
正确规则应该是:
- 只要存在 running 计时器,单一调度器运行。
- 所有计时器都 paused 或 completed 时,调度器停止。
- removed 项立即从存储和视图移除。
- completed 只触发一次到期事件。
四、逐秒减一为什么不等于真实时间
setInterval(callback, 1000)只表示“尽量在一秒后调度”,不保证精确每秒执行。主线程繁忙、页面不可见、系统节能或应用切到后台时,回调可能延迟。
例如一个 600 秒计时器实际经过 30 秒,但回调只执行 20 次,逐秒减一会显示 580 秒;真实剩余应是 570 秒。误差会累积。
可靠算法使用绝对时间:
remainingSeconds = ceil((targetEndAt - now) / 1000)interval 只负责让 UI 定期重新计算,不负责定义时间本身。
五、重新定义 TimerSnapshot
export type TimerStatus = 'running' | 'paused' | 'completed' export interface TimerSnapshot { timerId: string name: string emoji: string color: string totalSeconds: number remainingSeconds: number status: TimerStatus targetEndAt?: number createdAt: number updatedAt: number completedAt?: number completionNotified: boolean }字段边界如下:
- running 必须有 targetEndAt。
- paused 不依赖 targetEndAt,以 remainingSeconds 为真源。
- completed 的 remainingSeconds 固定为 0。
- completionNotified 防止同一到期事件重复弹出。
- timerId 是稳定字符串,不随剩余时间改变。
六、创建计时器时计算目标截止时间
function createTimer( name: string, seconds: number, now: number ): TimerSnapshot { return { timerId: `timer_${now}_${Math.random().toString(36).slice(2, 8)}`, name, emoji: '⏱', color: '#4CAF50', totalSeconds: seconds, remainingSeconds: seconds, status: 'running', targetEndAt: now + seconds * 1000, createdAt: now, updatedAt: now, completionNotified: false } }如果项目不希望使用随机字符串,可由服务层维护持久化序列;关键是 ID 不能在页面重建后复用。
七、统一剩余时间计算
function calculateRemaining(timer: TimerSnapshot, now: number): number { if (timer.status === 'completed') return 0 if (timer.status === 'paused') { return Math.max(0, Math.floor(timer.remainingSeconds)) } if (timer.targetEndAt === undefined) return 0 return Math.max(0, Math.ceil((timer.targetEndAt - now) / 1000)) }Math.ceil()可以避免尚有 100 毫秒时提前显示 0。最终到期判断仍使用targetEndAt <= now,不要只依赖格式化文本。
八、tick 应该是纯状态归一
interface TickResult { timers: TimerSnapshot[] completedTimerIds: string[] } function tickTimers(timers: TimerSnapshot[], now: number): TickResult { const completedTimerIds: string[] = [] const next = timers.map((timer) => { if (timer.status !== 'running') return timer const remaining = calculateRemaining(timer, now) if (remaining > 0) { return { ...timer, remainingSeconds: remaining, updatedAt: now } } completedTimerIds.push(timer.timerId) return { ...timer, remainingSeconds: 0, status: 'completed' as TimerStatus, completedAt: now, updatedAt: now } }) return { timers: next, completedTimerIds } }该函数不弹 Toast、不调用 ArkUI,也不访问存储,可用固定 now 做单元测试。
九、一个调度器管理所有计时器
private schedulerId?: number private startSchedulerIfNeeded(): void { const hasRunning = this.activeTimers.some((timer) => timer.status === 'running') if (!hasRunning || this.schedulerId !== undefined) return this.schedulerId = setInterval(() => this.onTick(), 250) } private stopScheduler(): void { if (this.schedulerId === undefined) return clearInterval(this.schedulerId) this.schedulerId = undefined }250 毫秒不是为了让秒数更快变化,而是减少跨秒边界的视觉延迟;真实剩余仍由 Date.now 计算。若更关注省电,可以使用 500 或 1000 毫秒。
十、每次 tick 后判断是否还需要调度
private onTick(): void { const result = tickTimers(this.activeTimers, Date.now()) this.activeTimers = result.timers result.completedTimerIds.forEach((id) => this.handleCompletion(id)) if (!this.activeTimers.some((timer) => timer.status === 'running')) { this.stopScheduler() } }这样暂停全部计时器、全部完成或删除全部后都不会继续空转。
十一、暂停是把截止时间固化为剩余量
function pauseTimer(timer: TimerSnapshot, now: number): TimerSnapshot { if (timer.status !== 'running') return timer const remaining = calculateRemaining(timer, now) return { ...timer, remainingSeconds: remaining, status: remaining > 0 ? 'paused' : 'completed', targetEndAt: undefined, updatedAt: now, completedAt: remaining > 0 ? undefined : now } }暂停后等待多久都不会减少,因为 remainingSeconds 已固定。
十二、恢复是重新建立截止时间
function resumeTimer(timer: TimerSnapshot, now: number): TimerSnapshot { if (timer.status !== 'paused' || timer.remainingSeconds <= 0) return timer return { ...timer, status: 'running', targetEndAt: now + timer.remainingSeconds * 1000, updatedAt: now } }恢复后调用startSchedulerIfNeeded(),不要为该 timer 再创建一个独立 interval。
十三、完成状态不能用 isRunning=false 猜测
当前模型中暂停和完成都是isRunning === false,只能再检查 remainingSeconds 区分。显式 status 能让 UI 和服务更清楚:
switch (timer.status) { case 'running': return '计时中' case 'paused': return '已暂停' case 'completed': return '已完成' }删除不是一个可恢复展示状态,服务完成删除后从数组移除即可;如果需要撤销,可以临时保存 DeleteSnapshot。
十四、到期事件必须去重
如果页面每 250 毫秒 tick,一旦状态已是 completed,后续 tick 不应重复通知。可以在处理后设置completionNotified = true:
private handleCompletion(timerId: string): void { const timer = this.activeTimers.find((item) => item.timerId === timerId) if (!timer || timer.completionNotified) return this.activeTimers = this.activeTimers.map((item) => item.timerId === timerId ? { ...item, completionNotified: true } : item ) promptAction.showToast({ message: `${timer.name}计时结束`, duration: 3000 }) }持久化 notified 能避免页面重建后再次提示同一完成事件。
十五、Toast 不是完整的到期提醒
Toast 只在用户正看页面时容易被发现。烹饪场景需要更明确的前台反馈,例如:
- 计时卡片变为完成态并置顶。
- 播放用户可控制的短提示音。
- 触发适度振动,但应遵守平台能力、权限和用户设置。
- 提供“知道了”或删除完成项。
如果要在应用后台可靠提醒,必须使用 HarmonyOS 官方允许的后台/通知能力,明确用户场景、权限与隐私说明;不能依赖隐藏常驻 interval 或规避系统限制。本文的本地页面方案只保证前台恢复时按绝对时间纠正,并触发未通知的完成事件。
十六、生命周期恢复不能只 clearInterval
当前aboutToDisappear()清掉所有 interval,但未保存;如果组件状态被保留,回来后 activeTimers 还在,调度却没有重启;如果组件销毁,则计时器全部消失。
建议生命周期链路:
aboutToAppear(): void { this.restoreTimers() this.reconcileTimers(Date.now()) this.startSchedulerIfNeeded() } aboutToDisappear(): void { this.stopScheduler() this.persistTimers() }restore 后立即用当前时间重算,已经到期的任务直接进入 completed,而不是从旧 remaining 继续倒数。
十七、持久化哪些字段
可以使用 Preferences 保存少量计时器快照:
interface TimerStore { schemaVersion: number revision: number timers: TimerSnapshot[] updatedAt: number }只保存业务字段,不保存 schedulerId 等运行时资源。应用重启后 running 项依据 targetEndAt 恢复,paused 项依据 remainingSeconds 恢复。
十八、存储结果要可观察
服务接口:
interface SaveTimerResult { ok: boolean revision?: number reason?: 'not_initialized' | 'serialize_failed' | 'write_failed' }添加计时器后如果保存失败,页面可以保留本次前台计时,但应标记“未保存”;不要提示“已开始”后让用户误以为离开页面仍能恢复。
十九、自定义分钟数要有边界
当前逻辑是:
const mins = Number(this.customMinutes) if (mins > 0) { this.addTimer(name, '⏱', mins * 60, color) }它没有限制小数、极大值或非有限数。建议:
function parseTimerMinutes(input: string): number | undefined { const minutes = Number(input.trim()) if (!Number.isFinite(minutes)) return undefined if (!Number.isInteger(minutes)) return undefined if (minutes < 1 || minutes > 24 * 60) return undefined return minutes }上限应按产品场景决定。烹饪计时通常不需要无限时长,明确边界也避免乘法溢出与异常展示。
二十、限制同时活跃数量
页面允许无限点击预设。对本地计时工具,可设定例如 12 个活动项上限:
const MAX_ACTIVE_TIMERS = 12 function canAddTimer(timers: TimerSnapshot[]): boolean { return timers.filter((timer) => timer.status !== 'completed').length < MAX_ACTIVE_TIMERS }达到上限时提示先完成或删除一个,不要继续创建大量卡片。
二十一、全部清除属于破坏性动作
当前“全部清除”立即停止所有 interval 并清空数组。多计时器烹饪中误触的代价很高。建议:
- 有 running 任务时二次确认。
- 或清除后提供短时撤销。
- 先持久化清空结果,再显示成功。
- 调用
stopScheduler(),不遗漏运行时资源。
二十二、删除单项应幂等
function removeTimer( timers: TimerSnapshot[], timerId: string ): TimerSnapshot[] { return timers.filter((timer) => timer.timerId !== timerId) }重复删除同一 ID 返回相同结果,不抛异常。删除后如果没有 running timer,停止调度器。
二十三、ForEach key 不能包含剩余秒数
当前 key 是:
`timer_${timer.id}_${timer.remainingSeconds}`每秒 remaining 变化,key 也变化,ArkUI 会把卡片视作新节点。动画、焦点和内部状态都可能被重建。应只用稳定 timerId:
ForEach(this.activeTimers, (timer: TimerSnapshot) => { this.TimerCard(timer) }, (timer: TimerSnapshot) => timer.timerId)二十四、进度值必须钳制
当前:
return (totalSeconds - remainingSeconds) / totalSeconds * 100恢复旧数据或异常输入时 remaining 可能超出范围。安全计算:
function getProgress(timer: TimerSnapshot): number { if (timer.totalSeconds <= 0) return 0 const completed = timer.totalSeconds - timer.remainingSeconds const percent = completed / timer.totalSeconds * 100 return Math.max(0, Math.min(100, percent)) }二十五、当前圆环不是角度进度
页面叠加两个完整圆形边框,再用 opacity 表示进度。结果是整圈颜色逐渐变深,不是从 0 到 360 度增长的进度环。
如果需要真正环形进度,应使用平台标准进度组件或 Canvas 绘制弧线;若不想增加绘制复杂度,保留横向进度条并把圆形区域作为状态图标更诚实。不要让视觉暗示与真实数值不一致。
二十六、状态分组比“进行中数量”更准确
当前标题显示activeTimers.length,其中包含 paused 和 completed。建议分别统计:
interface TimerCounts { running: number paused: number completed: number }UI 可以显示“计时中 2 · 暂停 1 · 已完成 1”,完成项可折叠或提供一键清除。
二十七、架构分层
27.1 TimerModel
定义状态、截止时间、暂停剩余量和完成通知标记。
27.2 TimerEngine
负责 create、tick、pause、resume、complete、remove 等纯状态转换。
27.3 TimerScheduler
只管理一个运行时 interval,页面离开时释放,出现时重建。
27.4 TimerRepository
负责 Preferences 读取、迁移、revision 和保存结果。
27.5 CookingTimerPage
负责预设、自定义输入、卡片、弹层和用户反馈。
二十八、测试矩阵
| 编号 | 场景 | 预期 |
|---|---|---|
| T01 | 启动 8 分钟预设 | targetEndAt=now+480000 |
| T02 | 调度延迟 5 秒 | 按 now 重算,不只减 1 |
| T03 | 暂停 | remaining 固定,targetEndAt 清空 |
| T04 | 暂停 30 秒再恢复 | 从暂停剩余量建立新截止时间 |
| T05 | 到期 | status=completed,remaining=0 |
| T06 | 连续 tick 到期项 | completion 只通知一次 |
| T07 | 多个计时器 | 只有一个 scheduler interval |
| T08 | 全部暂停 | scheduler 停止 |
| T09 | 删除最后一个 running | scheduler 停止且列表为空 |
| T10 | 页面离开 20 秒 | 返回后按绝对时间修正 |
| T11 | 后台期间到期 | 返回时立即 completed 并补一次反馈 |
| T12 | 输入 0、负数、空值 | 拒绝创建 |
| T13 | 输入小数或非有限数 | 拒绝创建 |
| T14 | 输入超过上限 | 提示允许范围 |
| T15 | 达到活动上限 | 不再新增 |
| T16 | 全部清除误触 | 有确认或撤销 |
| T17 | 保存失败 | 标记未保存,不假报可恢复 |
| T18 | 每秒更新 | timerId 与卡片身份保持稳定 |
二十九、可复核验收步骤
29.1 时间准确性
用可注入 Clock 创建 10 秒计时器。模拟 now 前进 3.4 秒,预期剩余 7 秒;再前进到 targetEndAt 之后,预期 completed。测试不需要真的等待 10 秒。
29.2 暂停恢复
在剩余 40 秒时 pause,模拟时钟前进 2 分钟,remaining 仍为 40;resume 后 targetEndAt 应等于恢复时刻加 40 秒。
29.3 生命周期
启动两个计时器后离开页面,确认 scheduler 已清除且快照已保存。等待一段时间再进入,running 项按截止时间更新,paused 项保持原值。
29.4 到期反馈
让两个计时器在相邻时刻到期,确认每个 timerId 只通知一次,完成卡片可见。再次重建页面不得重复提示已通知事件。
29.5 多设备
在手机竖屏、横屏、平板和 2in1 下检查横向预设、计时卡、操作按钮、自定义面板和底部安全区;深色模式下检查警告红色、暂停橙色、完成状态和进度背景的对比度。
三十、常见问题与定位
30.1 切到后台后时间慢了
原因:每次 interval 只减 1,没有按真实 elapsed time 计算。
处理:保存 targetEndAt,每次 tick 和恢复都用 Date.now 重算。
30.2 暂停后仍有周期工作
原因:interval 没清,只是不再修改剩余秒数。
处理:单一调度器;没有 running 项时立即停止。
30.3 到 0 后 CPU 仍有活动
原因:completed timer 的 interval 留在 Map 中。
处理:状态完成后判断 running 数量并停止 scheduler。
30.4 返回页面后计时器冻结
原因:aboutToDisappear 清了 interval,但 aboutToAppear 未重建。
处理:恢复快照、校准状态并按需启动调度器。
30.5 完成提示重复出现
原因:到期判断每次 tick 都触发,没有 notified 标记。
处理:completionNotified 持久化并按 timerId 去重。
30.6 卡片每秒闪动
原因:ForEach key 包含 remainingSeconds。
处理:key 只使用稳定 timerId。
三十一、落地顺序
- 先引入 TimerStatus、targetEndAt 和稳定 timerId。
- 把每 timer interval 改成单一 scheduler。
- 实现 pause/resume 的截止时间转换。
- 增加 completed 去重与前台到期反馈。
- 增加 Preferences 快照和生命周期恢复。
- 校验自定义输入、活动上限与清除确认。
- 最后优化圆形进度、多设备布局和可访问性。
每一步都可用 T01–T18 的相关用例验证,不需要依赖真实等待。
三十二、源码事实与建议实现的边界
本文讨论了截止时间、单一调度器和生命周期恢复,但这些属于针对当前代码缺口的改造方案。为避免把方案误认为已经上线的能力,先把可直接复核的源码事实列清楚:
| 维度 | 当前源码事实 | 本文建议,不代表已经实现 |
|---|---|---|
| 预设 | 页面内置 8 个预设,秒数从 180 到 3600 | 预设数据可继续配置化,但当前已能直接点击启动 |
| 活跃模型 | ActiveTimer只有 id、名称、emoji、颜色、总秒数、剩余秒数、isRunning | 增加显式 status、targetEndAt、completionNotified、revision |
| 调度方式 | 每次addTimer()都创建一个独立setInterval(..., 1000),并写入 Map | 所有 running 项共享一个调度器 |
| 时间计算 | 每次有效 tick 固定把remainingSeconds减 1 | 使用targetEndAt - now计算剩余时间 |
| 暂停恢复 | togglePause()只翻转isRunning;对应 interval 仍存在 | 暂停时固化剩余量,恢复时重建截止时间 |
| 到期处理 | 剩余量减到 0 时将isRunning设为 false,并显示 3 秒 Toast | 到期事件按 timerId 去重,并设计前台可感知反馈 |
| 资源释放 | 删除单项会清对应 interval;离开页面会遍历并清 interval | 离开时保存快照,返回时校准并按需重建调度 |
| 自定义输入 | 仅以Number(customMinutes) > 0判定有效,没有上限或活动数量限制 | 增加整数、小数、非有限值、最大分钟数和并发上限校验 |
| 列表身份 | ForEachkey 包含timer.id与remainingSeconds,所以每秒都会变化 | key 只使用稳定 timerId |
| 持久化 | 当前页面没有计时器 Preferences 读写,也没有后台到期能力 | TimerRepository、后台提醒和系统能力接入需要另行实现并验证 |
| 验证状态 | 本文完成源码级复核、配图检查和文章质量检查 | 构建、安装、锁屏、前后台切换及真实到期行为未在本文中宣称通过 |
因此,TimerSnapshot、TimerEngine、TimerScheduler、TimerRepository以及 T01–T18 测试矩阵都是建议实现与验收设计。当前 1.0.5 工程的真实能力仍以CookingTimerPage.ets为准,不能仅凭本文示例就认定已具备后台计时、持久化恢复或系统级通知。
三十三、总结
健康菜谱助手的计时器页已经有 8 个预设、自定义分钟数、多计时器、暂停、恢复、删除和空态,交互入口完整。真正的风险是把 interval 次数当作时间、为每个任务创建独立调度、到期和暂停仍空转,以及页面生命周期只清资源却不恢复业务状态。
可靠计时器需要守住五条原则:
- targetEndAt 是 running 状态的时间真源,interval 只刷新 UI。
- paused、running 和 completed 必须显式区分。
- 所有计时器共用一个调度器,无 running 项立即停止。
- 页面离开保存快照,回来按当前时间校准。
- 到期反馈按 timerId 去重,后台能力只使用官方合规路径。
当用户开始煲汤计时、切去查看菜谱再回来,剩余时间仍与真实经过时间一致时,这个页面才从倒数动画升级为可信的烹饪工具。
参考资料
- HarmonyOS ArkUI 概述
- HarmonyOS ArkTS 概述
- HarmonyOS 数据管理概述
- HarmonyOS Stage 模型 Application Context
- 工程源码:
CookingTimerPage.ets、ThemeConstants.ets