鸿蒙从零到一 HarmonyOS 通知与提醒实战:消息发布、点击跳转与定时触达
前言
通知负责把应用内的重要变化呈现在系统通知中心,提醒则负责在应用不运行时按时间或日程触达用户。二者看起来都在"弹消息",但生命周期、可靠性和适用场景并不相同。
本文以一个待办应用为例,从普通通知开始,逐步实现进度通知、点击通知进入指定页面,以及由系统托管的定时提醒。同时梳理权限、开关、重复发布、数据更新和排障要点,让通知能力能够真正用于生产环境。
一、先区分通知与提醒
在写代码之前,先根据业务发生的时机选择能力:
| 需求 | 推荐能力 | 示例 |
|---|---|---|
| 业务已经发生,立即告知用户 | NotificationKit | 下载完成、审核结果、聊天消息 |
| 长任务需要持续展示状态 | 进度通知 | 文件下载、数据迁移 |
| 到达指定时间再触达用户 | ReminderAgent | 待办到期、日历日程、倒计时 |
| 只更新当前页面 | ArkUI 状态管理 | 表单校验、列表刷新 |
通知不是后台任务执行器。发布通知以后,应用进程仍可能被系统回收;需要继续下载、定位或播放时,应先使用与业务匹配的后台任务能力,再把通知作为状态反馈。
提醒也不是精确执行任意代码的计时器。它由系统托管,在符合规则的时间展示提醒,适合用户可感知的日程场景,不应被用来绕过后台运行限制。
二、发布一条基础通知
HarmonyOS 的通知能力位于 NotificationKit。基础流程包括构造请求、发布通知和处理异常。
ts
import { notificationManager } from '@kit.NotificationKit'
import { BusinessError } from '@kit.BasicServicesKit'
export class TodoNotificationService {
async publishCompleted(todoId: string, todoTitle: string): Promise<void> {
const request: notificationManager.NotificationRequest = {
id: this.toNotificationId(todoId),
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: '任务已完成',
text: todoTitle,
additionalText: '点击查看详情'
}
}
}
try {
await notificationManager.publish(request)
} catch (error) {
const err = error as BusinessError
console.error(`publish notification failed: ${err.code}, ${err.message}`)
throw err
}
}
private toNotificationId(todoId: string): number {
let hash = 0
for (let index = 0; index < todoId.length; index++) {
hash = (hash * 31 + todoId.charCodeAt(index)) & 0x7fffffff
}
return hash || 1
}
}
id 不应随意使用随机数。相同业务对象使用稳定 ID,再次发布时可以更新原通知,避免通知中心堆出多条重复消息。不同业务对象则应映射到不同 ID。
不同 SDK 版本的类型导入和字段可能略有调整,实际项目应以当前 API 参考和工程编译结果为准。
三、先处理授权与通知开关
用户有权关闭应用通知。生产代码不能把 publish() 成功调用等同于用户一定看到了通知,而应在合适的交互时机检查授权状态。
ts
import { common } from '@kit.AbilityKit'
import { notificationManager } from '@kit.NotificationKit'
export async function ensureNotificationEnabled(
context: common.UIAbilityContext
): Promise<boolean> {
const enabled = await notificationManager.isNotificationEnabled()
if (enabled) {
return true
}
// 应由用户点击"开启通知"等明确操作触发,不要在启动时反复打扰。
await notificationManager.requestEnableNotification(context)
return notificationManager.isNotificationEnabled()
}
授权设计需要遵守三个原则:
- 在用户理解收益的场景中请求,例如用户开启"到期提醒"时。
- 请求前用简短文案说明用途,不承诺通知一定送达。
- 用户拒绝后保留核心功能,并提供进入系统设置的入口。
还要区分应用级通知开关和通知槽级别的设置。应用级开关开启,并不代表每一类通知都能以声音、横幅等方式出现。
四、用通知槽管理不同消息类型
通知槽可以把消息按业务用途分组,例如"任务提醒""同步状态""营销活动"。用户可以分别控制这些类别,应用也能设置合理的重要程度和提示方式。
创建通知槽时应注意:
- 槽 ID 一旦上线就视为稳定协议,不要频繁变更。
- 重要级别由真实业务决定,不要把普通消息都设成强提醒。
- 名称要让用户能理解,避免使用内部模块名。
- 创建操作应具备幂等性,可在应用初始化或首次使用能力时执行。
ts
import { notificationManager } from '@kit.NotificationKit'
const REMINDER_SLOT_ID = 'todo_reminder'
export async function ensureReminderSlot(): Promise<void> {
const slot: notificationManager.NotificationSlot = {
type: notificationManager.SlotType.SOCIAL_COMMUNICATION,
name: '任务提醒',
description: '待办到期和日程变化提醒',
level: notificationManager.SlotLevel.LEVEL_HIGH
}
await notificationManager.addSlot(slot)
}
槽类型和可配置字段会随系统版本演进。如果业务需要特定声音、振动或锁屏展示,应在目标设备和目标 API 版本上验证,不能只看模拟器效果。
发布请求中应关联业务对应的槽。若当前 SDK 通过 slotType 或其他字段关联,请以当前类型定义为准,确保创建和发布使用同一业务分类。
五、点击通知进入指定页面
通知只有文字还不够。用户点击后通常要进入订单、消息或待办详情,而不是停留在应用首页。
HarmonyOS 可以通过 WantAgent 描述点击后的动作。核心思路是把业务 ID 放进 Want 参数,点击通知时拉起目标 UIAbility,再由页面路由到详情。
ts
import { wantAgent, WantAgentInfo, OperationType, WantAgentFlags } from '@kit.AbilityKit'
import { notificationManager } from '@kit.NotificationKit'
async function createTodoWantAgent(todoId: string): Promise<wantAgent.WantAgent> {
const info: WantAgentInfo = {
wants: [{
bundleName: 'com.example.todo',
abilityName: 'EntryAbility',
parameters: {
route: 'TodoDetail',
todoId
}
}],
operationType: OperationType.START_ABILITY,
requestCode: Math.abs(todoId.length * 1009),
wantAgentFlags: [WantAgentFlags.UPDATE_PRESENT_FLAG]
}
return wantAgent.getWantAgent(info)
}
export async function publishTodoReminder(todoId: string, title: string): Promise<void> {
const agent = await createTodoWantAgent(todoId)
const request: notificationManager.NotificationRequest = {
id: Date.now() % 100000000,
wantAgent: agent,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: '待办即将到期',
text: title
}
}
}
await notificationManager.publish(request)
}
在 UIAbility 中读取参数时,要同时覆盖冷启动和热启动:
ts
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.handleNotificationWant(want)
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.handleNotificationWant(want)
}
private handleNotificationWant(want: Want): void {
const route = want.parameters?.route as string | undefined
const todoId = want.parameters?.todoId as string | undefined
if (route === 'TodoDetail' && todoId) {
AppStorage.setOrCreate('pendingTodoId', todoId)
}
}
}
页面出现后消费 pendingTodoId 并调用 Navigation 跳转。消费完成要清空该值,避免页面重建时重复导航。
不要把敏感信息放进 Want 参数。通知链路只传业务主键或一次性令牌,详情数据应在进入应用后从受保护的本地存储或服务端重新读取。
六、更新、取消与去重
通知生命周期不仅有"发布",还包括更新和取消。
6.1 更新进度
下载任务可以使用同一个通知 ID 持续更新内容。更新频率要节流,例如进度变化明显或间隔达到一定时间再发布,避免频繁跨进程调用。
ts
import { notificationManager } from '@kit.NotificationKit'
export async function updateDownloadProgress(
notificationId: number,
fileName: string,
progress: number
): Promise<void> {
const normalized = Math.max(0, Math.min(100, progress))
await notificationManager.publish({
id: notificationId,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: '正在下载',
text: fileName,
additionalText: `${normalized}%`
}
}
})
}
6.2 取消通知
业务失效后应主动取消。例如待办被删除、消息已在其他设备读取,旧通知继续存在会误导用户。
ts
export async function cancelTodoNotification(notificationId: number): Promise<void> {
await notificationManager.cancel(notificationId)
}
如果应用重装、账号切换或本地映射丢失,还要设计清理策略。通知 ID 与账号、业务类型和业务主键的映射应集中管理,避免各模块自行生成导致冲突。
七、使用 ReminderAgent 托管定时提醒
普通通知适合"事件已经发生"。若用户创建了明天上午的待办,需要系统到时再提醒,应使用 ReminderAgent,而不是让应用保持运行或使用普通定时器。
ts
import { reminderAgentManager } from '@kit.BackgroundTasksKit'
export async function scheduleTodoAlarm(
todoId: string,
title: string,
hour: number,
minute: number
): Promise<number> {
const reminder: reminderAgentManager.ReminderRequestAlarm = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM,
hour,
minute,
title: '待办提醒',
content: title,
expiredContent: '待办提醒已过期',
notificationId: Math.abs(todoId.length * 997),
slotType: reminderAgentManager.SlotType.SOCIAL_COMMUNICATION,
wantAgent: {
pkgName: 'com.example.todo',
abilityName: 'EntryAbility'
}
}
return reminderAgentManager.publishReminder(reminder)
}
publishReminder() 返回的提醒 ID 必须持久化,并与待办记录关联。用户修改提醒时间时,先取消旧提醒,再发布新提醒;用户删除待办时同步取消:
ts
export async function replaceReminder(
oldReminderId: number | undefined,
createNew: () => Promise<number>
): Promise<number> {
if (oldReminderId !== undefined) {
await reminderAgentManager.cancelReminder(oldReminderId)
}
return createNew()
}
实际项目还要处理设备重启、时区变化、夏令时和重复提醒规则。日历类业务应存储用户选择的本地时间语义,同时保留时区信息,避免只保存一个毫秒时间戳后产生跨时区偏差。
提醒能力涉及相应权限和配置声明,字段也会因提醒类型与 SDK 版本不同而变化。接入时应检查当前版本的 module.json5 配置、权限文档和接口定义。
八、封装统一的通知服务
页面和业务模块不应直接散落调用系统 API。可以抽象一个面向业务的接口:
ts
export interface AppNotificationService {
notifyTodoDue(todoId: string, title: string): Promise<void>
notifySyncResult(successCount: number, failedCount: number): Promise<void>
scheduleTodo(todoId: string, title: string, time: Date): Promise<number>
cancelNotification(notificationId: number): Promise<void>
cancelReminder(reminderId: number): Promise<void>
}
实现层负责:
- 权限和开关检查;
- 通知槽初始化;
- 业务 ID 到通知 ID 的映射;
- WantAgent 构建;
- 提醒 ID 持久化;
- 错误码转换和日志记录;
- 频率控制与重复消息合并。
这样 ViewModel 只表达"待办到期需要通知",不依赖系统 API 的具体结构,也更容易在单元测试中替换为假实现。
九、错误处理与可观测性
通知失败时,日志至少应包含业务类型、通知 ID、错误码和系统开关状态,但不要记录通知正文、账号信息等敏感数据。
ts
export async function publishSafely(
request: notificationManager.NotificationRequest,
scene: string
): Promise<boolean> {
try {
const enabled = await notificationManager.isNotificationEnabled()
if (!enabled) {
console.info(`notification disabled, scene=${scene}`)
return false
}
await notificationManager.publish(request)
return true
} catch (error) {
const err = error as BusinessError
console.error(
`notification failed, scene=${scene}, id=${request.id}, code=${err.code}`
)
return false
}
}
业务层需要区分两类结果:
- 可预期不可达:用户关闭通知、关闭对应通知槽,不应不断重试。
- 技术失败:参数错误、权限配置错误、系统接口异常,需要记录并排查。
对于服务端推送,还应建立服务端消息 ID、客户端业务 ID 和系统通知 ID 的关联,以便分析重复、延迟和点击转化。
十、常见问题排查
10.1 调用成功但没有看到通知
依次检查:
- 应用级通知开关是否开启;
- 对应通知槽是否关闭或被设置为静默;
- 通知 ID 是否与现有通知相同,导致原通知被更新;
- 设备是否处于免打扰、锁屏限制等状态;
- 标题和内容是否为空,字段是否符合当前 API 约束。
10.2 点击通知只进入首页
检查 WantAgent 的 bundleName、abilityName 和参数;确认 onCreate() 与 onNewWant() 都处理了跳转;确认 ArkUI 页面已初始化后再执行 Navigation。
10.3 多条通知点击后进入同一个详情
通常是 WantAgent 的 requestCode 固定,或更新标志与参数组合不正确。每个业务对象应有稳定且可区分的请求码,并验证系统是否复用了旧 WantAgent。
10.4 修改待办后旧提醒仍然触发
检查是否持久化了 ReminderAgent 返回的提醒 ID。仅更新数据库并不会自动替换系统中的提醒,必须显式取消旧提醒并重新发布。
10.5 重复收到相同消息
不要只在 UI 层去重。应使用服务端消息 ID 或业务事件 ID 建立持久化去重记录,并让通知 ID 保持稳定。进程重启后,内存集合会丢失,无法承担可靠去重。
十一、测试清单
通知和提醒高度依赖系统状态,至少需要真机覆盖以下路径:
- 首次请求授权、同意、拒绝和拒绝后再次进入设置;
- 前台、后台、进程被回收时的通知展示;
- 冷启动点击通知与应用存活时点击通知;
- 同一业务通知的更新、取消和重复消息合并;
- 应用通知开启但业务通知槽关闭;
- 修改时间、删除待办后提醒是否正确替换或取消;
- 重启设备、切换时区、跨天后的提醒行为;
- 长标题、多语言、隐私内容在锁屏上的展示;
- 多账号切换后旧账号通知和提醒是否清理。
自动化测试可以覆盖 ID 映射、去重、参数构造和 ViewModel 行为;系统通知中心展示、声音、振动和点击拉起仍应保留真机验收。
总结
可靠的通知能力不是简单调用一次 publish(),而是一套完整链路:
- 已发生的业务事件使用 NotificationKit 即时通知;
- 未来时间点的用户提醒交给 ReminderAgent 托管;
- 用通知槽区分业务类别,尊重用户的开关和提示偏好;
- 用稳定 ID 更新和取消通知,避免重复堆积;
- 通过 WantAgent 传递最小业务参数,并覆盖冷、热启动跳转;
- 持久化提醒 ID,在业务修改或删除时同步维护系统提醒;
- 把权限、错误、去重和测试纳入统一服务,而不是散落在页面中。
完成这些基础设施后,通知才能从"偶尔弹出一条消息"变成稳定、可维护、尊重用户的业务触达能力。