第36篇推送点开走错页用消息路由收件箱承接冷启动摘要推送点击最容易被写成“收到什么就跳哪里”。实际项目里冷启动、前台点击、登录拦截、重复送达会让页面走错、详情打不开、登录后回不到目标页。更稳的做法是把推送 payload 先落进消息路由收件箱再由统一路由器解析、去重、鉴权和跳转。我遇到过一次订单推送问题用户点“待支付订单”通知本该进入订单详情结果冷启动时只到了首页如果应用已经在前台又会重复打开两次详情页。排查后发现有三条入口同时在处理同一个 payloadUIAbility 启动参数、通知点击回调、首页 aboutToAppear 的未读消息刷新。它们各自都觉得自己在做正确的跳转最后栈里多了旧页面。这篇文章解决四个实际问题把推送 payload 从页面跳转里拆出来先进入收件箱。用 messageId 和 routeKey 做幂等挡住重复点击和重复送达。登录态不足时保存 pending route登录后继续原目标。用启动、前台、后台三条路径验证同一个路由结果。先把三条入口合成一个现场推送点击走错页时不要只查通知 SDK。先把冷启动、前台点击和后台回前台三种入口列出来看它们是否都在直接操作页面栈。位置现象风险处理方向冷启动点击UIAbility 拿到 payload 后首页又刷新一次目标页被首页跳转覆盖先写 RouteInbox前台点击通知回调直接 push 详情页重复点击打开两个页面messageId 幂等未登录点击路由先跳详情再被登录页拦截登录后丢失原目标保存 pending route重复送达服务端重发同一条消息旧消息再次消费消费状态落盘这一步的价值是先把问题放回运行链路。只要能确认问题停在哪个位置后面就不用靠猜测改页面。路由 owner 不应该是通知回调通知回调只负责把原始消息交出来不应该知道订单详情页、活动页或聊天页怎么跳。真正拥有路由规则的是 MessageRouteResolver。模块应该负责不应该负责NotificationReceiver原始 payload、点击时间页面名称和栈操作RouteInboxmessageId、payload、consumeState业务页面展示MessageRouteResolverrouteKey 到目标页的映射UIAbility 生命周期AuthGate是否需要登录、登录后续跳通知栏细节NavigationHostNavPathStack push/replace消息重复判断边界定清后代码就不会在页面、服务和回调之间来回复制同一段判断。后续新增场景也能先判断应该落在哪一层。用 RouteMessage 保留原始意图exportinterfaceRouteMessage{messageId:stringrouteKey:stringpayload:stringreceivedAt:numberconsumed:boolean}exportinterfaceRouteTarget{name:stringparamJson:stringrequireLogin:boolean}exportfunctioncreateRouteMessage(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 负责去重和暂存exportclassRouteInbox{constructor(privatereadonlystore:RouteMessageStore){}asyncaccept(message:RouteMessage):Promisevoid{constoldawaitthis.store.findById(message.messageId)if(old!nullold.consumed){return}awaitthis.store.upsert(message)}asynctakeNext():PromiseRouteMessage|null{constmessageawaitthis.store.findFirstUnconsumed()if(messagenull){returnnull}awaitthis.store.markConsumed(message.messageId)returnmessage}}accept()不直接跳页只保证消息进入同一个队列。takeNext()消费前先标记避免页面栈跳转过程中再次处理同一条消息。Service 的目标不是把所有逻辑都塞进去而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。UIAbility 只把 Want 转成消息exportclassEntryAbilityextendsUIAbility{privaterouteInbox:RouteInboxcreateRouteInbox()asynconCreate(want:Want):Promisevoid{constpayloadreadPushPayload(want)if(payload!null){awaitthis.routeInbox.accept(createRouteMessage(payload))}}asynconNewWant(want:Want):Promisevoid{constpayloadreadPushPayload(want)if(payload!null){awaitthis.routeInbox.accept(createRouteMessage(payload))AppStorage.setOrCreateboolean(route_inbox_dirty,true)}}}这里没有出现具体页面名。冷启动和新 Want 都只做同一件事把推送意图交给收件箱并通知 NavigationHost 稍后处理。页面层要控制展示和交互节奏但不应该拥有底层事实。这样页面重建、横竖屏变化或返回前台时都能重新从 Service 拿到可信结果。NavigationHost 在可跳转时消费收件箱EntryComponentstruct NavigationHost{StaterouteInboxDirty:booleanfalseprivatestack:NavPathStacknewNavPathStack()privaterouteInbox:RouteInboxcreateRouteInbox()privateresolver:MessageRouteResolvernewMessageRouteResolver()asynconShown():Promisevoid{awaitthis.consumeRouteInbox()}privateasyncconsumeRouteInbox():Promisevoid{constmessageawaitthis.routeInbox.takeNext()if(messagenull){return}consttargetthis.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-npushPath|pushPathByName|replacePath|pushUrlentry features common rg-nmessageId|routeKey|notification|Push|Wantentry features common rg-npendingRoute|route_inbox_dirty|markConsumedentry features common第一组命令找旧路径第二组命令找新边界第三组命令看生命周期或回调位置。命中后不要只看字段名要判断它是否仍在直接改页面状态。验证清单验证项预期结果冷启动点击推送先到目标详情页不被首页刷新覆盖前台连续点两次只打开一个目标页未登录点击登录后继续进入原目标页重复 messageId第二次不会再次跳转未知 routeKey进入兜底页而不是白屏这些验证最好在真机或模拟器里按顺序走一遍。只看源码很难发现冷启动、重启和回调晚到这类问题。常见问题和处理方式现象常见原因处理方式只到首页UIAbility 处理后首页又覆盖栈把 payload 写入 RouteInbox首页不直接跳打开两个详情页前台回调和 onNewWant 都跳了统一消费入口并加 consumed登录后目标丢失登录页没有 pending routeAuthGate 保存 RouteTarget未知消息白屏解析失败没有兜底增加 MessageFallbackPage如果现象没有出现在表里仍然建议按“输入来源 - 中间状态 - 持久化或页面展示 - 失败兜底”的顺序排查。小结推送点击先入箱再跳转推送路由的关键不是把每个通知都写一个跳转分支而是把入口收拢。通知、Want、前台点击都先进 RouteInboxResolver 决定目标AuthGate 处理登录NavigationHost 在合适时机操作页面栈。这样冷启动和前台路径才会有同一个结果。如果现象没有出现在表里仍然建议按“输入来源 - 中间状态 - 持久化或页面展示 - 失败兜底”的顺序排查。小结推送点击先入箱再跳转推送路由的关键不是把每个通知都写一个跳转分支而是把入口收拢。通知、Want、前台点击都先进 RouteInboxResolver 决定目标AuthGate 处理登录NavigationHost 在合适时机操作页面栈。这样冷启动和前台路径才会有同一个结果。