NotificationKit + HarmonyOS 厨房计时:到点提醒去重与授权降级 -04【共创稿事节】
厨房计时器的“到点提醒”很容易看起来已经完成:把秒数减到零,在页面上弹一个提示,再调用一次系统通知接口就行。真正用起来,问题往往出在第二次打开应用时:焖饭明明已经提醒过,用户从后台切回来却又看见同一张到点通知;或者用户拒绝通知授权,屏幕没有任何变化,误以为计时器根本没有结束。时间到期、发送通知、用户确认是三件不同的事,把它们写成一个布尔值,会使恢复逻辑无法解释页面状态。
KitchenTempo 的前面三节依次建立绝对截止时间 deadlineMs、TimerPhase 显式状态机、以及带版本号的 Preferences 快照。当前样本继续使用焖饭、蒸蛋、烤蔬菜三张卡片。本节不承诺后台持续唤醒,也不把系统通知当成保证准时执行的闹钟,而是专门梳理三个可验证的工程问题:何时产生完成事件、如何消除重复提醒、用户关闭通知权限时页面如何可靠降级。

一、到期不等于已经提醒,更不等于已经确认
先看最常见的复现场景。焖饭在应用后台期间超过 deadlineMs,再次进入前台时,settleFinished(now) 把它由 RUNNING 变为 FINISHED。如果页面简单地在所有 FINISHED 卡片上调用 publish(),每次 onPageShow() 都会重新触发。对同一个任务来说,完成事实始终为真,但通知动作不应无限重复。
应给每次完成事件分配一个稳定标识。任务 id 只能代表“焖饭这个计时器”,不能区分今天第一轮和重新开始后的下一轮;单独用 Date.now() 又可能在系统时钟调整后重复。这个小项目保留了创建本轮计时时的 deadlineMs,因此用 id + ':' + deadlineMs 作为完成事件键。相同任务重新开始会获得新的截止时间,在正常使用场景下得到不同的键。严格生产版本可以进一步为每轮计时引入持久化递增 runId,完全摆脱墙上时间作为身份的限制。
事件记录区分 delivered 与 acknowledged:前者表示一次系统通知发布 Promise 曾正常返回,后者表示用户点过“确认完成”。例如焖饭 FINISHED、delivered=true、acknowledged=false,页面就应该显示“已完成 · 待确认”;用户确认后才显示“已确认”。完成不等于已发送;已发送也不等于已读,更不能替用户决定要不要重新开始。
// entry/src/main/ets/model/ReminderRecord.ets
import { KitchenTask, TimerRow } from './KitchenTask';
export interface ReminderRecord {
key: string;
taskId: string;
delivered: boolean;
acknowledged: boolean;
}
export interface ReminderSnapshotV3 {
version: number;
savedAtMs: number;
tasks: TimerRow[];
reminders: ReminderRecord[];
}
export function completionKey(task: KitchenTask): string {
return `${task.id}:${task.deadlineMs}`;
}
这里的 TimerRow、KitchenTask 继续从之前的 model/KitchenTask.ets 导入;TimerSnapshotV3 沿用前一篇 tasks 结构,新增 reminders,版本由 2 升到 3。恢复时,旧版本没有提醒记录则初始化为空数组,不把旧任务默认认定为“用户已确认”。再次保存时才生成版本 3 快照,并保留原有任务的 durationMs、remainingMs、deadlineMs 和 phase。旧版本的校验与迁移不能因为多了一个字段就直接跳过。
二、通知授权是一个用户决定,不是启动时强制弹窗
HarmonyOS 的 NotificationKit 提供 notificationManager 管理系统通知。官方参考给出了可核实的调用顺序:先查询 isNotificationEnabled(),必要时由用户明确发起 requestEnableNotification(context),获准后才能通过 publish(request) 发布通知。这里面有几条很实际的限制:requestEnableNotification(context) 需要 Stage 模型和 UIAbilityContext,且应在应用 UI 已加载后调用;用户拒绝后不能依靠循环调用反复弹授权框。权限也可能被用户在系统设置中关闭,不能把上次的允许结果当作永久授权。
本篇把“允许系统通知”做成设置区的显式按钮。授权拒绝、通知服务故障或设备能力不满足时,计时状态依旧更新,页面出现“已完成 · 仅页面提醒”,而不是重置倒计时。普通可见页面的提醒是 ArkUI UI 反馈,不需要等待通知栏权限才能更新。下面的服务类只负责发送已经到期且尚未确认的单条通知,绝不承担后台调度。
// entry/src/main/ets/service/FinishNotifier.ets
import { notificationManager } from '@kit.NotificationKit';
import { common } from '@kit.AbilityKit';
import { KitchenTask } from '../model/KitchenTask';
export class FinishNotifier {
async requestFromButton(ctx: common.UIAbilityContext): Promise<boolean> {
try {
if (await notificationManager.isNotificationEnabled()) return true;
await notificationManager.requestEnableNotification(ctx);
return await notificationManager.isNotificationEnabled();
} catch (_e) { return false; }
}
async send(task: KitchenTask, notificationId: number): Promise<boolean> {
try {
if (!await notificationManager.isNotificationEnabled()) return false;
const request: notificationManager.NotificationRequest = {
id: notificationId,
label: `kitchen-${task.id}`,
content: {
notificationContentType:
notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: { title: '厨房计时完成', text: `${task.name} 已到点,请及时确认` }
}
};
await notificationManager.publish(request);
return true;
} catch (_e) { return false; }
}
}
同一任务使用固定的通知 id 与 label,例如焖饭对应 2001 和 kitchen-rice。官方文档说明相同 id 与 label 的通知会更新已有通知卡片,而不是生成另一张独立卡片。这里是降低重复可见通知的一种辅助措施;应用层仍需依靠 ReminderRecord 记录是否已经发布和确认。不同任务的数字 id 必须互不冲突,不能所有计时器都用 1,否则焖饭可能覆盖蒸蛋。
通知是否显示、声音如何播放,受系统授权、通知类型、用户设置和设备限制共同影响。publish() 成功表示接口调用得到正常返回,不等于用户实际看到了横幅或听到声音。更不能由此推断应用在进程已死亡时会定点醒来。对于需要闭屏准时响铃的产品,应另行核实系统闹钟、后台任务和特定权限的开放条件,不能把 UI 页面的 setInterval 包装成系统定时服务。

三、把提示发送与确认操作分成两条路径
计时器再次进入前台时,先调用上一节的 settleFinished(Date.now()),再查看版本 3 快照中的提醒记录。对于仍在运行和暂停的任务,根本不生成完成提醒。对于已完成的任务,如果记录为 acknowledged=true,不需要再次提示。如果已经 delivered=true,可以在页面继续显示“待确认”,但不要重新 publish()。只有未确认、未发送的事件才进入通知尝试。
实际代码还需防止同一前台生命周期内重复调用。页面刷新每 500 毫秒执行一次,如果把发送方法放在普通 build() 或刷新循环中,多个异步 Promise 可以同时经过“尚未发送”的判断。可以用一个内存集合挡住正在发送的事件;持久记录则负责跨冷启动去重。两个层次不可互相代替。
// KitchenTimerPage.ets 的核心增量
private inFlight: Set<string> = new Set<string>();
private notifier: FinishNotifier = new FinishNotifier();
private async offerReminder(task: KitchenTask, id: number): Promise<void> {
if (task.phase !== TimerPhase.FINISHED) return;
const key: string = completionKey(task);
const old: ReminderRecord | undefined = this.reminders.find(
(r: ReminderRecord) => r.key === key);
if (old?.acknowledged || old?.delivered || this.inFlight.has(key)) return;
this.inFlight.add(key);
try {
const sent: boolean = await this.notifier.send(task, id);
if (!sent) { this.notice = `${task.name} 已完成,当前仅在页面提醒`; return; }
const record: ReminderRecord = {
key: key, taskId: task.id, delivered: true, acknowledged: false
};
await this.store.enqueue(this.snapshotWithReminder(record));
this.reminders = this.reminders.concat([record]);
} catch (_e) {
this.notice = '提醒记录暂未保存,请检查本地数据';
} finally { this.inFlight.delete(key); }
}
this.store 仍是 B-03 的 TimerStore,需要将其 enqueue 参数从 TimerSnapshot 扩充为当前版本的快照类型;snapshotWithReminder(record) 负责保留当前 tasks,并把事件按 key 去重后写入 reminders。在此假设 reminders 是页面已恢复好的 @State ReminderRecord[]。真实代码应另外实现授权按钮的 onClick,调用 requestFromButton(getContext(this) as common.UIAbilityContext),不要在页面初始化时自动弹窗。
这段策略有一个公开的权衡:先通知、后写盘。如果 publish() 已成功、enqueue() 失败,下一次启动还会再次尝试发送。固定 id + label 可以尽量替换原通知卡片,却不能承诺用户完全感知不到第二次提醒。如果反过来先存 delivered=true 再调用 publish(),应用恰好在两者之间退出,就会留下“没有发送却标记已发送”的漏报。因此普通轻量 Preferences 不提供消息投递系统的严格 exactly-once 语义,应把“重复可替换”和“失败可见”作为可实现的目标。
确认按钮走另一条路径。用户点击“确认完成”时,先将对应 ReminderRecord.acknowledged 置为 true 形成新快照,经 TimerStore.enqueue 持久化成功后才改页面的确认标识;失败则维持待确认,并显示“确认尚未保存”。如果期望同时清理通知栏里的对应卡片,可以调用官方的 notificationManager.cancel(id, label),但它与 Preferences 的写入同样不是一个跨系统事务,失败需要分别反馈。不要在 settleFinished() 中自动把 acknowledged 设为真,否则用户即使没看见计时结束也会被标为已读。
四、验证应该覆盖拒绝授权与进程恢复
这次功能验证不需要捏造后台性能数据。可以先用固定当前时间构造焖饭已过期、蒸蛋暂停、烤蔬菜已确认的快照,然后检查页面:焖饭显示“00:00 · 已完成 · 待确认”;蒸蛋显示“03:10 · 已暂停”;烤蔬菜显示“00:00 · 已完成 · 已确认”。当通知授权关闭时,页面顶部显示“系统通知未开启”,焖饭卡片仍有醒目的完成提示和确认按钮。提示不是发生在某个神奇的系统后台计时器里,而是在应用恢复页面后结算得到。
第二组测试在应用已经打开、通知权限允许时让焖饭自然到期,调用一次提醒检查;重复进入页面以及重新执行 offerReminder,正常情况下不会增加独立通知卡片。第三组测试主动拒绝授权,确保任务不回到 RUNNING、不卡在“等待通知”状态,也不无限请求授权。第四组在通知发布之后、提醒记录落盘之前故意注入失败;这条测试必须接受“有机会重复提示”,并确认错误文字不会假装已持久化。

第五组验证旧快照迁移。B-03 的版本 2 有 tasks、savedAtMs,但没有 reminders,升级时只生成空提醒记录,不能删除三个原任务。尤其要防止把长期以前完成的任务全部当成第一次提醒,造成启动时连续弹出通知。一个实用策略是:迁移时把旧的 FINISHED 项标为仅页面展示,等待用户查看或确认;新运行轮次才接入自动通知。这项规则应作为业务迁移策略明确编写,而不是由 undefined 的真假值偶然决定。
再检查最棘手的现实边界:用户手动修改系统时间,deadlineMs 的墙上时间算法可能提前或延后完成判断;系统可以在后台回收应用进程,普通页面刷新回调不会继续执行;用户可能在应用外撤销通知许可,原有授权状态不再有效。对于严格实时、安全或医疗等需求,这些都不是一个 UI 小程序能靠多写几个 Promise 修好的。KitchenTempo 在此阶段定位为厨房辅助计时,页面结算准确性与授权降级可解释性优先。
经过这一节,KitchenTempo 的状态图多了一个重要区别:TimerPhase.FINISHED 描述计时事实,ReminderRecord.delivered 描述系统通知动作,acknowledged 描述人的确认。下一篇将以这三个维度为基础,处理多任务同时结束时的页面优先级、重启后的提醒整理以及 UI 刷新开销,而不把“通知成功”误当成业务流程的终点。
参考:华为开发者 · NotificationManager API、通知请求结构 NotificationRequest、ArkData Preferences API、UIAbility 生命周期。
更多推荐



所有评论(0)