ArkUI + HarmonyOS 厨房双工位计时收官:模块化与可扩展实践 -06【共创稿事节】
KitchenPulse 已经可以同时管理灶台和烤箱两条倒计时,允许暂停、恢复、重新开始,能够在应用返回前台时按照截止时间重新计算剩余秒数;上一轮又加入可编辑的分钟预设。功能列表看起来完整,真正的工程难点却在最后一步:用户可能在烤箱倒计时进行中编辑预设,随后锁屏、进后台,等五分钟再打开应用,发现灶台早已完成,而烤箱仍在计时。界面要呈现哪一份状态,哪些事件需要补充提醒,哪些动作绝不能重复?这不是再添加一个按钮可以解决的事情。

一、从“两个按钮能跑”转向模型的单一解释权
六篇连载沿用同一组工位 ID:hob 对应“灶台·焖煮”,oven 对应“烤箱·烘焙”。计时模型的四个业务状态是 idle、running、paused、done。运行态以 deadlineMs 保存目标截止时刻,同时保留 remainingSeconds 供显示;暂停态应停止按照当前时钟递减,而是保存暂停瞬间计算出的秒数;完成态的剩余秒数强制归零;空闲态等待用户选择下一次预设。每个工位独立完成状态流转,两条工位可以由同一个页面刷新节拍驱动,但绝不能共用一份 remainingSeconds。
这里要保留上一轮最重要的产品约定:预设 TimerPreset.minutes 只表示下次启动时的默认值,TimerLane 则表示当前计时事实。烤箱运行中将预设从十二分钟改成十五分钟,当前 deadlineMs 不变。只有用户结束当前任务并明确发起新一次开始操作,才读取新的预设。若把预设变更直接写进正在运行的工位,就会同时影响倒计时、完成事件键和通知去重,破坏之前所有章节建立的边界。
最终的模块依赖非常简明:UI 组件 TimerCard 接受可观察的展示快照和点击回调;纯业务模块 TimerEngine 处理开始、暂停、恢复和时间计算;TimerStore 把两个工位的持久检查点存入 Preferences;PresetRepository 保存下一次默认分钟数;FinishNotice 使用 Notification Kit 尝试发布提醒,并通过另一份键集记录已处理事件。界面从这些模块读取结果,不需要知道它们的存储文件名或操作系统通知的每一个细节。这个设计允许将来增加“醒面”“浸泡”等工位,而不用复制一整套页面状态。
二、让截止时刻和剩余秒数的关系可以单独测试
前后台恢复最大的误差源不是每秒刷新有没有准时触发,而是把 UI 定时器次数当成了真实流逝时间。JavaScript 计时回调在应用后台可能被延迟甚至不执行;因此运行态的剩余时间必须由恢复时的当前时钟与保存的 deadlineMs 比较。只有在暂停态,才信任磁盘里的 remainingSeconds。为便于离线测试,把所有时间相关判断集中到纯函数里,由调用方注入 nowMs,而不是每个组件都自行读取 Date.now()。
// entry/src/main/ets/model/TimerEngine.ets
export type LaneStatus = 'idle' | 'running' | 'paused' | 'done';
export interface TimerLane {
id: string;
status: LaneStatus;
deadlineMs: number;
remainingSeconds: number;
}
export function reconcileLane(lane: TimerLane, nowMs: number): TimerLane {
if (lane.status !== 'running') { return { ...lane }; }
if (!Number.isFinite(lane.deadlineMs) || lane.deadlineMs <= 0) {
return { ...lane, status: 'paused' };
}
const remain: number = Math.max(0,
Math.ceil((lane.deadlineMs - nowMs) / 1000));
if (remain === 0) {
return { ...lane, status: 'done', remainingSeconds: 0 };
}
return { ...lane, remainingSeconds: remain };
}
Math.ceil 是一个明确的交互约定:距离截止时刻还有 100 毫秒时显示一秒,实际达到截止时刻才进入完成态,避免页面提前显示 00:00。如果保存的数据存在 NaN 或负的截止时刻,函数不会悄悄推测用户原本想设置多少分钟,而是转为暂停并交由页面提示检查;正式仓储恢复还应进一步校验剩余秒数是非负整数、工位 ID 唯一以及状态字符串属于允许值。相反,合法 done、paused 数据直接返回副本,保证重建不会让已完成计时突然再次开始。
对于用户主动暂停,业务层应在一次操作里先调用 reconcileLane(lane, nowMs) 获取准确剩余秒数,再把状态改成 paused 并清理当前运行截止标记;用户点击继续时,用 nowMs + remainingSeconds * 1000 生成新的 deadlineMs。这两个转换还必须写回检查点,才能在杀进程后保持含义。页面使用一个短周期刷新器只是“把模型投影到文字和进度圆环”,不是业务的权威时钟,也不保证系统在后台持续执行 JavaScript。
三、恢复流程需要顺序,也需要明确失败出口
启动页不能在还没读到存储时就把两条工位默认为八分钟或十二分钟并立刻保存,因为这可能覆盖上一轮真实的检查点。更可靠的顺序是:先读取 TimerStore;对存储对象逐字段校验和版本兼容;使用一次统一的 nowMs 重算运行中的两条工位;将计算后的快照交给 UI;只有用户明确操作或恢复需要更新状态时再进行持久化。读盘失败时允许展示带有“恢复失败,请检查计时记录”的状态,而不是把旧数据悄悄清空。
Stage 生命周期事件承担通知应用进入前台或后台的职责。onBackground 可停止页面的 UI 刷新器,并尽力保存检查点;不能将后台切换当成用户点击暂停。onForeground 后重新读取或重算状态:如果原本运行的灶台已经超过截止时刻,reconcileLane 返回 done;烤箱还没到时刻,则用新的剩余秒数继续显示。对于进程被系统结束后的再打开,应按同样的恢复函数处理,不依赖一定会先收到某一个生命周期回调,因为非正常终止不保证所有清理逻辑都得到执行。
Preferences 适合当前双工位的小规模配置与检查点,但并不提供定时触发的后台保证,也不是跨设备的强一致计时服务。要做真正的后台定时告警,应以系统公开允许的后台能力、授权与设备限制为前提,不应将示例里的一个 setInterval 解释为常驻后台服务。时间基准也有边界:这里采用墙上时钟的毫秒时间戳,系统时钟被人为调节时可能改变计算结果;若产品要求高精度计量,需要设计单调时钟与重启之后的校准策略,并以目标设备实测确认。

本轮界面效果固定为“灶台运行中,烤箱已完成”:顶部标题“厨房双工位计时”,第一张卡片是橙色的“灶台·焖煮 04:28 / 08:00”,状态为“计时中”;第二张卡片是绿色的“烤箱·烘焙 00:00 / 12:00”,状态为“已完成”。底部提示写明“返回应用后已重新核对截止时间”,预设入口注明“修改仅影响下次启动”。这些文字既是视觉设计事实,也是后续验收用例的输入与预期,不能把这种静态示意误认成真实设备保存的运行记录。
四、业务完成与通知发送之间存在不可省略的边界
第四篇已经把完成提醒交给 Notification Kit,并使用 ${lane.id}:${lane.deadlineMs} 这样的事件键做应用侧去重。这里再次强调两种完全不同的事实:done 表示计时业务已经结束;published_v1 表示应用曾经记录过某个完成事件的通知处理状态。操作系统能否展示通知取决于用户授权、系统策略和接口调用结果。即使发布 API 返回成功,也不等于用户实际看到了横幅,更不代表提醒被阅读。
通知层可为两条工位分配固定的数字 ID,例如灶台 401、烤箱 402。同一个 ID 与 label 再次发布时,根据 Notification Kit 文档可更新已有卡片,这有助于降低重复通知;但存储去重与系统通知更新不是同一个保证。假设通知发出后应用立即崩溃,尚未来得及把 published_v1 落盘,重建时仍可能重新请求通知。反过来,如果先标记成功再发通知,崩溃就可能导致漏报。因此这套示例能够减少正常恢复中的重复发送,却不承诺跨崩溃“恰好一次送达”。需要严格送达语义的产品必须在更完整的事务与回执体系中定义保障。
// entry/src/main/ets/common/FinishNotice.ets(完成判定层)
import { TimerLane } from '../model/TimerEngine';
export interface FinishEvent {
key: string;
laneId: string;
deadlineMs: number;
}
export function completedEvent(lane: TimerLane): FinishEvent | undefined {
if (lane.status !== 'done' || lane.deadlineMs <= 0) {
return undefined;
}
return {
key: `${lane.id}:${lane.deadlineMs}`,
laneId: lane.id,
deadlineMs: lane.deadlineMs
};
}
这里只负责从模型提取一次完成事件,不在纯函数中直接发布系统通知。FinishNotice 类再异步读取 published_v1、查询系统通知授权,并按原有逻辑尝试 notificationManager.publish()。完成事件键稳定的前提是“每次真正重新开始时产生新的截止时间”,而不是在每次 UI 刷新时重算并改写 deadlineMs。当用户关闭提醒权限时,业务 done 状态仍要正确显示,页面可以用非强制的提示说明无法发送系统提醒。
五、最后一轮验收重点是组合路径而非漂亮截图
可复现测试从纯函数开始。给 running 的灶台 deadlineMs=10000,在 nowMs=6000 时应得到四秒;在 nowMs=10000 时应变为完成;给 paused 的烤箱同样输入时间变化,剩余秒数不应改变。再分别构造负截止时间、非法状态、零分钟预设和九十一分钟预设,验证恢复拒绝不合法检查点,设置保存也不会触动已经运行的任务。测试代码中使用常量时钟和内存仓储替身,不需要凭设备的实时计时碰运气。
随后在设备上按完整路径验证:设置灶台八分钟、烤箱十二分钟,启动两条计时;中途修改烤箱默认预设为十五分钟,观察当前烤箱的截止时刻保持不变;切入后台,经过预定的预设时间再返回,检查两条工位分别由 deadlineMs 恢复。灶台完成后反复进入和退出页面,不应在正常情况下重复尝试同一事件通知;通知权限关闭时应保留完成状态并显示提醒限制。最后分别构造 Preferences 保存失败、损坏 JSON、用户取消计时、设备重启、进程异常终止与屏幕大字体,检查有明确的恢复边界。

需要特别说明:单元测试通过不代表设备通知可靠性已经达标,界面截图也不能代替后台权限、不同系统版本和电源管理条件的验证。正式提交前,使用 DevEco 的测试工具验证逻辑层,使用可控制的时间输入复现临界秒数,并在真实目标设备上检查授权请求、后台切换和用户可见效果。收集 HiLog 时只记录工位 ID、状态转换和错误类别,不记录不必要的用户隐私信息;没有证据时不写具体耗时、成功率或“百分百送达”结论。
六篇连载走完后,KitchenPulse 的价值是分离了四个容易混淆的概念:预设表示下一次的默认配置,工位快照表示当前业务事实,墙上时钟用于重算运行截止时间,系统通知表示一次需要授权的提醒尝试。只要这几层保持独立,扩展第三个工位可以复用 TimerEngine 与 TimerCard,升级通知策略也不应改变当前倒计时。工程最终目标不是让一个圆环每秒跳一次,而是在用户反复操作、切换环境和遇到异常时,仍能准确解释计时器正在做什么。
官方资料:NotificationManager 通知管理、Preferences 用户首选项、UIAbility 生命周期、ArkTS 自动化测试。
更多推荐




所有评论(0)