烹饪计时器看起来只是每秒减一,但它比普通数字动画更接近一个时间系统。用户会同时煮鸡蛋、炖排骨和蒸鱼;计时期间可能锁屏、切到菜谱步骤页、暂停某一个任务,或者在应用后台时到期。只依赖页面中的 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 |
活跃计时器模型如下:
ts
interface ActiveTimer {
id: number
name: string
emoji: string
color: string
totalSeconds: number
remainingSeconds: number
isRunning: boolean
}
页面状态还包括:
ts
@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:
ts
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 秒。误差会累积。
可靠算法使用绝对时间:
ts
remainingSeconds = ceil((targetEndAt - now) / 1000)
interval 只负责让 UI 定期重新计算,不负责定义时间本身。

五、重新定义 TimerSnapshot
ts
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 是稳定字符串,不随剩余时间改变。
六、创建计时器时计算目标截止时间
ts
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 不能在页面重建后复用。
七、统一剩余时间计算
ts
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 应该是纯状态归一
ts
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 做单元测试。
九、一个调度器管理所有计时器
ts
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 后判断是否还需要调度
ts
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()
}
}
这样暂停全部计时器、全部完成或删除全部后都不会继续空转。
十一、暂停是把截止时间固化为剩余量
ts
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 已固定。
十二、恢复是重新建立截止时间
ts
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 和服务更清楚:
ts
switch (timer.status) {
case 'running': return '计时中'
case 'paused': return '已暂停'
case 'completed': return '已完成'
}
删除不是一个可恢复展示状态,服务完成删除后从数组移除即可;如果需要撤销,可以临时保存 DeleteSnapshot。
十四、到期事件必须去重
如果页面每 250 毫秒 tick,一旦状态已是 completed,后续 tick 不应重复通知。可以在处理后设置 completionNotified = true:
ts
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 还在,调度却没有重启;如果组件销毁,则计时器全部消失。
建议生命周期链路:
ts
aboutToAppear(): void {
this.restoreTimers()
this.reconcileTimers(Date.now())
this.startSchedulerIfNeeded()
}
aboutToDisappear(): void {
this.stopScheduler()
this.persistTimers()
}
restore 后立即用当前时间重算,已经到期的任务直接进入 completed,而不是从旧 remaining 继续倒数。
十七、持久化哪些字段
可以使用 Preferences 保存少量计时器快照:
ts
interface TimerStore {
schemaVersion: number
revision: number
timers: TimerSnapshot[]
updatedAt: number
}
只保存业务字段,不保存 schedulerId 等运行时资源。应用重启后 running 项依据 targetEndAt 恢复,paused 项依据 remainingSeconds 恢复。
十八、存储结果要可观察
服务接口:
ts
interface SaveTimerResult {
ok: boolean
revision?: number
reason?: 'not_initialized' | 'serialize_failed' | 'write_failed'
}
添加计时器后如果保存失败,页面可以保留本次前台计时,但应标记"未保存";不要提示"已开始"后让用户误以为离开页面仍能恢复。
十九、自定义分钟数要有边界
当前逻辑是:
ts
const mins = Number(this.customMinutes)
if (mins > 0) {
this.addTimer(name, '⏱', mins * 60, color)
}
它没有限制小数、极大值或非有限数。建议:
ts
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 个活动项上限:
ts
const MAX_ACTIVE_TIMERS = 12
function canAddTimer(timers: TimerSnapshot[]): boolean {
return timers.filter((timer) => timer.status !== 'completed').length < MAX_ACTIVE_TIMERS
}
达到上限时提示先完成或删除一个,不要继续创建大量卡片。
二十一、全部清除属于破坏性动作
当前"全部清除"立即停止所有 interval 并清空数组。多计时器烹饪中误触的代价很高。建议:
- 有 running 任务时二次确认。
- 或清除后提供短时撤销。
- 先持久化清空结果,再显示成功。
- 调用
stopScheduler(),不遗漏运行时资源。
二十二、删除单项应幂等
ts
function removeTimer(
timers: TimerSnapshot[],
timerId: string
): TimerSnapshot[] {
return timers.filter((timer) => timer.timerId !== timerId)
}
重复删除同一 ID 返回相同结果,不抛异常。删除后如果没有 running timer,停止调度器。
二十三、ForEach key 不能包含剩余秒数
当前 key 是:
ts
`timer_${timer.id}_${timer.remainingSeconds}`
每秒 remaining 变化,key 也变化,ArkUI 会把卡片视作新节点。动画、焦点和内部状态都可能被重建。应只用稳定 timerId:
ts
ForEach(this.activeTimers, (timer: TimerSnapshot) => {
this.TimerCard(timer)
}, (timer: TimerSnapshot) => timer.timerId)
二十四、进度值必须钳制
当前:
ts
return (totalSeconds - remainingSeconds) / totalSeconds * 100
恢复旧数据或异常输入时 remaining 可能超出范围。安全计算:
ts
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。建议分别统计:
ts
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 判定有效,没有上限或活动数量限制 |
增加整数、小数、非有限值、最大分钟数和并发上限校验 |
| 列表身份 | ForEach key 包含 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
AI 辅助声明:本文由 AI 辅助完成源码统计、结构校对与配图设计,当前能力结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布;截止时间模型、调度器、持久化方案和测试矩阵不代表工程已经构建、安装或上线。