烹饪计时器看起来只是每秒减一但它比普通数字动画更接近一个时间系统。用户会同时煮鸡蛋、炖排骨和蒸鱼计时期间可能锁屏、切到菜谱步骤页、暂停某一个任务或者在应用后台时到期。只依赖页面中的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 个预设预设秒数展示时长焯水18003:00煮鸡蛋48008:00蒸鱼60010:00腌制90015:00蒸米饭120020:00烤肉180030:00炖排骨240040:00煲汤360001: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: Mapnumber, number new Map()这是一份适合渲染的模型却没有描述“何时到期”。二、当前 addTimer 的运行方式每添加一个计时器就创建一个 intervalconst 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 定期重新计算不负责定义时间本身。五、重新定义 TimerSnapshotexport 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。十三、完成状态不能用 isRunningfalse 猜测当前模型中暂停和完成都是isRunning false只能再检查 remainingSeconds 区分。显式 status 能让 UI 和服务更清楚switch (timer.status) { case running: return 计时中 case paused: return 已暂停 case completed: return 已完成 }删除不是一个可恢复展示状态服务完成删除后从数组移除即可如果需要撤销可以临时保存 DeleteSnapshot。十四、到期事件必须去重如果页面每 250 毫秒 tick一旦状态已是 completed后续 tick 不应重复通知。可以在处理后设置completionNotified trueprivate 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 会把卡片视作新节点。动画、焦点和内部状态都可能被重建。应只用稳定 timerIdForEach(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 分钟预设targetEndAtnow480000T02调度延迟 5 秒按 now 重算不只减 1T03暂停remaining 固定targetEndAt 清空T04暂停 30 秒再恢复从暂停剩余量建立新截止时间T05到期statuscompletedremaining0T06连续 tick 到期项completion 只通知一次T07多个计时器只有一个 scheduler intervalT08全部暂停scheduler 停止T09删除最后一个 runningscheduler 停止且列表为空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 仍为 40resume 后 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
阅读完成 · 觉得有帮助?