厨房多计时器发布前一致性验收封面

有一个很容易被忽略的场景:焖饭与蒸蛋同时到点,用户恰好把应用切到了后台,再从通知栏回到厨房计时页面。此时页面可能出现两条“已完成”卡片,但仍留着两个定时刷新器;随后用户确认焖饭,另一个已经到期的任务却被当作新提醒再次发送。问题看起来像通知故障,根因却是页面生命周期、快照状态和通知副作用三套时序没有对齐。

KitchenTempo 前五篇已经建立 deadlineMs 截止时间模型、TimerPhase 显式状态机、Preferences 快照恢复、授权后的通知去重和同批到期稳定排序。最后一篇不再追求增加更多计时卡片,而是拆开一个完整发布前场景:页面消失时谁负责清理,页面回来时哪些状态需要重算,通知已经发送但记录保存失败时如何避免错误承诺,以及哪些测试可以在没有后台常驻能力的前提下复现。

一、先把“时间正确”与“提示可达”分成两条验收线

计时的事实来自 deadlineMs - Date.now(),并不是 setInterval 运行了多少次。可见页面的一秒刷新只负责更新文字和识别已经到期的任务;当页面离开前台时,停止刷新器不会暂停业务截止时间。返回前台再读取 Date.now(),把已越过截止时间的 RUNNING 任务结算为 FINISHED,最后决定哪些未确认完成事件需要在页面上突出显示。

通知是一条另行授权的副作用。notificationManager.publish 的 Promise 返回,不能证明用户一定在通知中心看到提醒;权限被关闭、系统限制弹窗、用户已经离开现场都可能影响最终体验。因此业务完成记录中的 delivered 只是本应用的发布状态标记,acknowledged 才表示用户在应用里明确确认。两个字段不能合并为一个 done 布尔值,否则首次通知失败时会误以为用户已处理。

第 B-05 篇的排序规则继续保留:未确认的 FINISHED 优先,其次 RUNNING、PAUSED、其他状态;同级任务保持原列表相对顺序。排序只决定显示,不改变业务 ID,也不改变完成事件键。对焖饭、蒸蛋和烤蔬菜三种卡片,ForEach 的 Key 仍用 task.id,而不是数组序号,防止确认一条完成任务后,其余卡片复用错误的本地状态。

二、一个刷新器就够,页面退出必须成对释放

不要把 setInterval 写进 build(),因为任何响应式更新都可能重新触发 UI 构造;也不要让每个 TimerCard 维护自己的后台计时器。页面持有唯一的 tickerId,进入时只在尚未启动的情况下注册,离开时清理。页面销毁后,已经开始执行的异步存盘仍可能返回,所以还需要会话序号防止旧结果覆盖新页面的提示文字。

// entry/src/main/ets/pages/KitchenTimerPage.ets:页面生命周期增量
private tickerId: number = -1;
private viewEpoch: number = 0;
@State private nowMs: number = Date.now();
@State private notice: string = '';

private stopVisibleClock(): void {
  if (this.tickerId === -1) return;
  clearInterval(this.tickerId);
  this.tickerId = -1;
}

private startVisibleClock(): void {
  if (this.tickerId !== -1) return;
  this.nowMs = Date.now();
  this.tickerId = setInterval(() => {
    this.nowMs = Date.now();
    if (this.ready) this.handleDueTasks(this.nowMs);
  }, 1000);
}

onPageShow(): void {
  const epoch: number = ++this.viewEpoch;
  this.reconcileOnReturn(epoch);
}

onPageHide(): void {
  ++this.viewEpoch;
  this.stopVisibleClock();
}

aboutToDisappear(): void {
  ++this.viewEpoch;
  this.stopVisibleClock();
}

这里的 reconcileOnReturn 是工程方法,而不是系统回调。只有 onPageShow/onPageHide 和 aboutToDisappear 属于组件生命周期入口。初次渲染应先完成 B-03 的 store.open 与 restoreTasks,再让页面进入 ready = true;如果反过来先启动时钟,未恢复的空列表可能短暂显示为“所有任务完成”。`

DevEco 之外的生命周期与快照回归判定图

viewEpoch 管理 UI 会话,不是要取消已经开始的持久化。后台切换时仍需让已提交的串行写盘完成,只是返回值不再对过期页面执行 this.notice = ...。另外,重复进入页面会调用 onPageShow,因此 startVisibleClock 的“只允许一个实例”判断依然不可删除;只靠每次离开页面清理无法应对连续恢复事件。

三、恢复先结算快照,发送提醒放到后面

恢复流程建议明确分为四步:先读 ReminderSnapshotV3,重建 KitchenTask 响应式对象;然后以当前时刻统一结算到期项,形成候选业务快照;第三步等待 TimerStore.enqueue 保存成功;最后才更新页面顺序和尝试通知。这个顺序不保证电源中断时完全原子,却避免“通知已经发送,页面还没记录任务完成”的常见逻辑倒置。

// KitchenTimerPage.ets:配合 B-05 已有 handleDueTasks 与 orderTasks
private async reconcileOnReturn(epoch: number): Promise<void> {
  try {
    if (!this.ready) {
      await this.loadPersistedTasks(); // B-03 存储读取与模型恢复
      this.ready = true;
    }
    const now: number = Date.now();
    await this.handleDueTasks(now); // 内部先存快照,再发通知
    if (this.viewEpoch !== epoch) return;
    this.nowMs = now;
    this.refreshOrder();
    this.startVisibleClock();
  } catch (_e) {
    if (this.viewEpoch !== epoch) return;
    this.notice = '恢复未完成,请重新进入页面核对任务';
  }
}

例子中 loadPersistedTasks() 表示承接 B-03 既有读取与版本迁移方法的工程封装;如果现有页面已在 aboutToAppear 里完成初始化,就不要重复读相同 Preferences,也要避免 ready 标志先于数据完成恢复。新页面需要在 handleDueTasks 内保持 B-05 的 settling 锁,防止一秒刷新器与页面恢复入口重复结算同一批到期任务。

需要格外留意的不是“发送成功”这个返回值,而是快照和提醒记录的先后。某条完成事件使用 completionKey(task)(任务 ID 与 deadlineMs 拼接)作为去重键;发送之后若记录落盘失败,应用下次可能再次尝试发送。不能把这种本地逻辑称作全局 exactly-once;若业务要求严格去重,需要持久化状态机(待发送、发送中、已确认)、设备限制分析以及真正的重复发送测试。对于厨房提醒这类低风险体验,可在页面上始终提供“待确认”兜底,授权失败时不阻塞主流程。

四、把回归测试写成可执行的状态表

只用一次“点击开始并等到时间结束”的示例是不够的。更有用的是固定一组可重复输入:焖饭与蒸蛋设置同一截止时刻,烤蔬菜保持暂停;首次返回前台时,两条到期事件归为同一批业务结算,页面置顶两张卡片。用户确认蒸蛋后,蒸蛋不再排在“待确认完成”之前,焖饭仍须被突出显示;烤蔬菜保持 04:30,不能因为列表重新排序变成另一个任务的秒数。

测试台账可按下列路径逐项判断:

  1. 前台正常完成:两张到期卡片分别可确认,完成键保持稳定;刷新器数量始终为一。
  2. 切后台后超时:离开时 tickerId 变为 -1,回来后按实际截止时间结算,不凭后台循环次数补扣秒数。
  3. 快速开关页面:连续切入、切出多次,viewEpoch 淘汰过期 UI 回调,不能出现重复显示的定时器。
  4. 通知授权关闭:FINISHED 和“待确认”状态不受影响,只提示“当前仅在页面提醒”。
  5. 快照损坏:显示恢复失败的明确提示,不静默创建三条全新的计时任务覆盖旧记录。
  6. 调整系统时间:记录期望偏差和使用边界,不宣称 Date.now() 在人工改时后仍然是单调时钟。

这里的数字只是复现步骤的固定业务数据,不是性能实测。无法借助普通页面定时器来保证应用被杀死或设备长时间休眠后的准点唤醒,因此界面文案应当强调“计时状态可恢复”,而不是承诺“无论发生什么都会响铃”。这也是为什么在官方通知接口之外,还要保留页面的已完成列表和确认按钮。

KitchenTempo 任务完成、待确认与暂停状态的竖屏界面

把这六组用例保存为一份固定测试记录时,建议每次只改变一个输入条件:例如先固定同一截止时刻,再分别切换授权状态;先让两张卡片到期,再分别尝试确认其中一张;先让页面销毁,再将存盘 Promise 延后返回。这样可以避免一次测试里同时存在权限拒绝、系统时间调整和存储损坏,最终难以定位真正原因。测试人员还应记录操作前的快照版本、任务 ID、完成键与操作后的目标状态,问题复现时不需要依赖一段难以重复的视频。

还有两组更容易遗漏的异常:一是重启应用时 delivered = true、acknowledged = false 的事件不应再当成新事件重新广播,但页面仍需要显示确认;二是用户确认后立即按返回键,持久化 Promise 尚未结束,要在下次打开时从磁盘重新读取状态,而不是简单假设内存里的“已确认”已经安全落盘。对于可能因 flush 失败而留下不确定状态的情形,可使用“刷新快照并复核当前事件”按钮,而不是盲目反复发布通知。

五、工程收尾:把边界写到功能之外

KitchenTempo 六篇最终形成两个相互独立但协同的数据层:任务截止时间提供计时事实,版本 3 完成记录解释提醒和确认结果。UI 可根据排序规则高亮任务,却不承担业务时钟的唯一可信来源;通知授权受系统控制,却不决定任务是否完成;Preferences 负责轻量级本地保存,不负责跨进程强一致性。把这几件事分离,比增加一堆不可验证的后台能力更有意义。

用户体验层面还需要为“状态已完成”和“提醒可能失败”采用不同的视觉语义:完成卡片用稳定的强调色,而通知授权提示用温和的说明条。不要因为设备当前关闭通知权限就把已完成任务重新标成运行中,也不要在用户明确确认后将提醒记录从快照中直接删光,否则下一次重建同一个任务的历史完成事件会丢失去重依据。用 completionKey 将一次任务执行和下一次重新启动区分开,重置任务时就必须重新计算截止时刻,而不是继续沿用上一轮的完成键。

真正进入发布流程前,应按目标 SDK 将页面、模型、存储、通知适配层整合到一个工程,在 DevEco Studio 中进行编译,构造切后台、页面销毁、授权拒绝、数据异常和冷启动回归;若计划支持更多设备形态,继续检查不同窗口尺寸与前后台切换路径。除了状态正确,还需验证界面文字在系统大字号下可读、按钮可操作、异常提示不挡住核心确认入口。

最后明确适用范围:它是单设备烹饪辅助计时应用,不适合医疗给药、食品安全强控制或必须精准到秒的工业任务。状态恢复是应用业务逻辑,系统级后台唤醒、通知交付和设备时钟策略需按实际产品权限与设备能力另行核查。收尾验收只要求可以重现、可以定位、可以解释,不依赖无证据的成功编译截图或夸张的时延数字。

官方资料:ArkUI 自定义组件生命周期、NotificationManager、Preferences API、ForEach 列表渲染。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐