这次问题只在一种入口出现:应用完全退出后,从通知里的商品链接冷启动。展开态页面能打开 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 格式,并生成稳定的路由键。

export interface RouteIntent {
  traceId: string
  target: 'CatalogDetail'
  sku: string
  source: string
  routeKey: string
}

export function parseCatalogWant(want: Want): RouteIntent | undefined {
  const uri = want.uri ?? ''
  const match = /^app:\/\/catalog\/item\/(sku_[0-9]+)(?:\?source=([a-z_]+))?$/.exec(uri)
  if (!match) return undefined

  const traceId = String(want.parameters?.['traceId'] ?? '')
  if (!traceId) return undefined
  const source = 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 拦截系统或业务侧的重复投递。

export class DeepLinkGate {
  private seen = new Set<string>()
  private pending?: RouteIntent

  accept(want: Want): boolean {
    const intent = parseCatalogWant(want)
    if (!intent || this.seen.has(intent.traceId)) return false
    this.seen.add(intent.traceId)
    this.pending = intent
    return true
  }

  takeWhenReady(): RouteIntent | undefined {
    const intent = this.pending
    this.pending = undefined
    return intent
  }

  endSession(): void {
    this.pending = undefined
    this.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 查找,已存在时移动到顶层并更新参数,不存在时才新增页面。

export class PaneCoordinator {
  constructor(private readonly stack: NavPathStack) {}

  open(intent: RouteIntent, widthVp: number): 'RESTORED' | 'WAITING' {
    if (widthVp <= 0) return 'WAITING'

    const param = { sku: intent.sku, source: intent.source, routeKey: intent.routeKey }
    const index = 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'
  }

  private findByRouteKey(routeKey: string): number {
    return this.stack.getAllPathName().findIndex((_name, index) => {
      const param = this.stack.getParamByIndex(index) as Record<string, string> | undefined
      return param?.['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 才产生新的业务目标;窗口从单栏切到双栏后,当前详情与焦点都不改变。只要其中一项回退,就不能把问题归结为“偶现布局抖动”。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐