第36篇|推送点开走错页:用消息路由收件箱承接冷启动
摘要:推送点击最容易被写成“收到什么就跳哪里”。实际项目里,冷启动、前台点击、登录拦截、重复送达会让页面走错、详情打不开、登录后回不到目标页。更稳的做法是把推送 payload 先落进消息路由收件箱,再由统一路由器解析、去重、鉴权和跳转。
我遇到过一次订单推送问题:用户点“待支付订单”通知,本该进入订单详情,结果冷启动时只到了首页;如果应用已经在前台,又会重复打开两次详情页。排查后发现有三条入口同时在处理同一个 payload:UIAbility 启动参数、通知点击回调、首页 aboutToAppear 的未读消息刷新。它们各自都觉得自己在做正确的跳转,最后栈里多了旧页面。

这篇文章解决四个实际问题:


先把三条入口合成一个现场
推送点击走错页时,不要只查通知 SDK。先把冷启动、前台点击和后台回前台三种入口列出来,看它们是否都在直接操作页面栈。
| 冷启动点击 | UIAbility 拿到 payload 后首页又刷新一次 | 目标页被首页跳转覆盖 | 先写 RouteInbox |
| 前台点击 | 通知回调直接 push 详情页 | 重复点击打开两个页面 | messageId 幂等 |
| 未登录点击 | 路由先跳详情再被登录页拦截 | 登录后丢失原目标 | 保存 pending route |
| 重复送达 | 服务端重发同一条消息 | 旧消息再次消费 | 消费状态落盘 |
这一步的价值是先把问题放回运行链路。只要能确认问题停在哪个位置,后面就不用靠猜测改页面。
路由 owner 不应该是通知回调
通知回调只负责把原始消息交出来,不应该知道订单详情页、活动页或聊天页怎么跳。真正拥有路由规则的是 MessageRouteResolver。
| NotificationReceiver | 原始 payload、点击时间 | 页面名称和栈操作 |
| RouteInbox | messageId、payload、consumeState | 业务页面展示 |
| MessageRouteResolver | routeKey 到目标页的映射 | UIAbility 生命周期 |
| AuthGate | 是否需要登录、登录后续跳 | 通知栏细节 |
| NavigationHost | NavPathStack push/replace | 消息重复判断 |
边界定清后,代码就不会在页面、服务和回调之间来回复制同一段判断。后续新增场景也能先判断应该落在哪一层。
用 RouteMessage 保留原始意图
export interface RouteMessage {
messageId: string
routeKey: string
payload: string
receivedAt: number
consumed: boolean
}
export interface RouteTarget {
name: string
paramJson: string
requireLogin: boolean
}
export function createRouteMessage(raw: PushPayload): RouteMessage {
return {
messageId: raw.messageId,
routeKey: raw.routeKey,
payload: raw.payload,
receivedAt: Date.now(),
consumed: false
}
}
这个模型把“收到消息”和“跳转目标”分开。payload 保留原始业务参数,consumed 用来挡住重复送达,RouteTarget 则是解析后的页面意图。
这类模型最好放在 models 或 common 中,页面、Service 和仓储都使用同一份类型,避免各层用字符串互相猜。
RouteInbox 负责去重和暂存
export class RouteInbox {
constructor(private readonly store: RouteMessageStore) {}
async accept(message: RouteMessage): Promise<void> {
const old = await this.store.findById(message.messageId)
if (old !== null && old.consumed) {
return
}
await this.store.upsert(message)
}
async takeNext(): Promise<RouteMessage | null> {
const message = await this.store.findFirstUnconsumed()
if (message === null) {
return null
}
await this.store.markConsumed(message.messageId)
return message
}
}
accept() 不直接跳页,只保证消息进入同一个队列。takeNext() 消费前先标记,避免页面栈跳转过程中再次处理同一条消息。
Service 的目标不是把所有逻辑都塞进去,而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。
UIAbility 只把 Want 转成消息
export class EntryAbility extends UIAbility {
private routeInbox: RouteInbox = createRouteInbox()
async onCreate(want: Want): Promise<void> {
const payload = readPushPayload(want)
if (payload !== null) {
await this.routeInbox.accept(createRouteMessage(payload))
}
}
async onNewWant(want: Want): Promise<void> {
const payload = readPushPayload(want)
if (payload !== null) {
await this.routeInbox.accept(createRouteMessage(payload))
AppStorage.setOrCreate<boolean>('route_inbox_dirty', true)
}
}
}
这里没有出现具体页面名。冷启动和新 Want 都只做同一件事:把推送意图交给收件箱,并通知 NavigationHost 稍后处理。
页面层要控制展示和交互节奏,但不应该拥有底层事实。这样页面重建、横竖屏变化或返回前台时,都能重新从 Service 拿到可信结果。
NavigationHost 在可跳转时消费收件箱
@Entry
@Component
struct NavigationHost {
@State routeInboxDirty: boolean = false
private stack: NavPathStack = new NavPathStack()
private routeInbox: RouteInbox = createRouteInbox()
private resolver: MessageRouteResolver = new MessageRouteResolver()
async onShown(): Promise<void> {
await this.consumeRouteInbox()
}
private async consumeRouteInbox(): Promise<void> {
const message = await this.routeInbox.takeNext()
if (message === null) {
return
}
const target = this.resolver.resolve(message)
if (target.requireLogin && !isSignedIn()) {
savePendingRoute(target)
this.stack.pushPathByName('LoginPage', undefined)
return
}
this.stack.pushPathByName(target.name, target.paramJson)
}
}
跳转发生在 NavigationHost 可见之后,而不是通知回调里。登录态不足时保存 pending route,避免用户登录后只停在首页。
实际项目里,很多问题不是第一次进入页面暴露,而是在冷启动、返回前台、页面重建或回调晚到时暴露。把这段生命周期补上,修复才算闭环。
兜底页比静默失败更重要
| routeKey 不认识 | 跳到 MessageFallbackPage | 展示消息摘要和刷新入口 |
| 业务对象已删除 | 进入空结果页 | 提示订单或内容已失效 |
| 未登录 | 保存 pending route | 登录成功后继续跳转 |
| 重复点击 | RouteInbox 忽略已消费消息 | 页面栈不增加重复详情 |
兜底路径不是可有可无。用户遇到异常时,页面至少要给出当前状态、可执行动作和可排查线索。
排查时先搜直接跳转点
rg –n "pushPath|pushPathByName|replacePath|pushUrl" entry features common
rg –n "messageId|routeKey|notification|Push|Want" entry features common
rg –n "pendingRoute|route_inbox_dirty|markConsumed" entry features common
第一组命令找旧路径,第二组命令找新边界,第三组命令看生命周期或回调位置。命中后不要只看字段名,要判断它是否仍在直接改页面状态。
验证清单
| 冷启动点击推送 | 先到目标详情页,不被首页刷新覆盖 |
| 前台连续点两次 | 只打开一个目标页 |
| 未登录点击 | 登录后继续进入原目标页 |
| 重复 messageId | 第二次不会再次跳转 |
| 未知 routeKey | 进入兜底页而不是白屏 |
这些验证最好在真机或模拟器里按顺序走一遍。只看源码很难发现冷启动、重启和回调晚到这类问题。
常见问题和处理方式
| 只到首页 | UIAbility 处理后首页又覆盖栈 | 把 payload 写入 RouteInbox,首页不直接跳 |
| 打开两个详情页 | 前台回调和 onNewWant 都跳了 | 统一消费入口并加 consumed |
| 登录后目标丢失 | 登录页没有 pending route | AuthGate 保存 RouteTarget |
| 未知消息白屏 | 解析失败没有兜底 | 增加 MessageFallbackPage |
如果现象没有出现在表里,仍然建议按“输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底”的顺序排查。
小结:推送点击先入箱,再跳转
推送路由的关键不是把每个通知都写一个跳转分支,而是把入口收拢。通知、Want、前台点击都先进 RouteInbox,Resolver 决定目标,AuthGate 处理登录,NavigationHost 在合适时机操作页面栈。这样冷启动和前台路径才会有同一个结果。
如果现象没有出现在表里,仍然建议按“输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底”的顺序排查。
小结:推送点击先入箱,再跳转
推送路由的关键不是把每个通知都写一个跳转分支,而是把入口收拢。通知、Want、前台点击都先进 RouteInbox,Resolver 决定目标,AuthGate 处理登录,NavigationHost 在合适时机操作页面栈。这样冷启动和前台路径才会有同一个结果。




