本文基于 HarmonyOS NEXT(API 12+)Stage 模型,所有接口签名与字段说明均对照官方 API 参考核对。文中标注了 API 11 起废弃的写法,供老项目迁移参考。
- "定时"到底归谁管 :很多人第一反应是
NotificationRequest.deliveryTime,但它是只读的、系统自动生成的字段,你写进去也不会生效。 - 点击跳转 :
WantAgent有一批 API 11 废弃的字段,抄老博客会直接踩到;冷启动/热启动两条路径要分别处理。 - 桌面角标 :
badgeNumber字段是累加 语义,setBadgeNumber()是直接设定语义,两者混用会出现"清了又冒出来"的经典 bug。
下面按这个顺序拆开讲。
一、先搞懂:Notification 没有"定时发送"能力
打开 NotificationRequest 的字段表,会看到 deliveryTime:
| 字段 | 类型 | 说明 |
|---|---|---|
deliveryTime |
number | 通知发送时间。系统自动生成,无需开发者配置。时间戳,单位 ms |
showDeliveryTime |
boolean | 是否显示分发时间。预留能力,暂未支持 |
也就是说,deliveryTime 是系统在通知落库时打的时间戳,用来记录/展示"这条通知什么时候发的",不是让你预约发送时间的入口。往里塞一个未来时间戳,不会让通知延迟出现。
另外要明确本地通知的边界:本地通知由你的应用进程发布,进程不存活就发不出来。想实现"应用退到后台甚至被杀掉之后,到点仍然弹出提醒",必须依赖系统代理------这正是**代理提醒(Agent-powered Reminder)**存在的理由。
所以定时消息的正确打开方式是:
css
定时能力 → reminderAgentManager(Background Tasks Kit,代理提醒)
展示能力 → Notification Kit(代理提醒到点后由系统调用它发通知)
二、通知授权:一切的前提
没有用户授权,后面所有代码都是空转。发布前必须先申请。
ts
import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit';
// 在 UIAbility 中调用
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
return;
}
// ⚠️ 必须等 loadContent 成功后才能调用,否则拉起弹窗失败
notificationManager.requestEnableNotification(this.context)
.then(() => {
hilog.info(0x0000, 'notify', '用户已授权');
})
.catch((e: BusinessError) => {
// 1600004:用户已拒绝
// 1600013:已有授权弹窗在展示中
hilog.error(0x0000, 'notify', `授权失败 code=${e.code} msg=${e.message}`);
});
});
}
三个必须记住的行为:
requestEnableNotification()只能在loadContent成功之后调用,否则弹窗拉不起来。- 用户拒绝后,这个接口不会再弹第二次 。二次引导要走
notificationManager.openNotificationSettingsWithResult()(API 13+)拉起通知管理页,让用户手动打开。 - 每次都先查状态,不要无脑弹 :
isNotificationEnabledSync()(API 12+,同步)或isNotificationEnabled()(异步)返回false时,直接走引导逻辑。
ts
import { notificationManager } from '@kit.NotificationKit';
if (!notificationManager.isNotificationEnabledSync()) {
// 走设置页引导,而不是再调 requestEnableNotification
await notificationManager.openNotificationSettingsWithResult(this.context);
}
三、发一条基础通知
3.1 先选渠道类型
渠道类型(SlotType)决定这条通知的提醒强度,它会直接影响横幅、提示音、锁屏展示:
| 渠道类型 | 值 | 对应级别 | 适合场景 |
|---|---|---|---|
SOCIAL_COMMUNICATION |
1 | LEVEL_HIGH | 社交通信、IM 消息 |
SERVICE_INFORMATION |
2 | LEVEL_HIGH | 服务提醒、订单/物流/待办 |
CONTENT_INFORMATION |
3 | LEVEL_MIN | 内容资讯(默认静默,无横幅无声音) |
CUSTOMER_SERVICE |
5 | LEVEL_DEFAULT | 客服消息,需用户主动发起 |
OTHER_TYPES |
0xFFFF | LEVEL_MIN | 其他(默认值) |
常见事故 :把定时提醒发在
CONTENT_INFORMATION渠道上,结果到点只有状态栏一个小图标,既没横幅也没声音------因为该渠道对应LEVEL_MIN。提醒类通知请用SERVICE_INFORMATION。
3.2 字段名注意废弃
ts
// ❌ API 11 起废弃
content: { contentType: ... }
notificationSlotType: ... // 对应的旧字段是 slotType,已废弃
// ✅ API 11+
content: { notificationContentType: ... }
notificationSlotType: notificationManager.SlotType.SERVICE_INFORMATION
3.3 完整发布代码
ts
import { notificationManager } from '@kit.NotificationKit';
async function publishBasic(title: string, text: string): Promise<void> {
const request: notificationManager.NotificationRequest = {
id: 1001, // 同 id 重复发布 = 更新该条通知
notificationSlotType: notificationManager.SlotType.SERVICE_INFORMATION,
content: {
notificationContentType:
notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: title, // 不可为空串,上限 1024 字节
text: text,
additionalText: ' '
}
},
// 可选:点击后是否自动清除当前通知,默认 true
tapDismissed: true,
// 可选:到达该时间戳后自动清除通知
// autoDeletedTime: Date.now() + 30_000
};
await notificationManager.publish(request);
}
几个容易忽略的点:
id是覆盖键 。同一个id再发布一次,是"更新"而不是"新增"。tapDismissed只在通知携带wantAgent或actionButton时生效 。默认true,即点击后自动消失;如果你的业务希望点完仍留在通知中心(比如"标记已读"),要显式设false。autoDeletedTime才是真正的"定时消除"------它是通知的过期清除时间,跟"定时发送"是两回事,别混淆。
四、定时消息:代理提醒三件套
代理提醒提供三种提醒类型:
| 类型 | 枚举值 | 关键字段 | 场景 |
|---|---|---|---|
| 倒计时 | REMINDER_TYPE_TIMER |
triggerTimeInSeconds |
番茄钟、倒计时结束提醒 |
| 日历 | REMINDER_TYPE_CALENDAR |
dateTime、repeatMonths/repeatDays/daysOfWeek |
指定日期时间的一次性/周期性提醒 |
| 闹钟 | REMINDER_TYPE_ALARM |
hour、minute、daysOfWeek |
每日固定时刻提醒(打卡、吃药) |
4.1 前置准备
(1) 声明权限 :在 module.json5 中声明:
json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
}
]
}
}
(2) 申请通知授权 :官方文档明确要求,publishReminder 需要在调用过 requestEnableNotification() 之后才能用(见第二节)。
(3) AGC 侧开通"代理提醒"开放能力 :这是上线前最容易漏的一步 。代理提醒属于受控开放能力,需要在 AppGallery Connect 申请开通并重新生成 Profile 签名,否则真机上 publishReminder 会失败。具体入口与材料要求以官方最新说明为准,建议在开发排期里就把这条挂上。
4.2 发布日历提醒
ts
import { reminderAgentManager } from '@kit.BackgroundTasksKit';
import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit';
async function publishCalendarReminder(
target: Date,
title: string,
content: string
): Promise<number> {
const calendar: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
year: target.getFullYear(),
month: target.getMonth() + 1, // ⚠️ 取值 [1,12],别直接用 getMonth()
day: target.getDate(),
hour: target.getHours(),
minute: target.getMinutes(),
second: 0
},
// 三方应用最多两个按钮
actionButton: [
{
title: '关闭',
type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE
},
{
title: '稍后提醒',
type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_SNOOZE
}
],
// 点击提醒后跳转的目标(简化对象,不是 WantAgent 实例)
wantAgent: {
pkgName: 'com.example.demo',
abilityName: 'EntryAbility',
parameters: { from: 'reminder', reminderId: 1001 }
},
ringDuration: 5, // 单位 s,范围 [0, 1800]
snoozeTimes: 3, // 延时提醒次数
timeInterval: 300, // 延时间隔,单位 s,最少 30
title: title,
content: content,
expiredContent: '提醒已过期',
snoozeContent: '稍后再次提醒你',
notificationId: 1001, // ⚠️ 相同 id 的提醒会互相覆盖
slotType: notificationManager.SlotType.SERVICE_INFORMATION,
tapDismissed: true
};
try {
const reminderId: number = await reminderAgentManager.publishReminder(calendar);
// ⚠️ 一定要持久化这个 id,取消/更新时都要用
return reminderId;
} catch (e) {
const err = e as BusinessError;
if (err.code === 1700001) {
// 通知未开启 ------ 引导用户去授权
} else if (err.code === 1700002) {
// 提醒数量超限
}
throw e;
}
}
4.3 取消与查询
ts
// 按 id 取消
await reminderAgentManager.cancelReminder(reminderId);
// 取消本应用所有提醒
await reminderAgentManager.cancelAllReminders();
// 查询所有仍然有效的提醒(用于页面回显 / 数据校准)
const validList: reminderAgentManager.ReminderRequest[] =
await reminderAgentManager.getValidReminders();
getValidReminders() 非常有用:本地数据库和系统里实际存在的提醒很容易漂移(用户卸载重装、系统清理、异常中断),每次进入提醒列表页时用它做一次对账,比信任本地存储可靠得多。
4.4 约束与限制(务必先看)
- 个数限制 :一个普通应用最多 30 个有效提醒 ,系统应用最多 10000 个,整个系统上限 12000 个。超限报
1700002。 - 有效期语义 :到点弹出后,如果用户没点"关闭"按钮,这条提醒仍然算有效/未过期 ;点了关闭才算过期。周期性提醒(如"每天提醒")无论是否点关闭,始终有效。这意味着非周期提醒用完后要主动
cancelReminder,否则会持续占用 30 个名额。 - 跳转限制 :点击提醒跳转的必须是申请代理提醒的本应用,不能跳到别的 App。
- 提醒表现受系统设置影响 :勿扰模式、关闭横幅、静音都会改变实际表现,
publishReminder成功只代表系统已受理,不代表一定准时高调弹出。 - 模拟器:API 20 起支持在模拟器上开发调试,之前的版本需要真机。
五、点击路由跳转
这里有个关键分叉:代理提醒和普通通知,用的不是同一个 wantAgent 类型。
| 场景 | wantAgent 类型 |
构造方式 |
|---|---|---|
普通通知 NotificationRequest |
wantAgent.WantAgent 实例 |
await wantAgent.getWantAgent(info) |
代理提醒 ReminderRequest |
普通对象 { pkgName, abilityName, parameters?, uri? } |
直接字面量 |
抄代码时经常混用,导致点击无反应或者跳转目标不对。
5.1 构造 WantAgent(普通通知用)
ts
import { wantAgent, Want } from '@kit.AbilityKit';
async function buildWantAgent(detailId: string): Promise<wantAgent.WantAgent> {
const want: Want = {
bundleName: 'com.example.demo',
abilityName: 'EntryAbility',
parameters: {
from: 'notification',
detailId: detailId
}
};
const info: wantAgent.WantAgentInfo = {
wants: [want], // ⚠️ 预留能力,当前只取数组第一个元素
actionType: wantAgent.OperationType.START_ABILITY,
requestCode: 1001,
actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
};
return await wantAgent.getWantAgent(info);
}
废弃字段对照(API 11 起):
| 旧写法(已废弃) | 新写法(API 11+) |
|---|---|
operationType |
actionType |
wantAgentFlags |
actionFlags |
另外,NOTIFICATION_CONTROLLER 之外还有一个高频需求------通知被用户移除时触发逻辑 ,对应 NotificationRequest.removalWantAgent。注意它有硬限制:当前只支持发布公共事件,不支持跳转 UIAbility (即 actionType 必须取 SEND_COMMON_EVENT,值 4)。
5.2 requestCode 与 actionFlags 的配合
这两个字段决定"重复发布同一 id 的通知时,旧的 WantAgent 会怎样":
actionFlags |
值 | 行为 |
|---|---|---|
ONE_TIME_FLAG |
0 | 只能触发一次,触发后自动取消 |
NO_BUILD_FLAG |
1 | 不存在则不创建,直接返回 null |
CANCEL_PRESENT_FLAG |
2 | 创建新的之前,先取消已存在的 |
UPDATE_PRESENT_FLAG |
3 | 用新 WantAgent 的额外数据替换已存在的 |
CONSTANT_FLAG |
4 | WantAgent 不可变 |
实务建议 :如果 requestCode 固定,而每次通知携带的 detailId 不同,必须带 UPDATE_PRESENT_FLAG ,否则系统复用的还是第一次那个 WantAgent,用户点第二条通知跳进的却是第一条的详情页------这是最高频的路由 bug 之一。更稳妥的做法是用业务唯一值参与 requestCode,避免复用。
5.3 接收端:冷启动与热启动要分别处理
在 Stage 模型下,点击通知拉起应用有两条路径:
- 冷启动 (进程不在)→ 走
onCreate - 热启动 (进程还在,Ability 已存在)→ 走
onNewWant
只写 onCreate 是最常见的漏配,表现为"第一次点能跳,第二次点没反应"。
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 params = want.parameters;
if (!params) {
return;
}
const from = params['from'] as string;
if (from !== 'notification' && from !== 'reminder') {
return;
}
// ⚠️ 关键:不要在 Ability 里直接 router.pushUrl
// 此时 UI 可能还没 ready,路由会失败
// 正确做法是暂存"待跳转意图",由页面消费
AppStorage.setOrCreate('pendingRoute', {
from: from,
detailId: (params['detailId'] as string) ?? '',
reminderId: (params['reminderId'] as number) ?? 0
});
}
}
页面侧消费:
ts
import { router } from '@kit.ArkUI';
@Entry
@Component
struct Index {
aboutToAppear(): void {
const pending = AppStorage.get<Record<string, Object>>('pendingRoute');
if (!pending) {
return;
}
// 消费后立刻清掉,避免返回时重复跳转
AppStorage.delete('pendingRoute');
if (pending['from'] === 'notification') {
AppStorage.setOrCreate('currentDetailId', pending['detailId']);
router.pushUrl({ url: 'pages/DetailPage' });
}
}
build() { /* ... */ }
}
两个补充要点:
module.json5里EntryAbility建议配"launchType": "singleton",避免每次点击通知都新起一个 Ability 实例。want.parameters里只放可序列化的基础类型 (string / number / boolean)。传对象、类实例很容易在跨进程后丢掉或变成undefined。复杂数据请只传 id,落地后回查。
六、桌面角标适配
角标有两条路径,语义完全不同,这是最容易出 bug 的地方。
6.1 两种设置方式的区别
| 方式 | 语义 | 说明 |
|---|---|---|
通知携带 badgeNumber 字段 |
累加 | 每发布一条按值累加,≤0 时忽略本次设定,>99 显示 99+ |
notificationManager.setBadgeNumber(n) |
直接设定 | 桌面按 n 直接呈现,n ≤ 0 时清除角标 |
官方文档给的累加示例非常直观:
应用发布 3 条通知,
badgeNumber依次设置为2、0、3,应用将依次展示为2、2、5。
注意第二个值是 0 ------ 0 被忽略,角标仍停在 2 。很多人以为 badgeNumber: 0 能清零,这是错的。
ts
// 方式 A:随通知累加(适合"来一条消息 +1")
const request: notificationManager.NotificationRequest = {
id: 1001,
badgeNumber: 1,
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
content: { /* ... */ }
};
await notificationManager.publish(request);
// 方式 B:直接设定 / 清零(适合"进入应用后显示未读总数")
await notificationManager.setBadgeNumber(5); // 桌面显示 5
await notificationManager.setBadgeNumber(0); // 清除角标
6.2 "清了又冒出来"是怎么来的
典型的事故链是这样:
- 收消息时用
badgeNumber: 1累加 → 角标涨到 5。 - 用户进入应用,调
setBadgeNumber(0)清零。 - 但此时通知中心里还挂着 5 条未读通知。用户点了其中一条,或系统重新渲染通知列表,累加逻辑再次生效 → 角标又变回 5。
根因是混用两种机制:一种在"加",一种在"设",而通知中心的存量通知会持续触发"加"。
解决思路 :二选一,不要混用。
- 想用
setBadgeNumber做精确控制 → 清角标的同时清理对应通知 (notificationManager.cancel(id)/cancelAll()/cancelGroup(groupName)),否则存量通知会把它顶回去。 - 想用
badgeNumber累加 → 就别再调setBadgeNumber,靠"发布通知"和"取消通知"来驱动角标。
另外,setBadgeNumber 是异步接口,连续调用必须串行 (用 await 排队)。并发调用会让最终值不可预期。
6.3 角标不显示的排查清单
按顺序排查,基本能覆盖 95% 的情况:
- 通知授权是否拿到?没授权一切白搭。
- 系统"桌面角标"开关是否打开 ?路径大致在
设置 > 通知 > 应用 > 桌面角标。部分 ROM 需要同时打开"通知开关"和"桌面角标"两项。 - 通知渠道是否允许显示角标 ?渠道字段
badgeFlag(默认true)控制是否显示角标,检查一下是不是被关掉了。 - 设备能力差异 :部分设备/平板只支持红点,不支持数字角标;Wearable 上部分接口直接返回
801(Capability not supported,API 18 起)。 - 接口是否静默失败 :
setBadgeNumber失败不会弹任何提示,必须自己接catch打日志。
还有一条反直觉的行为,官方 FAQ 里专门提过:打开应用、点击通知、手动清理通知,都不会自动清除角标数字。想让角标归零,必须显式调用清除逻辑。所以"进入应用首页时清角标"这行代码,得你自己写。
七、封装:一个能直接用的工具类
把上面的逻辑收拢成一个 helper,业务侧只关心"我要提醒什么、点了跳哪里"。
ts
import { notificationManager } from '@kit.NotificationKit';
import { reminderAgentManager } from '@kit.BackgroundTasksKit';
import { wantAgent, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
const BUNDLE = 'com.example.demo';
const ABILITY = 'EntryAbility';
export class NotifyHelper {
/** 检查并引导通知授权 */
static async ensureEnabled(): Promise<boolean> {
if (notificationManager.isNotificationEnabledSync()) {
return true;
}
try {
await notificationManager.requestEnableNotification(AppStorage.get('ctx'));
return true;
} catch (e) {
return false;
}
}
/** 发布一条可点击跳转的普通通知 */
static async publish(opts: {
id: number;
title: string;
text: string;
route?: Record<string, Object>;
badge?: number;
slot?: notificationManager.SlotType;
}): Promise<void> {
let agent: wantAgent.WantAgent | undefined = undefined;
if (opts.route) {
const want: Want = {
bundleName: BUNDLE,
abilityName: ABILITY,
parameters: opts.route
};
agent = await wantAgent.getWantAgent({
wants: [want],
actionType: wantAgent.OperationType.START_ABILITY,
// 用 id 参与 requestCode,避免不同通知复用同一个 WantAgent
requestCode: opts.id,
actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
});
}
const request: notificationManager.NotificationRequest = {
id: opts.id,
notificationSlotType:
opts.slot ?? notificationManager.SlotType.SERVICE_INFORMATION,
badgeNumber: opts.badge,
wantAgent: agent,
content: {
notificationContentType:
notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: { title: opts.title, text: opts.text, additionalText: ' ' }
}
};
await notificationManager.publish(request);
}
/** 发布定时提醒,返回 reminderId(请务必持久化) */
static async scheduleAt(
target: Date,
title: string,
content: string,
notificationId: number
): Promise<number> {
const req: reminderAgentManager.ReminderRequestCalendar = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,
dateTime: {
year: target.getFullYear(),
month: target.getMonth() + 1,
day: target.getDate(),
hour: target.getHours(),
minute: target.getMinutes()
},
wantAgent: {
pkgName: BUNDLE,
abilityName: ABILITY,
parameters: { from: 'reminder', reminderId: notificationId }
},
actionButton: [
{
title: '关闭',
type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE
}
],
ringDuration: 5,
title: title,
content: content,
notificationId: notificationId,
slotType: notificationManager.SlotType.SERVICE_INFORMATION
};
return await reminderAgentManager.publishReminder(req);
}
/** 取消定时提醒 */
static async cancelSchedule(reminderId: number): Promise<void> {
await reminderAgentManager.cancelReminder(reminderId);
}
/** 清角标:先清通知,再清数字,顺序不能反 */
static async clearBadge(): Promise<void> {
try {
await notificationManager.cancelAll();
await notificationManager.setBadgeNumber(0);
} catch (e) {
const err = e as BusinessError;
hilog.error(0x0000, 'notify', `清角标失败 code=${err.code}`);
}
}
}
八、避坑速查
| 现象 | 大概率原因 |
|---|---|
写了 deliveryTime 但通知立刻弹出 |
deliveryTime 是系统生成的只读字段,不做定时 |
| 应用被杀后定时提醒不生效 | 用了普通通知而非代理提醒 |
publishReminder 报 1700001 |
通知授权未获取 |
publishReminder 报 1700002 |
有效提醒超过 30 条(非周期提醒用完没取消) |
| 提醒只有状态栏图标,无横幅无声音 | 渠道选了 CONTENT_INFORMATION(LEVEL_MIN) |
| 第二次点通知没反应 | 只写了 onCreate,漏了 onNewWant |
| 点第二条通知进了第一条的详情 | requestCode 固定且没带 UPDATE_PRESENT_FLAG |
| 点击通知跳转失败、白屏 | 在 Ability 里直接 router.pushUrl,UI 尚未 ready |
| 角标清零后又自动变回来 | badgeNumber 累加与 setBadgeNumber 混用,存量通知把它顶回去 |
设了 badgeNumber: 0 但角标没清 |
0 属于"≤0 忽略本次设定",不是清零 |
| 部分机型角标不显示 | 系统"桌面角标"开关未开 / 该设备仅支持红点 |
小结
三个功能,本质上是三条不同的链路:
- 定时消息 ------ 交给
reminderAgentManager,它由系统代理执行,不受进程存活影响。记得处理 30 条上限和 AGC 开放能力申请。 - 点击路由 ------ 分清
WantAgent实例(普通通知)和简化对象(代理提醒),冷热启动两条路径都写,跳转意图用AppStorage中转而不是在 Ability 里直接路由。 - 桌面角标 ------
badgeNumber累加、setBadgeNumber设定,二选一;角标不会自动清除,得自己写清除逻辑。
参考文档