41-深链拉起只到首页-用Want解析和兜底页接住入口
第41篇深链拉起只到首页把 Want 解析、冷启动暂存和兜底页接成闭环摘要HarmonyOS 应用被短信、浏览器、分享卡片或推送拉起后只停在首页通常不是某个按钮跳转写错而是外部 Want、应用启动、Navigation 就绪、登录拦截和目标页注册没有形成同一条入口链路。更稳的做法是先把外部入口解析成明确的业务意图再由入口路由器决定跳转、暂存、鉴权和兜底。实际项目里最容易误判的一类问题是测试同学点一条活动短信应用确实被拉起来了但页面只显示首页再点一次同样的链接有时又能进活动页。开发同学第一反应是在首页aboutToAppear里补pushPathByName结果冷启动、登录态失效、后台再次拉起各有各的失败方式。最后排查才发现Want 里拿到的只是原始 URI目标页还没注册到路由表Navigation 宿主也未完成创建登录页又把原本要去的目标覆盖掉了。这篇文章只解决一个工程问题外部深链拉起应用后如何让入口从 Want 解析到目标页跳转都有可验证的归属。读完可以拿走四个落点UIAbility只接收和转交 Want不把业务跳转写进生命周期回调。DeepLinkParser把 URI 转成 typed intent解析失败也返回明确的Unknown。PendingDeepLinkStore处理冷启动和前台二次拉起等 Navigation ready 后只消费一次。EntryRouteGate统一做路由表校验、登录 redirect 和兜底页不让错误链接静默回首页。先把“只到首页”拆成四个断点首页是最后可见结果不一定是失败发生的位置。排查深链时先把链路按时间顺序拆开系统把 Want 交给应用、Ability 提取 URI、业务层解析入口、Navigation 宿主执行跳转。任何一个环节提前返回用户看到的都会像是“只打开了首页”。断点常见现象真正风险应该留下的证据Want 参数uri为空或 schema 不匹配解析失败后被当成普通启动记录原始 URI 和来源冷启动时机pushPathByName调了但没效果Navigation 还没有绑定NavPathStack暂存 intent记录消费时机登录拦截先到登录页登录后回首页redirect 丢失保存RouteTarget而不是保存字符串路由注册目标页名字拼错或未进router_map.json跳转失败无用户提示统一校验并进入兜底页这张表的作用不是增加流程而是避免把所有问题都塞回首页。深链入口属于应用级输入首页只是一个可能的展示面如果首页承担解析、鉴权、跳转和失败提示后续任何页面改版都会影响外部入口。module.json5 先声明可以被外部拉起深链入口要先有系统侧配置。如果项目走 App Linking配置里要围绕https域名和 path 规则声明入口如果项目还保留自定义 scheme也应该单独按项目能力核验不要和 App Linking 的域名校验混在一段逻辑里。工程上至少要确认目标 Ability 对外部 Want 是可达的并且配置变化能在测试包里真实生效。{ module: { abilities: [ { name: EntryAbility, srcEntry: ./ets/entryability/EntryAbility.ets, exported: true, skills: [ { actions: [ ohos.want.action.viewData ], uris: [ { scheme: https, host: example.com, pathStartWith: app } ] } ], routerMap: $profile:router_map } ] } }这段配置只说明应用愿意接收哪些入口不代表业务已经能跳到目标页。exported、skills和uris解决的是系统分发问题routerMap解决的是 Navigation 页面注册问题。两者都要检查否则会出现“应用能启动但目标页进不去”的半成功状态。实际项目里建议把可外部打开的 path 写进一份入口清单产品、服务端短链、App 端解析器和测试用例都对着同一份清单核。否则服务端新增一个分享链接App 端没有同步注册用户端看到的就是首页。UIAbility 只接收 Want不直接跳页面UIAbility的职责是拿到系统交来的 Want并把它交给入口协调器。这里不要直接依赖某个页面的NavPathStack因为冷启动时页面树还没创建后台二次拉起时也可能要合并或覆盖已有 pending intent。importUIAbilityfromohos.app.ability.UIAbilityimporttypeWantfromohos.app.ability.Wantimportwindowfromohos.windowexportdefaultclassEntryAbilityextendsUIAbility{onCreate(want:Want):void{DeepLinkCoordinator.fromWant(want,abilityCreate)}onNewWant(want:Want):void{DeepLinkCoordinator.fromWant(want,newWant)}onWindowStageCreate(windowStage:window.WindowStage):void{windowStage.loadContent(pages/Index,(){DeepLinkCoordinator.markShellLoaded()})}}这里的关键点有三个。点位为什么这样写onCreate处理冷启动第一次交进来的 WantonNewWant处理应用已存在时的新入口不要求用户杀进程复现markShellLoaded只表示宿主页面加载完成不等于目标页已经跳转成功如果在onCreate里直接调页面栈代码会依赖“页面已经存在”这个不稳定前提。把 Want 转交给协调器后Ability 生命周期和页面导航生命周期就分开了后续调试也能分别看日志。不要让页面直接读原始 URI原始 URI 只能作为输入不能一路传到页面里再拆。页面直接读字符串会导致三个问题每个页面都复制解析逻辑错误链接没有统一结果后续 schema 调整时很难知道哪些页面受影响。exportenumDeepLinkKind{Couponcoupon,Orderorder,Articlearticle,Unknownunknown}exportinterfaceDeepLinkIntent{kind:DeepLinkKind targetId:stringsource:stringrawUri:stringreason:stringreceivedAt:number}exportfunctionunknownDeepLink(rawUri:string,source:string,reason:string):DeepLinkIntent{return{kind:DeepLinkKind.Unknown,targetId:,source,rawUri,reason,receivedAt:Date.now()}}Unknown不是异常分支的偷懒写法而是一个必须被页面承接的业务结果。链接为空、path 不支持、参数缺失、版本不兼容都应该进入兜底页告诉用户当前链接为什么不可用同时给排查人员留下原始入口。模型里保留source和receivedAt也有现实价值。活动短信、浏览器通用链接、推送透传字段可能最终都落到同一个目标页但来源不同排查时需要知道是哪条渠道生成了错误入口。解析器只认入口规则不碰 Navigation解析器不要知道登录状态也不要知道NavPathStack。它只负责把外部输入规整成应用内部看得懂的 intent。这样解析规则能单独写测试业务路由也不会被 URI 细节污染。exportclassDeepLinkParser{parse(rawUri:string,source:string):DeepLinkIntent{if(rawUri.trim().length0){returnunknownDeepLink(rawUri,source,链接为空)}constnormalizeddecodeURIComponent(rawUri)constcouponIdthis.matchLastSegment(normalized,/coupon/)if(couponId.length0){returnthis.create(DeepLinkKind.Coupon,couponId,source,rawUri)}constorderIdthis.matchLastSegment(normalized,/order/)if(orderId.length0){returnthis.create(DeepLinkKind.Order,orderId,source,rawUri)}constarticleIdthis.matchLastSegment(normalized,/article/)if(articleId.length0){returnthis.create(DeepLinkKind.Article,articleId,source,rawUri)}returnunknownDeepLink(rawUri,source,暂不支持该入口)}privatecreate(kind:DeepLinkKind,targetId:string,source:string,rawUri:string):DeepLinkIntent{return{kind,targetId,source,rawUri,reason:,receivedAt:Date.now()}}privatematchLastSegment(uri:string,marker:string):string{constindexuri.indexOf(marker)if(index0){return}returnuri.substring(indexmarker.length).split(/[?#]/)[0]}}这段代码不追求覆盖所有 URL 解析细节重点是把“可识别”和“不可识别”都变成显式结果。真实项目可以把 path 规则换成更严格的 URL 工具类或白名单表但不要回到页面里includes(/coupon/)的写法。解析器输出以后下一层才判断这个 intent 能不能打开、要不要登录、目标页是否存在。分层之后线上出现错误链接时可以先看解析日志如果kind已经是Unknown问题在入口规则如果kind正确但没有跳转问题在路由链路。冷启动入口要暂存不能抢跑冷启动时 Want 比页面栈更早到。解决办法不是延迟几百毫秒而是给外部入口一个明确的 pending storeAbility 收到就保存Navigation 宿主 ready 后再消费消费后清空。exportclassPendingDeepLinkStore{privatependingIntent:DeepLinkIntent|nullnullprivateshellReady:booleanfalsesave(intent:DeepLinkIntent):void{this.pendingIntentintent}markShellReady():void{this.shellReadytrue}takeWhenReady():DeepLinkIntent|null{if(!this.shellReady||this.pendingIntentnull){returnnull}constintentthis.pendingIntentthis.pendingIntentnullreturnintent}}这里用“只保留最后一个入口”的策略适合活动链接、订单详情、消息详情这类单目标入口。如果业务要求连续处理多个入口可以把pendingIntent换成队列但不要在没有需求时引入队列复杂度。更重要的是pending store 要有日志保存时记录source/rawUri/kind消费时记录目标页和结果。否则冷启动失败只能靠肉眼观察页面无法判断到底是未消费、消费过早还是跳转失败。Navigation 宿主 ready 后再消费在Navigation宿主页面里持有唯一的NavPathStack等组件出现后把栈注册给入口协调器。这个时刻才适合把 pending intent 转成页面跳转。EntryComponentstruct Index{privatepageStack:NavPathStacknewNavPathStack()aboutToAppear():void{DeepLinkCoordinator.attachStack(this.pageStack)DeepLinkCoordinator.consumeIfPossible()}build(){Navigation(this.pageStack){HomePage()}.hideTitleBar(true)}}如果项目已经有全局导航服务也可以在宿主页面初始化时注册 stack但要遵守一个原则一个Navigation对应一个NavPathStack不要在多个宿主间复用同一个栈。深链失败时复用栈会让问题变得更难定位因为日志看起来像跳转成功实际页面树可能不是当前展示的那棵。这一步还要避免把消费逻辑放进每个业务页。业务页不应该关心自己是不是被外部拉起它只接收路由参数并展示业务状态。入口消费属于宿主或服务层页面只处理自己的param。EntryRouteGate 统一决定目标、登录和兜底解析结果不能直接pushPathByName。要先经过入口路由器把目标页名称、参数、登录要求和兜底策略收成一个RouteTarget。这样后续新增订单、文章、优惠券入口时不会散落在多个页面生命周期里。exportinterfaceRouteTarget{name:stringparam:Recordstring,string|number|booleanrequireLogin:boolean}exportclassEntryRouteGate{resolve(intent:DeepLinkIntent):RouteTarget{if(intent.kindDeepLinkKind.Unknown){returnthis.fallback(intent)}if(intent.kindDeepLinkKind.Coupon){return{name:CouponReceivePage,param:{couponId:intent.targetId},requireLogin:true}}if(intent.kindDeepLinkKind.Order){return{name:OrderDetailPage,param:{orderId:intent.targetId},requireLogin:true}}if(intent.kindDeepLinkKind.Article){return{name:ArticleDetailPage,param:{articleId:intent.targetId},requireLogin:false}}returnthis.fallback(intent)}privatefallback(intent:DeepLinkIntent):RouteTarget{return{name:DeepLinkFallbackPage,param:{rawUri:intent.rawUri,reason:intent.reason,source:intent.source},requireLogin:false}}}RouteTarget比直接传字符串稳因为它把页面名、参数和登录要求绑在一起。登录前保存的是完整目标而不是只保存一个redirectUrl。登录后继续跳转时也就不会出现“登录成功但回首页”的问题。如果项目使用router_map.json注册 Navigation 页面可以在 gate 中维护一份允许外部打开的 route 白名单。白名单校验失败时进入DeepLinkFallbackPage同时把缺失的 routeName 写入日志避免线上用户静默失败。登录拦截要保存目标不保存页面状态深链入口经常撞上登录态。错误做法是先跳登录页登录成功后让首页自己判断要不要继续跳。首页一刷新redirect 就丢了。更稳的做法是保存RouteTarget登录完成后由同一个入口协调器继续消费。exportclassLoginRedirectStore{privateredirectTarget:RouteTarget|nullnullsave(target:RouteTarget):void{this.redirectTargettarget}take():RouteTarget|null{consttargetthis.redirectTargetthis.redirectTargetnullreturntarget}}exportclassDeepLinkNavigator{constructor(privatereadonlyauth:AuthSession,privatereadonlyredirectStore:LoginRedirectStore){}navigate(stack:NavPathStack,target:RouteTarget):void{if(target.requireLogin!this.auth.isLoggedIn()){this.redirectStore.save(target)stack.pushPathByName(LoginPage,{from:deepLink})return}stack.pushPathByName(target.name,target.param)}}这段逻辑的边界很清楚登录拦截只关心“当前能不能打开目标”。它不解析 URI不知道短信来源也不在登录页里拼业务参数。登录页只需要在登录成功后调用take()如果有目标就继续跳没有就走普通登录后的首页路径。实际测试时要覆盖“应用未启动 未登录 点击订单链接”这个场景。很多深链问题在已登录状态下看不出来只有登录页插入链路后才暴露 redirect 丢失。兜底页不是错误页而是外部入口收口页未知链接、参数缺失、活动结束、目标页未注册都不应该静默回首页。首页看起来正常但用户不知道发生了什么运营和客服也无法定位链接是否有效。兜底页至少要给出原因、返回入口和可追踪信息。BuilderexportfunctionDeepLinkFallbackPageBuilder(name:string,param:object){DeepLinkFallbackPage({param:paramasRecordstring,string})}Componentstruct DeepLinkFallbackPage{Propparam:Recordstring,stringbuild(){NavDestination(){Column({space:12}){Text(链接暂时无法打开).fontSize(22).fontWeight(FontWeight.Bold)Text(this.param.reason??入口信息不完整).fontSize(15)Button(返回首页).onClick(()DeepLinkCoordinator.backHome())}.width(100%).padding(24)}.title(入口异常)}}兜底页展示给用户的是简短原因日志里保存的是完整rawUri/source/routeName。不要把长链接、token 或隐私参数原样展示在页面上。用户只需要知道当前链接不可用以及下一步能做什么开发和运营需要的是可检索的 trace。兜底页还有一个额外作用它能让测试用例变得可判定。过去“只到首页”很难判断是设计如此还是跳转失败现在错误链接应该稳定进入兜底页不允许悄悄回首页。router_map.json 要把外部目标页纳入发布自查使用Navigation体系时外部目标页要按系统路由表或项目自定义路由表注册。尤其是 HAP/HSP/HAR 拆包后页面源文件移动了深链 gate 里的页面名如果没有跟着更新就会出现开发环境能打开、发布包打不开的情况。{routerMap:[{name:CouponReceivePage,pageSourceFile:src/main/ets/pages/coupon/CouponReceivePage.ets,buildFunction:CouponReceivePageBuilder,data:{allowDeepLink:true,requireLogin:true}},{name:DeepLinkFallbackPage,pageSourceFile:src/main/ets/pages/common/DeepLinkFallbackPage.ets,buildFunction:DeepLinkFallbackPageBuilder,data:{allowDeepLink:true,requireLogin:false}}]}建议把allowDeepLink写成路由元信息或单独清单然后在发布前做一次对账EntryRouteGate能输出的每个name都必须存在于路由表所有允许外部打开的页面都要有参数校验和兜底策略。这类自查比人工点几条链接更稳。人工测试容易覆盖常用入口但遗漏活动页下线、HSP 拆分、页面重命名这类版本演进问题。排查命令要围绕入口链路搜索深链问题不要只搜一个pushPathByName。要按“系统入口、解析、暂存、路由、登录、兜底”六个位置找散点。散点越多越说明入口链路还没有收口。rg-nskills|uris|viewData|routerMapentry/src/main/module.json5 entry/src/main/resources rg-nonCreate\\(|onNewWant\\(|Want|uri|parametersentry/src/main/ets rg-nDeepLink|PendingDeepLink|EntryRouteGate|RouteTarget|redirectentry features common rg-npushPathByName|pushPath\\(|LoginPage|FallbackPageentry features common命中后按下面的标准判断搜索结果需要确认Ability 里直接 push是否依赖未创建的NavPathStack页面里解析 URI是否应该迁到DeepLinkParser多处保存 redirect是否会互相覆盖没有 FallbackPage错误入口是否静默回首页routeName 字符串散落是否和router_map.json脱节如果项目还在旧ohos.router和Navigation混用阶段更要先确认当前入口最终进入哪套导航体系。不要从Navigation子页面再跳回外层 router否则外部入口可能把整个导航宿主重新包一层返回路径也会变得不可预测。验证清单要覆盖启动态和登录态组合深链不是只测“已登录点链接”。至少要覆盖冷启动、后台二次拉起、登录拦截、错误链接和目标页缺注册。每一项都要有可观察结果不要只写“页面正常”。验证项操作方式预期结果冷启动优惠券链接杀进程后点短信或模拟 URI先启动应用再进入CouponReceivePage后台二次拉起应用停在首页时再点订单链接触发onNewWant进入订单详情未登录订单链接清登录态后点订单链接先到登录页登录后继续订单详情不支持的 path构造未知 path进入DeepLinkFallbackPage不回首页参数缺失只传/coupon/不传 id兜底页提示入口不完整目标页未注册临时移除路由表项测试有失败日志和兜底页连续点两个链接很短时间内点不同入口按既定策略只消费最后一个或按队列消费验证时最好同时看日志收到 Want、解析结果、pending 保存、Navigation ready、目标决策、实际 push、登录 redirect、兜底原因。只看页面跳转很容易漏掉“跳到了但参数错了”或“登录后目标被覆盖”。常见问题和处理方式现象常见原因修复方向每次只到首页Want 被当成普通启动处理UIAbility转交给DeepLinkCoordinator冷启动偶发失败Navigation 未 ready 就跳转pending store 暂存宿主 ready 后消费登录后回首页redirect 只保存了字符串或没保存保存完整RouteTarget错误链接无提示解析失败返回nullUnknownintent 进入兜底页活动页打不开routeName 与router_map.json不一致gate 输出和路由表做发布前对账后台点新链接没反应只处理了onCreate补onNewWant并记录来源连续打开重复跳pending 没有清空takeWhenReady()消费后清空如果一轮修复后仍然只到首页先不要改 UI。按“Want 是否收到 - intent 是否正确 - pending 是否消费 - target 是否存在 - 登录是否拦截 - push 是否成功”的顺序查通常能定位到具体断点。小结深链入口要有自己的工程边界深链拉起不是普通页面点击。它来自应用外部参数不一定可信启动时机不一定稳定登录态也不一定满足。把 Want 接收、URI 解析、冷启动暂存、Navigation ready、入口路由、登录 redirect 和兜底页串成一条链路后用户不再被错误地带回首页开发也能知道问题停在哪个断点。真正可维护的做法不是在首页补更多判断而是让每个层次只做自己的事Ability 接入口Parser 产出 intentStore 处理时机Gate 决定目标Navigator 执行跳转FallbackPage 承接失败。这样新增一个外部入口时改动范围清楚验证路径也清楚。