【鸿蒙心迹】通知与后台任务实战:应用不在场时,谁替你把提醒送到
【鸿蒙心迹】通知与后台任务实战:应用不在场时,谁替你把提醒送到
前言
性能篇收束时预告过,下一章从"不在场"开始。这个题目对码上清单来说躲不掉:待办应用的天职是提醒,而提醒响起的那个时刻,应用大概率不在前台,甚至进程都已经不在了。用户晚上十一点建了一条"明早九点交方案",然后划掉应用睡觉去了——九点整,谁来把这条提醒送到?
最早的实现很天真:页面里起一个定时器,到点发一条本地通知。开发机上测试完美,因为应用一直开着。真到了用户手里,应用退到后台没几分钟进程就被挂起,定时器跟着一起停摆——提醒要么不响,要么等用户下次打开应用时"补响"。一个迟到的提醒比没有提醒更糟:用户设提醒,赌的就是那一刻应用不需要在场。
这篇记录把提醒链路补完整的过程,分三段:通知怎么发出去、进程不在时谁替你发、以及确实需要自己的代码在后台跑时,有哪几条合规通道。环境照旧:DevEco Studio 5.x、HarmonyOS NEXT(API 14+),接口与配额以官方 Notification Kit、Background Tasks Kit 文档为准。
先立心智:退后台不等于"还活着"
鸿蒙对后台的态度很干脆:应用退至后台一小段时间后进程被挂起,资源紧张时直接终止。挂起不是"跑得慢",是"不跑了"——CPU、网络、定时器、公共事件,挂起期间统统用不了。系统这么做的理由写在文档第一段:后台应用继续跑,耗电和卡顿都是全体用户买单。
所以"到点提醒"要拆成两个问题:通知由谁在那个时刻发出,以及发出之前需不需要我的代码跑一下。系统给的答案,是四条规范内的通道:
- 代理提醒:到点由系统替应用发通知,应用进程在不在都行——定时提醒类业务的正解;
- 短时任务:退后台前申请一小段宽限期,把手上的活收尾,比如状态保存;
- 长时任务:音乐播放、导航、设备连接这类用户可感知的持续服务,申请后进程不被挂起;
- 延迟任务:不急的活交给系统挑时机统一调度,比如有网时同步备份。
判断句式一句话:到点只需要"响一下"的,交给代理提醒;需要自己的代码跑的,再按"多急、多久"在后三条里选。码上清单最初踩的坑,就是拿"让自己的代码一直活着"去解决一个"到点响一下"的问题——方向错了,后面所有努力都在加固错误。
通知授权:弹窗只有一次机会
发通知前要用户授权。接口是 requestEnableNotification,第一次调用弹窗让用户选。它看着和权限篇的 user_grant 流程一样,但规则狠得多:用户一旦点了拒绝,这个接口就再也拉不起弹窗,后续调用直接抛错误码 1600004。权限篇里说过,普通权限第一次被拒后还能再问一次;通知授权没有这个待遇,拒绝一次,弹窗永久关闭。
第一版我把它放在了首页 aboutToAppear——应用第一次打开就弹"是否允许发送通知"。用户连这个应用能干什么都还没弄明白,顺手点了"不允许",于是提醒功能在这台手机上直接报废,只剩引导设置一条补救路。这是权限篇"场景化申请"原则的原样复刻,只是这次学费更贵。
改法两步。第一,把弹窗挪到用户第一次给待办设置提醒时间的那一刻——此刻"允许通知"和他刚做的动作是因果关系,不需要任何解释话术。第二,被拒之后改走 openNotificationSettings(API 13 起),拉起通知管理的半模态页,让用户不离开应用就能把开关打开:
import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
// 设置提醒时间时调用;返回 true 才继续走发布提醒的流程
async function ensureNotifyEnabled(ctx: common.UIAbilityContext): Promise<boolean> {
if (await notificationManager.isNotificationEnabled()) {
return true;
}
try {
await notificationManager.requestEnableNotification(ctx);
return true;
} catch (err) {
if ((err as BusinessError).code === 1600004) {
// 用户拒绝过:系统弹窗不会再出现,先在自己页面上说清"为什么",
// 再拉起通知管理半模态页;用户改完开关,下次保存时重新走一遍检查
await notificationManager.openNotificationSettings(ctx);
}
return false;
}
}
半模态页拉起之前,先用自己的页面把"为什么需要"讲一遍,别让用户裸脸面对一个系统设置页——这是权限篇立过的规矩,原样适用。
渠道选型:不是所有通知都配打扰用户
授权之后是渠道。SlotType 决定一条通知的打扰度,官方文档的对照表很直白:SOCIAL_COMMUNICATION(社交通信)和 SERVICE_INFORMATION(服务提醒)是全通道——通知中心、横幅、锁屏、铃声振动全都有;CONTENT_INFORMATION(内容资讯)只进通知中心,不横幅、不响铃;OTHER_TYPES 同样只能在通知中心里安静躺着。
码上清单有两类通知:待办到期提醒,和每周一次的完成情况周报。最初图省事全走同一个渠道,结果周报在周一早上和三条到期提醒一起响铃,用户分不清哪条要紧。拆开之后的原则写进了代码注释:用户主动设置的到期提醒走 SERVICE_INFORMATION,它是用户自己要的打扰;应用自己想说的话(周报、功能提示)走 CONTENT_INFORMATION,只配进通知中心。
渠道不需要提前手动创建——发布时在请求里带上渠道类型,对应渠道不存在会自动创建。但渠道语义定了就别乱换:把自说自话的运营内容塞进服务提醒渠道,短期多了曝光,长期换来的是用户把整个应用的通知一刀切掉。
代理提醒:进程没了,系统替你响
进入正题。到点提醒用 reminderAgentManager,支持倒计时、日历、闹钟三类,待办的"某月某日几点"对应日历类型:
import { reminderAgentManager } from '@kit.BackgroundTasksKit';
import { notificationManager } from '@kit.NotificationKit';
async function publishTodoReminder(todoId: number, title: string,
dueTime: Date): Promise<number> {
const request: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
year: dueTime.getFullYear(),
month: dueTime.getMonth() + 1, // 月份从 0 数起,记得加一
day: dueTime.getDate(),
hour: dueTime.getHours(),
minute: dueTime.getMinutes()
},
title: '待办到期',
content: title,
notificationId: todoId, // 相同 ID 的提醒会互相覆盖
slotType: notificationManager.SlotType.SERVICE_INFORMATION,
wantAgent: { pkgName: 'com.example.todoapp', abilityName: 'EntryAbility' },
actionButton: [{
title: '知道了',
type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE
}]
};
// 返回的 reminderId 必须落库:改期与删除全靠它
return await reminderAgentManager.publishReminder(request);
}
发布之前有三件事要配平。其一,module.json5 里声明 ohos.permission.PUBLISH_AGENT_REMINDER(system_grant 级别,声明即得)。其二,通知授权已拿到——上一节那步不能跳。其三,一条最容易漏的:商用环境下代理提醒是受管控能力,需要按官方文档的流程发邮件申请开通,目前只对工具类应用开放。码上清单是纯工具应用,申请走得通,但这件事必须排进上线计划:权限声明了、代码也对,没开通之前接口照样调不通,这种"万事俱备只欠白名单"的坑排查起来最冤,以官方文档最新说明为准。
系统给的硬约束也要记牢:一个普通应用最多 30 个有效提醒。对待办应用这不是宽裕数字——重度用户同时挂着几十条带时间的待办很正常。解法是只把"最近要到期的一批"注册成代理提醒,其余留在数据库里,应用每次回到前台用 getValidReminders 对一次账,把腾出来的名额补给接下来最近的待办。还有一条"有效"的定义值得留意:到点弹出的提醒,用户点了关闭按钮才算过期,一直不点就一直占着名额;周期性提醒则无论点不点都始终有效。
需要自己跑代码时:三条通道的分界
提醒交给系统后,码上清单真正要在后台跑代码的只剩两件事:退后台时把未保存的编辑落盘,以及隔三差五把本地数据同步到云端。它们恰好对应两条不同的通道。
落盘走短时任务。 应用退后台后很快被挂起,如果此刻还有一次写库没完成,就申请一小段宽限期:
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
// 在 UIAbility 的 onBackground 里调用
function flushOnBackground(): void {
let requestId = -1;
const done = () => {
if (requestId < 0) return;
try { backgroundTaskManager.cancelSuspendDelay(requestId); } catch (e) { /* 已取消 */ }
requestId = -1;
};
try {
// 回调在宽限期即将耗尽时触发:只做收尾和取消,别在这里才开始干活
requestId = backgroundTaskManager.requestSuspendDelay('flush pending edits', done).requestId;
} catch (e) {
// 配额耗尽或申请时机不对:拿不到宽限期,落盘只能尽力而为
}
draftStore.flush().finally(done); // 干完主动取消,否则白白消耗当日配额
}
它的规矩全是数字:必须在前台或 onBackground 回调里申请;同一时刻最多 3 个;单次最长 3 分钟,低电量时缩到 1 分钟,单日总配额默认 10 分钟;快超时时系统会回调通知你,不取消就等着被挂起甚至终止。配额只在应用处于后台期间计时,所以"干完立刻取消"不是洁癖,是在给当天后面的自己省额度。
同步走延迟任务。 云同步晚半个小时没人在乎,这正是延迟任务的定位:声明触发条件,交给系统挑时机。先实现一个 WorkSchedulerExtensionAbility,并在 module.json5 的 extensionAbilities 里注册,type 填 workScheduler:
import { workScheduler, WorkSchedulerExtensionAbility } from '@kit.BackgroundTasksKit';
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
onWorkStart(work: workScheduler.WorkInfo): void {
// 单次回调最长运行 2 分钟:只推增量,一次推不完留给下一轮
syncService.pushIncremental();
}
onWorkStop(work: workScheduler.WorkInfo): void {
// 超时或被取消时触发:记下断点,下次从这里接着来
syncService.saveCheckpoint();
}
}
申请用 workScheduler.startWork,传入 workId、bundleName、abilityName,再加触发条件——码上清单设的是 networkType: workScheduler.NetworkType.NETWORK_TYPE_WIFI。约束同样要背下来:一个应用同一时刻最多 10 个延迟任务;单次回调最长 2 分钟;执行频率由系统按应用活跃分组决定——活跃分组最小间隔 2 小时,经常使用 4 小时,常用 24 小时,极少使用 48 小时,受限与从未使用的直接禁止。翻译成人话:用户越少打开你,系统越少替你干活,逻辑很公平。调试时不必干等两小时,hidumper -s 1904 -a '-t <包名> <Ability名>' 可以手动触发一次回调,先验证 onWorkStart 的逻辑通不通。
长时任务这次没用上,但边界值得说清。它要声明 ohos.permission.KEEP_BACKGROUND_RUNNING,在 backgroundModes 里写明类型(数据传输、音频播放、定位导航等),申请成功后通知栏会常驻一条长时任务通知。它的判断标准是"用户此刻能感知到应用在持续为他服务"——播放、导航、传文件,条条对得上;待办应用没有任何场景配得上这个标准,硬申请换来的只有通知栏里一条用户看不懂的常驻通知。没用上的能力,说清楚为什么不用,比硬用上更有价值。
踩坑三条
- 改了时间,旧提醒照响:待办从九点改到十点,结果九点、十点各响一次。原因是编辑时只发布了新提醒,旧的
reminderId还有效。解法是把"改期"定义成一个原子动作:先cancelReminder旧 ID,再发布新的,新 ID 覆盖落库;删除待办、勾选完成同理——三个入口共用同一个函数,别各写各的; - 把短时任务当长时任务用:早期把云同步也塞进了退后台的宽限期,数据量一大就超时,进程被挂起时一次批量写入停在半截。短时任务只配干秒级的收尾,而且写库必须包在事务里——持久化篇那条"批量写走事务"的纪律,在这里救了数据;
- 拿延迟任务赌时效:曾想用延迟任务定时拉取云端的最新待办(另一台设备上的改动),上线后发现有人半天收不到。翻约束才明白:频率是系统按活跃分组给的,两小时起步,还要叠加内存、温度、功耗的统一调度。延迟任务承诺的是"会做",从不承诺"何时做"——对时效有要求的更新得另想办法,比如回到前台时主动拉一次。
收个尾
回头看,提醒这条链路的关键不在任何一个接口,而在最开始那个问题问对了:这件事发生的时候,需要我的代码在场吗? 不需要,就交给系统——代理提醒比任何保活手段都准时;需要,就按"多急、多久"老实领配额,在配额里把活干完。鸿蒙的后台模型没有后门,接受这一点之后,设计反而变简单了。
照例自查清单:
- 通知授权是在用户理解用途的那一刻弹的吗?被拒之后有引导路径吗?
- 每类通知的渠道,对得起它的打扰度吗?
- 改期、删除、完成三个入口,都取消了旧提醒吗?有效提醒数离 30 的上限还有多远?
- 短时任务干完主动取消了吗?超时回调里是在收尾,还是才开始干活?
- 交给延迟任务的业务,接受"两小时到两天之间的某个时刻"这种时效吗?
下一篇继续"不在场"这条线的另一半:应用在这台设备上不在场,但用户正在另一台设备上——聊分布式数据同步与跨设备接续,让手机上记到一半的待办,在平板上接着写完。下篇见。
*本文环境:DevEco Studio 5.x / HarmonyOS NEXT(API 14+)。通知授权、渠道、代理提醒与三类后台任务的接口及配额,以官方 Notification Kit、Background Tasks Kit 文档为准;代理提醒的管控申请流程以官方最新说明为准。系列上一篇:《【鸿蒙心迹】冷启动优化实战》,下一篇:分布式数据同步与跨设备接续。*
更多推荐


所有评论(0)