☰
HarmonyOS 7 ArkUI Navigation + Want:平行视界冷启动深链的双栏落位与页面栈去重【鸿蒙心迹】
2026/10/3 20:14:12 网站建设 项目流程

这次问题只在一种入口出现:应用完全退出后,从通知里的商品链接冷启动。展开态页面能打开sku_4072,但详情偶尔落到左栏;再点一次通知,右栏会叠出两张相同详情。普通列表点击一直正常,所以最初的页面自测没有抓到它。

我把复现链路收进SplitLink Lab。本次跟踪号是link_20261001_04,输入 URI 为app://catalog/item/sku_4072?source=push。10:24 的窗口宽度为 840 vp,布局模式SPLIT,主栏栈深度 1,详情栏栈深度 2,重复页面 0,深链稳定落位耗时 31 ms,最终状态RESTORED。

这组数字不是为了把页面做成监控面板,而是帮助我区分三个时机:Want 已经到达、双栏容器已经可用、目标详情已经进入正确的栈。之前的实现把这三个动作写在UIAbility.onCreate()里,看起来一步到位,实际上每一步都可能早于下一步。

一、日志里出现了两次 sku_4072

第一条可疑日志是want accepted,紧接着出现detail pushed;几十毫秒后窗口完成布局,又出现一次detail restored。冷启动时,通知 Want 负责打开详情,页面恢复逻辑也认为自己需要补回上次详情,于是同一个商品被推入两次。

折叠态更隐蔽。单栏中列表与详情共享一条视觉路径,重复页只是返回时多按一次;展开到 840 vp 后,主栏和详情栏同时可见,错误才表现为详情跑到左边或右栏叠页。它不是一个“平行视界样式”问题,而是外部入口、窗口状态和 Navigation 栈三套状态没有共同的提交点。

我最后给流程定了一个原则:Want 只产生路由意图,不直接操作页面;窗口只报告当前可用布局,不猜目标页面;栈协调器在两者都准备好以后做一次幂等落位。这样onCreate、onNewWant和恢复回调即使顺序变化,也不会各自 push。

为了确认判断,我没有先改 UI,而是在三个边界分别打点。Ability 收到 Want 时只打印 trace,Navigation 完成栈绑定时打印stackReady,窗口回调只打印 width 与 layoutMode。复现一次就能看到顺序并不稳定:有时 840 vp 先到,有时栈先就绪,有时恢复快照插在两者中间。以前的日志都叫openDetail,看不出究竟是谁打开了页面;拆开以后,重复 push 的来源才变得明确。

我也删掉了页面组件里“发现 param 就自动跳详情”的副作用。组件构建可能因为状态刷新执行多次,把导航写在构建链附近,相当于让渲染次数决定业务动作。现在组件只渲染协调器提交后的状态,导航动作有唯一入口。这个改动看似保守,却直接消除了热重载、旋转和展开时的偶发重入。

二、先把 Want 解析成不可变路由意图

下面这段代码解决的是“URI 字符串在 Ability、页面和组件之间反复拆解,参数规则逐渐不一致”。解析器只接受应用自己的 scheme、合法资源类型和 SKU 格式,并生成稳定的路由键。

exportinterfaceRouteIntent{traceId:stringtarget:'CatalogDetail'sku:stringsource:stringrouteKey:string}exportfunctionparseCatalogWant(want:Want):RouteIntent|undefined{consturi=want.uri??''constmatch=/^app:\/\/catalog\/item\/(sku_[0-9]+)(?:\?source=([a-z_]+))?$/.exec(uri)if(!match)returnundefinedconsttraceId=String(want.parameters?.['traceId']??'')if(!traceId)returnundefinedconstsource=match[2]??'unknown'return{traceId,target:'CatalogDetail',sku:match[1],source,routeKey:`CatalogDetail:${match[1]}`}}

当前 Demo 解析后得到traceId=link_20261001_04、sku=sku_4072、source=push、routeKey=CatalogDetail:sku_4072。routeKey与通知投递次数无关,用来判断栈里是否已经存在同一业务页;traceId则用于判断同一条外部事件是否被重复交付,两者不能混成一个字段。

这里主动拒绝缺少跟踪号或格式不合法的 URI。正式项目可以把无法识别的链接降级到目录首页,但不要把原始 URI 直接拼成页面名。解析方法不持有 UIContext,也没有生命周期资源,因此可以单测;如果参数包含用户数据,还应限制长度并避免把完整值写进生产日志。

三、冷启动和热启动要走同一个入口

这段代码解决的是“onCreate能处理第一次 Want,应用在前台收到onNewWant时却走了另一套逻辑”。门禁只缓存最新意图,并用 traceId 拦截系统或业务侧的重复投递。

exportclassDeepLinkGate{privateseen=newSet<string>()privatepending?:RouteIntentaccept(want:Want):boolean{constintent=parseCatalogWant(want)if(!intent||this.seen.has(intent.traceId))returnfalsethis.seen.add(intent.traceId)this.pending=intentreturntrue}takeWhenReady():RouteIntent|undefined{constintent=this.pendingthis.pending=undefinedreturnintent}endSession():void{this.pending=undefinedthis.seen.clear()}}

accept()执行时页面可能还没构建,所以它不调用pushPath。takeWhenReady()只在 Navigation 容器完成绑定、窗口宽度已经确定后执行一次;取出后立即清空 pending,避免重组布局时再次消费。同一个link_20261001_04在冷启动恢复和通知重投中只会通过一次。

seen的生命周期与本次 UIAbility 会话一致,不适合无限增长。endSession()在 Ability 真正销毁时清理,而不是页面每次不可见时清理;否则从详情临时切到系统页再回来,同一通知可能重新入栈。跨进程的业务防重应该由服务端或持久化层承担,这里解决的只是一次应用会话内的页面重入。

门禁还需要处理“新意图覆盖旧意图”。如果页面尚未就绪时连续收到两个不同商品链接,产品规则是最后一次用户动作优先,pending 会更新为新的 RouteIntent;已经提交的旧请求则通过 generation 失效。这里不能简单排队逐个打开,否则应用刚启动就连续闪过多张详情,也不能把不同 trace 全部判成重复,因为用户确实可能在通知中心改点了另一件商品。

四、等 840 vp 的容器就绪,再决定落到哪一栏

平行视界不是“宽度大于某个值就多画一栏”这么简单。深链到达时,窗口信息可能还没稳定,Navigation 也可能尚未拿到真实NavPathStack。我把布局状态定义为WAITING → SINGLE / SPLIT → RESTORED,只有进入SINGLE或SPLIT后才允许消费意图。

下面这段代码解决的是“同一详情在单栏、双栏和重复深链下都能落到正确位置”。栈适配器先按routeKey查找,已存在时移动到顶层并更新参数,不存在时才新增页面。

exportclassPaneCoordinator{constructor(privatereadonlystack:NavPathStack){}open(intent:RouteIntent,widthVp:number):'RESTORED'|'WAITING'{if(widthVp<=0)return'WAITING'constparam={sku:intent.sku,source:intent.source,routeKey:intent.routeKey}constindex=this.findByRouteKey(intent.routeKey)if(index>=0){this.stack.moveToTop(index,false)this.stack.setParamByIndex(index,param)}else{this.stack.pushPath({name:'CatalogDetail',param},{animated:false,launchMode:LaunchMode.MOVE_TO_TOP_SINGLETON})}AppStorage.setOrCreate('layoutMode',widthVp>=720?'SPLIT':'SINGLE')return'RESTORED'}privatefindByRouteKey(routeKey:string):number{returnthis.stack.getAllPathName().findIndex((_name,index)=>{constparam=this.stack.getParamByIndex(index)asRecord<string,string>|undefinedreturnparam?.['routeKey']===routeKey})}}

840 vp 下记录的是SPLIT,目标CatalogDetail由 Navigation 的详情区域承载;折回窄窗后,同一栈仍保留详情,只改变呈现方式。这里没有在窗口变化时清空并重建页面,因此滚动位置、输入状态和详情数据不会因为展开动作全部丢失。

animated:false只用于冷启动恢复,避免用户先看到目录页再闪进详情。用户主动点击列表仍保留正常转场。不同 API 版本的栈查询和移动接口可能有差异,正式工程应把这些操作收口在适配器中;不要让每个页面自己遍历栈,也不要在NavDestination.onReady里反复补路由。

五、恢复快照只提供候选,外部深链优先

项目原来把“上次浏览的详情”当成必须恢复的状态。这个判断在普通启动成立,但从通知进入时,外部意图应该覆盖旧快照。现在协调顺序是:先确认是否存在有效 pending Want;有则以它为目标,并把旧快照仅用于补主栏筛选条件;没有外部入口时,才恢复上次详情。

快照保存的是业务键,不保存整个组件树。主栏记录目录筛选,详情栏记录routeKey和必要参数。应用版本升级后,如果页面名或参数版本不兼容,恢复器会丢弃详情并回到目录页。这样做比反序列化整条历史栈更克制,也不会把已经下架的商品永远留在本地。

页面进入后台时只落盘已经稳定的RESTORED状态,WAITING中的半成品不保存。写入失败不会阻塞当前导航,只记录摘要;下次启动没有快照就按普通首页处理。若用户主动退出账号,快照必须随会话清理,避免另一个账号看到前一位用户的页面线索。

恢复完成后还要校验数据归属。sku_4072只是公开目录键,但详情里的收藏、优惠和草稿可能属于账号;快照只允许恢复页面位置,业务数据仍按当前会话重新加载。网络回调返回时携带 accountGeneration,账号已经切换就丢弃结果。这样页面位置恢复不会演变成跨账号状态泄漏。

DevEco Studio 图里,左侧工程目录分成ability / routing / navigation / model,中间打开PaneCoordinator.ets,右侧模拟器显示SplitLink Lab的 840 vp 双栏结果。底部日志依次是want accepted、layout=SPLIT、route restored,最终值为masterDepth=1 detailDepth=2 duplicate=0 settle=31ms。这能证明页面不是碰巧打开,而是经过一次受控落位。

六、我用四种顺序故意打乱它

只测“点击通知一次”不够。我把窗口准备与 Want 到达组合成四种顺序:冷启动先收到 Want、页面先完成构建;热启动收到同一 trace 两次;单栏打开后立即展开;展开态恢复旧快照后收到新深链。四种路径最后都必须得到同一业务结果:active SKU 为sku_4072,重复页面为 0。

有一个测试很有用:给恢复逻辑故意加 200 ms 延迟,让外部 Want 先落位,再观察旧快照会不会覆盖它。修复前右栏会从sku_4072跳回旧商品;加入“外部意图优先”的提交规则后,延迟回调只补主栏状态,不再触碰详情目标。

我还在窄窗状态连续点两次同一通知,再展开到 840 vp。修复前栈深度从 2 变成 3,展开后两张详情都进入右栏;现在 trace 门禁拦住第二次投递,routeKey 门禁又兜住不同 trace 指向同一 SKU 的情况。两层防重针对的是不同故障,缺一层仍然会漏。

10:24 的运行页与日志一致:跟踪号link_20261001_04,URI 指向sku_4072,窗口 840 vp,模式SPLIT,主栏栈 1、详情栏栈 2、重复 0、稳定耗时 31 ms,状态RESTORED。红色批注只指出“深链落到详情栏”和“重复页面为 0”,它们正好对应这次修复的两条验收线。

七、边界不在双栏,而在状态所有权

这次调整没有给平行视界增加复杂动画,真正变化的是状态所有权:Want 归解析器,重复事件归门禁,窗口形态归布局状态,页面栈归协调器,快照只负责提供候选。任何一个回调都不能越过协调器直接改栈。

正式产品还要继续处理几类边界:目标资源已删除时回退目录并给出可理解提示;通知链接版本高于当前客户端时拒绝未知参数;多窗口实例要各自持有门禁,不能共享一个全局 pending;页面在拉取详情时被新深链替换,要取消旧请求或用 generation 丢弃旧结果;进入后台后不要让延迟回调再次移动焦点。

SplitLink Lab最终没有靠“多加一个 if”解决重复页。它把冷启动、热启动、窗口切换和页面恢复统一成同一条状态提交链。平行视界里真正难的不是把列表和详情摆在左右两边,而是在所有入口顺序都不可靠时,仍然让一个业务目标只落位一次。

上线观察时,我会把source、layoutMode、routeKeyHash、duplicateCount、settleMs放进一次路由诊断事件,不记录完整 URI 查询参数。31 ms 只是本机本次结果,验收更关注不同入口下是否都能稳定落位,以及 p95 是否随版本显著回退。若某机型长期处于WAITING,日志应能判断是窗口未就绪、栈未绑定还是目标参数被拒绝,而不是留下一句含糊的“跳转失败”。

最后还要保留一条可回归的测试基线:相同 trace 重投不增加栈深度,不同 trace 指向同一 routeKey 只更新现有详情,不同 SKU 才产生新的业务目标;窗口从单栏切到双栏后,当前详情与焦点都不改变。只要其中一项回退,就不能把问题归结为“偶现布局抖动”。

参考资料:

  • HarmonyOS Want 概述
  • HarmonyOS Navigation 分栏模式
  • HarmonyOS Navigation 跨包路由
  • HarmonyOS 平行视界社区实践

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询