这篇我不想只讲“怎么把推送收下来”,而是把我自己做 Push 功能时真正绕不开的几件事讲透:Token 怎么管理、消息通道怎么设计、点击通知后怎么准确跳页、离线场景怎么兜住,以及为什么很多推送功能看起来能跑,上线后却总在链路细节上翻车。
一、推送功能真正难的,不是“能收到”,而是“能稳定闭环”
很多人第一次接 Push Kit,关注点通常只有一个:设备能不能收到消息。
真做到业务里,问题马上就变了。
一个活动通知发出去,用户是前台收到,还是后台弹系统通知?
一个订单状态通知过来,应该进入订单详情,还是先落到消息中心?
同一个应用里有系统公告、交易提醒、互动消息、运营消息,它们到底该不该走同一个消息通道?
用户关掉某类通知后,服务端要不要继续推?客户端怎么感知?
这些都不是“注册个 Token、调个回调”能解决的。
我后面把链路拆成四段:
- 设备身份建立:注册 Push Token,形成设备可达身份;
- 消息分类落槽:按业务类型映射 Notification Slot;
- 消息展示与点击:前台展示、后台通知、点击回调处理;
- 路由执行闭环:解析参数、打开目标页、记录跳转结果。
做完这四段,推送功能才算从“能用”走到“可交付”。
二、先把消息通道设计清楚,别一上来就堆接口
我实际做这类功能时,最不建议的写法,就是所有消息都塞进一个通知通道里。
短期看很省事,长期维护会越来越乱。因为消息类型不同,优先级、展示方式、可关闭策略、落地页行为都不一样。
我更倾向把消息分成四类:
- 系统通知:公告、版本提醒、安全提醒;
- 互动消息:评论、点赞、@我;
- 交易消息:订单、支付、物流;
- 活动消息:营销活动、福利领取、运营触达。
通道层只做“分类与承载”,不要把复杂业务判断塞进 Notification Slot。Slot 更适合解决“这条消息属于哪一类、该怎么展示、用户能否单独关闭”这类问题。
下面这段代码就是一个比较实用的通道初始化思路:
import notificationManager from '@kit.NotificationKit'; export class NotificationUtil { static async createMessageSlots() { const slots = [ { type: notificationManager.SlotType.SOCIAL_COMMUNICATION, name: '互动消息', description: '评论、点赞、@我的消息', level: notificationManager.SlotLevel.LEVEL_HIGH, }, { type: notificationManager.SlotType.CONTENT_INFORMATION, name: '系统通知', description: '系统公告与服务提醒', level: notificationManager.SlotLevel.LEVEL_DEFAULT, }, { type: notificationManager.SlotType.SERVICE_INFORMATION, name: '交易消息', description: '订单、支付、物流状态更新', level: notificationManager.SlotLevel.LEVEL_HIGH, } ]; for (const slot of slots) { await notificationManager.addSlot(slot); } } }这里最关键的不是 API 本身,而是把消息类别和展示语义提前定住。这样后面无论服务端发什么消息,客户端都能用统一规则承接。
三、Token 只是起点,真正要管的是“设备身份状态”
很多线上问题,不是消息没发,而是 Token 状态出了偏差。
常见情况有几个:
- 用户重装应用,旧 Token 失效;
- 用户清理数据后,客户端还在用老的注册状态;
- 服务端保存了多个历史 Token,没有做去重;
- 客户端拿到 Token 后没及时回传,导致消息发往空设备;
- 通知权限关闭了,但服务端还按“可触达”处理。
所以客户端拿到 Token 后,最好立刻做三件事:
- 本地保存最新 Token;
- 上报业务服务端,绑定用户与设备;
- 同步当前通知权限与通道开关状态。
我一般会把注册和点击监听放在入口页初始化阶段:
import pushService from '@kit.PushKit'; import { router } from '@kit.ArkUI'; async function initPush() { const token = await pushService.registerToken(); AppStorage.setOrCreate('pushToken', token); console.info(`token registered: ${token}`); pushService.onMessage((msg) => { console.info(`message received, id=${msg.messageId}`); }); pushService.onNotificationClick((msg) => { console.info(`notification clicked, route=${msg.routeUrl}`); if (msg.routeUrl) { router.pushUrl({ url: msg.routeUrl }); } }); }这段逻辑很朴素,但它解决了两个真实问题:
- Push Token 不再只是“拿到了就算完”,而是进入应用状态体系;
- 点击通知不再只是“知道点了”,而是直接进入路由执行阶段。
上面这类 DevEco 截图的价值,不在于“界面看起来像不像”,而是它把一条完整链路放在一个视角里:左边是工程目录,中间是 Push 初始化和点击回调代码,右边是模拟器中的消息中心,底部日志又把 Token 注册、消息接收、点击跳转串起来了。
四、消息中心页面怎么设计,决定了后续排查效率
我现在做推送类功能,会尽量给业务留一个“消息中心”或“推送调试页”。
不是为了好看,是为了排查快。
一个可用的消息中心,我会至少放这些信息:
- 通知总开关;
- 消息通道列表;
- Token 状态;
- 通知权限状态;
- 最近消息列表;
- 点击后跳转明细。
因为用户一句“我没收到消息”,背后可能是五种完全不同的问题:
- 通知权限没开;
- 某个 Slot 被关了;
- Token 未注册成功;
- 消息已经展示但被忽略;
- 点击后路由参数解析失败。
把这些状态可视化,很多问题根本不用进代码就能先排一半。
这张图里我比较喜欢的是红色标注做法。它不是为了“装饰”,而是把用户看不见但开发必须关注的几个关键点直接圈出来:
- 消息通道:到底是哪类消息在生效;
- Token 状态:设备是否处于可达状态;
- 通知权限:系统层是否允许投递。
这种标法很适合放文章里,因为它兼顾了读者阅读效率和排查思路。
五、点击通知之后,不要只跳页,要把“路由链路”跑完整
很多推送功能的坑,出在点击通知之后。
最常见的误区是:只要点开了某个页面,就算链路完成。
其实不够。
推送点击至少应该校验:
- 消息 ID 是否唯一可追踪;
- 路由参数是否完整;
- 目标页面是否匹配;
- 页面是否成功打开;
- 是否有失败兜底。
我自己的习惯是,消息体里放三层信息:
- 业务类型:如 order / activity / system;
- 目标页面:如
/pages/order/detail; - 路由参数:如订单 ID、来源标记、utm 等。
收到点击回调后,先落日志,再统一交给路由解析器处理。这样消息来源一多,也不会把点击逻辑写散。
这张图很适合拿来讲“点击闭环”。红色圈和箭头标出的几个字段,基本就是线上排查最有价值的几个观察点:
- 消息 ID:唯一标识一条消息;
- 路由参数:点击之后到底要带什么信息走;
- 点击后跳转结果:最终有没有真正落到目标页面。
如果文章只讲“点击跳详情页”,其实很浅。把这几个观察位讲清楚,读者才能真正把推送能力落到业务里。
六、我最后总结的 Push 实战经验
这套链路做下来,我越来越确信一件事:Push 功能最有技术含量的地方,不是调用哪个接口,而是把消息触达、展示控制和点击跳转串成一条可验证的业务链。
我现在会默认做这些约束:
- Token 注册后必须立即回传服务端;
- 消息必须带类型、目标页和参数;
- 不同消息走不同 Notification Slot;
- 客户端必须能查看权限、通道、Token 状态;
- 点击通知后必须记录最终路由结果;
- 异常场景要有统一兜底页或消息中心回退。
这样做的好处很直接:
平时看起来只是多写了一点结构代码,到了线上排查阶段,会比“哪里坏了再补哪里”轻松很多。
如果你现在也在做 HarmonyOS 推送能力,我的建议不是“先把消息发出去”,而是先把链路设计完整,再让消息跑起来。一旦链路对了,后面的扩展,比如多业务线推送、AB 实验、活动消息降级、通知聚合,才有稳定基础。