NotificationKit + HarmonyOS 双工位计时:完成提醒与重复投递防护 -04【共创稿事节】
厨房计时器只要能把 08:00 变成 00:00,就算完成了吗?对 KitchenPulse 而言,还差一层:用户需要知道“灶台·焖煮”已经结束,同时不应因为切换页面、重新进入应用而反复收到相同的完成提醒。前一篇用 deadlineMs 和 Preferences 检查点解决前后台恢复;本篇沿用 hob、oven 两个工位,把业务完成事件接到 HarmonyOS 的通知管理能力上,重点讨论授权、去重、失败和系统约束,而不承诺应用退出后仍会准点运行回调。

一、done 不是“通知已送达”
在上一轮的模型里,运行态工位以 deadlineMs 计算剩余秒数。当某次前台刷新或恢复发现 remainingSeconds === 0,状态就从 running 进入 done。这个状态只表达计时业务已经结束,不能直接解释为操作系统向用户展示了通知。通知是否允许由系统管理,是否发布成功又取决于接口返回;即便发布成功,用户也可能关闭横幅、开启勿扰或没有及时查看。
因此我们把三类事实分开记录。第一类是业务层的 TimerLane.status,继续使用 idle、running、paused、done;第二类是单个完成事件的唯一键,采用 ${lane.id}:${lane.deadlineMs},同一个工位每次重新开始都会产生新的截止时间;第三类是通知提交结果,只用于提示“已发布”“未授权”或“发布失败”。这三个数据不能偷懒地塞成同一个 finished 标志。
设想用户把灶台设为八分钟、烤箱设为十二分钟。灶台结束后进入 done,烤箱仍可能继续计时;点击“已知晓”仅确认灶台的完成提醒,不会让烤箱暂停。若用户把灶台重新设成八分钟,新一轮的截止时间不同,必须允许新通知;若页面仅仅因为前后台切换再次执行了恢复,已经处理过的同一轮完成事件不能再变成新的通知。
需求先限定在普通通知:使用 Notification Kit 的 notificationManager.isNotificationEnabled() 查询允许状态,在明确的界面按钮中调用 requestEnableNotification(context) 请求授权,再由 publish() 发布一条基础文本通知。通知能力从 API version 9 开始提供,并不意味着所有设备、账户和通知模式都具有一致表现。例子不使用系统专属权限、不做强制震动,也不将普通 setInterval 包装成可靠后台闹钟。
二、通知服务只依赖完成事件
我们先定义纯业务事件。上一轮的 restoreLanes() 会把已经越过截止时间的 running 工位转成 done。新增一个比对函数,输入恢复前与恢复后两份工位数据,只生成本次真正发生的完成边沿。必须排除原来就已经 done 的项目,否则每次 onForeground 都会发出重复提醒。
// entry/src/main/ets/model/CompletionEvent.ets
import { TimerLane } from '../common/TimerCheckpoint';
export interface CompletionEvent {
laneId: string;
laneName: string;
deadlineMs: number;
eventKey: string;
}
export function finishedEdges(before: TimerLane[], after: TimerLane[]): CompletionEvent[] {
const events: CompletionEvent[] = [];
for (const current of after) {
const old = before.find((item: TimerLane): boolean => item.id === current.id);
if (old && old.status === 'running' && current.status === 'done') {
events.push({
laneId: current.id,
laneName: current.label,
deadlineMs: current.deadlineMs,
eventKey: `${current.id}:${current.deadlineMs}`
});
}
}
return events;
}
这段函数完全不访问 UI 和通知服务,可以在单元测试里用构造的工位数组验证。典型输入为“灶台 running,截止时间刚过去;烤箱 paused,剩余 260 秒”,输出只包含灶台一个事件。若再次用已经处于 done 的数组作为 before,输出为空。这个规则解释了为什么状态恢复要先得到旧数据、计算新数据,然后才决定是否发布通知。
为了避免重启后重复投递,事件标识还需落盘。继续使用 ArkData Preferences,但与上一轮 lanes_v1 检查点分开保存,例如文件 kitchenpulse_notice、键 published_v1。它只保存最近一批完成事件的键,而不写进业务的 TimerLane。工位结束后先判断是否已经记录;如果记录过,直接返回“已处理”。业务需要限制历史键集合的大小,例如保留最近 20 次,避免本地偏好无限增长。
三、通知授权和发布是两个独立操作
Notification Kit 在 Stage 模型下支持授权查询和请求。授权弹窗应该在界面加载后、用户点击“开启完成通知”时触发,而不是每秒刷新时偷偷调用。用户拒绝之后,程序不能假定重复调用就会弹出同样的授权框;可以在设置页面说明通知关闭,并根据官方文档引导进入通知设置。系统未授权时,应用内仍可以显示“已完成”状态,但不能假装已向通知中心送达。
// entry/src/main/ets/common/CompletionNotifier.ets
import { notificationManager } from '@kit.NotificationKit';
import { common } from '@kit.AbilityKit';
import { CompletionEvent } from '../model/CompletionEvent';
export class CompletionNotifier {
async askPermission(context: common.UIAbilityContext): Promise<boolean> {
if (await notificationManager.isNotificationEnabled()) { return true; }
await notificationManager.requestEnableNotification(context);
return notificationManager.isNotificationEnabled();
}
async publish(event: CompletionEvent): Promise<void> {
const enabled = await notificationManager.isNotificationEnabled();
if (!enabled) { throw new Error('通知尚未开启'); }
const request: notificationManager.NotificationRequest = {
id: event.laneId === 'hob' ? 401 : 402,
content: {
notificationContentType:
notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: '厨房计时已完成',
text: `${event.laneName} 已结束,请及时查看`
}
}
};
await notificationManager.publish(request);
}
}
id 用 401/402 区分两个工位:同一个通知 ID 和标签再次发布时会更新原通知,而不会简单地堆出一排同类型通知。这是平台通知层的更新行为,不等于业务层的持久去重;不同轮次的同一工位可能主动覆盖上一轮卡片,是否需要同时保留历史通知取决于产品选择。示例没有声明通知成功会震动或响铃,因为提醒方式由通知槽、设备设置和用户授权共同决定。要精细控制通知提示方式,可再配置 NotificationSlot,并检查设备支持情况。

需要强调一点:publish() 返回的成功是平台接受发布操作的结果,而不是“用户看见了、听见了”。接口也可能返回通知禁用、通知槽关闭、频率超限等错误。异常分支应把结果转换为可理解的页面反馈,不要捕获之后完全忽略;也不能在失败时把业务 done 改回 running,否则可能让零秒的计时器重新开始计时。
四、为完成事件设计一次提交的记账规则
发布通知前查重,发布成功后记账,是最容易解释的流程。下面的示例只展示关键片段,需与上一轮的 TimerCheckpoint 按相同 UIAbility Context 初始化,并把记录成功的结果保存到 Preferences。publishedKeys 是一次已恢复的本地历史列表;函数在页面中串行调用,避免同一事件被两个异步入口同时处理。
// entry/src/main/ets/common/NoticeLedger.ets
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
export class NoticeLedger {
private context: common.UIAbilityContext;
constructor(context: common.UIAbilityContext) { this.context = context; }
async read(): Promise<string[]> {
const p = await preferences.getPreferences(this.context,
{ name: 'kitchenpulse_notice' });
const value = String(await p.get('published_v1', '[]'));
const parsed = JSON.parse(value) as string[];
if (!Array.isArray(parsed) || !parsed.every((v: string): boolean =>
typeof v === 'string')) { throw new Error('通知历史损坏'); }
return parsed;
}
async markPublished(eventKey: string): Promise<void> {
const p = await preferences.getPreferences(this.context,
{ name: 'kitchenpulse_notice' });
const before = await this.read();
const next = [...new Set<string>([...before, eventKey])].slice(-20);
await p.put('published_v1', JSON.stringify(next));
await p.flush();
}
}
页面恢复时先保存 before,再得到 after = restoreLanes(before, Date.now()),随后调用 finishedEdges(before, after)。对于每个结果,先查询 NoticeLedger.read(),如果键未出现才尝试 CompletionNotifier.publish();发布成功再 markPublished(),并更新 UI 的“通知已提交”状态。askPermission() 则只应由明确的用户按钮触发。为了避免多处刷新同时提交,同一个页面还应维护 dispatchingKeys 集合:发起发布前先加入键,Promise 完成或失败时移除。这个集合只负责当前进程的并发互斥,跨进程仍需仓储级协调。
这里存在一个不能忽视的故障窗口:通知发布成功但 markPublished 写盘失败,重启后可能再次发布同一事件。因此上述流程提供的是通常情况下的幂等保护,不是严格的跨服务事务。平台通知 ID 的覆盖语义可以降低可见重复,真正需要强幂等时则应让有持久事务能力的事件存储成为权威来源,再结合系统 API 的可重复提交策略。反过来如果先记账、后发布,虽然消除了重复,却会产生“记录已经成功、通知实际没有发出”的漏提醒风险,两种顺序各有边界。
五、前后台恢复要区分补发与准点
上一轮明确过:onBackground 只停止 UI 刷新器,不应被当成业务暂停;回到前台时根据 deadlineMs 重算。一条实际可行的补发路径是:用户切出应用十分钟,灶台原本八分钟后结束;回到前台时发现该工位从 running 转成 done,此时生成完成事件并尝试发布通知。这个提醒表示“重新打开后确认了已结束”,并不能推导出应用在离开期间准点发出了通知。
如果要求应用在锁屏、系统回收进程后仍按目标时间自动触发提醒,应单独设计系统支持的后台执行或定时调度方案,验证所使用 SDK 的开放条件、权限、设备和电量策略。普通前台 setInterval 与 notificationManager.publish() 的组合不能替代这些平台保障。本文不伪造后台通知准点送达实验,也不把通知管理 API 写成闹钟调度 API。

还应防止重复提醒引发的体验问题。用户从通知卡片返回应用,页面仍需从本地检查点恢复工位并展示“已完成”;不能因为入口改变就清空烤箱的剩余时间。点击某工位的“重新开始”时应产生新的截止时间,历史的 published_v1 仍可保留,新的事件键自然不同。用户禁用通知后,界面照常显示计时完成,并提供可重新开启的设置入口,而不是反复在前台弹授权窗口。
六、六组验证检查完成闭环
第一组是单工位完成:灶台运行到截止时间,烤箱仍在暂停,预期仅灶台产生一条完成事件。第二组在 done 状态连续触发前台刷新,预期 finishedEdges() 不再生成同一事件。第三组把通知授权关闭,预期计时状态仍为 done,页面提示“通知未开启”,而不是把它当作已经投递。第四组构造 publish 抛错,预期保留可重试的事件标记,同时记录错误结果。第五组在两次请求中人为并发调用同一事件,应由 dispatchingKeys 拦截重复入队。第六组在发布成功但写入历史前重启应用,识别这次故障可能导致重发,验证固定通知 ID 的覆盖表现。
以上是开发时应执行的验证步骤与预期逻辑,而非某一台设备的实测统计。若要量化通知延迟,必须指定机型、系统版本、应用状态、授权状态以及计时基准,记录真实样本后才能讨论分位数。一个示例截图不能证明“后台零延迟”,也不应凭空写出测得的毫秒数。
七、把提醒当成产品契约而不是按钮特效
KitchenPulse 的三条状态通路现在已经分清楚:工位计时通过 deadlineMs 和检查点恢复业务事实;界面通过 ArkUI 呈现当前状态;Notification Kit 在用户允许、应用具备执行机会时尝试发布提醒。它们互相协作,却不具有相同的保证等级。把 done 和“通知已送达”分离后,用户反馈更清晰,工程师也能分别排查计时算法、存储恢复和通知授权问题。
后续可以围绕厨房预设的可编辑性、多个计时事件的聚合展示和重复提醒策略继续迭代。真正有价值的优化不是让界面图标多一个动画,而是让每个重要状态都有稳定的含义,并且知道哪些保证来自应用代码、哪些需要系统能力。
官方参考:NotificationManager 通知管理 API、NotificationRequest 数据结构、通知槽开发指南、UIAbility 生命周期。
更多推荐

所有评论(0)