这次改的是一个很容易被误判成“动画抖了一下”的问题。项目 ParallelCatalogLab 是商品浏览 Demo,手机上走普通单栏 Navigation,折叠屏展开或平板大屏时启用平行视界:左边商品列表保持不动,右边不断切换商品详情。表面看只是把页面从全屏切成左右两栏,真正上线后却出现了一个麻烦现象:快速连续点击同一商品,或者设备在展开/收拢的瞬间又触发一次状态恢复,右侧会重复压入相同 ProductDetail。用户第一次返回看不出异常,第二次返回仍然停在同一详情页,像返回键失效。

我最后没有把它当 UI 问题修,而是把它当“系统分栏状态 + 业务 Navigation 栈”双状态源冲突来处理。下面记录这次治理过程,重点不是介绍平行视界怎么开,而是说明一个已经接入平行视界的业务,怎样保证详情页只入栈一次、单栏与双栏切换后返回关系仍然一致。

一、先确认:重复页面不是平行视界自己多开了一份

HarmonyOS 7(API 26)的平行视界面向折叠屏、平板等大屏设备,官方能力说明里提供购物模式、导航模式以及可调分栏比例等场景。项目里最适合的是购物类“左列表 + 右详情”:左栏保留上下文,右栏连续浏览详情。这个能力会改变窗口中的页面呈现关系,但我们的业务路由栈仍然由应用自己维护。

问题最初出现在三种操作组合里:

  1. 展开态下在左栏连续点两次 SKU-2381;
  2. 从单栏详情页展开设备,布局切为双栏时执行一次“恢复当前详情”;
  3. 进程未销毁、窗口重新获得焦点后,页面生命周期回调再次按当前选中商品补一次详情。

三个入口都认为自己“有理由打开详情”,于是都调用 pushPathByName('ProductDetail', ...)。平行视界只是让这个错误更容易被观察到:右栏仍然看起来是同一商品,但 Navigation 栈深度已经从 2 变成 3、4。于是问题不在“系统创建了几个页面”,而在“我们给同一个业务意图创建了几个路由节点”。

我在调试版里给每次详情打开动作加了序号 routeSeq。正常流程中,打开 SKU-2376 是 #15,切到 SKU-2381 是 #16;重复事件到达时不再继续 push,而是记录为 #17 的 DUPLICATE_BLOCK。最终截图里当前商品仍然是 SKU-2381,栈深度保持 2,去重拦截次数累计 3。

二、不要让“当前商品”和“当前路由节点”各自维护一份真相

这类 bug 常见的根源是:列表组件维护 selectedId,详情组件从路由参数读取 id,页面恢复逻辑又从本地持久化拿 lastProductId。三个值平时相同,一遇到窗口形态变化就可能错开半拍。

我把状态拆成两层:

  • CatalogSelectionState:业务层只回答“用户当前想看哪个商品”;
  • RouteGate:路由层只回答“这个业务意图是否已经对应到栈顶节点”。

页面组件不再直接 push,所有“打开详情”的入口都进 RouteGate.openDetail()。这样设备展开、点击列表、恢复现场、深链启动虽然来源不同,最后都遵守同一条规则。

下面是项目里简化后的状态定义。这里的 routeSeq 不是系统字段,只是我为了排查顺序加的业务诊断值。

// model/CatalogRouteState.ets
export interface DetailRouteState {
  productId: string
  routeSeq: number
  openedAt: number
}

@ObservedV2
export class CatalogSelectionState {
  @Trace currentDetailId: string = ''
  @Trace layoutMode: 'SINGLE_PANE' | 'PARALLEL' = 'SINGLE_PANE'

  select(productId: string): void {
    this.currentDetailId = productId
  }
}

这么拆以后,一个非常关键的约束就明确了:currentDetailId 可以因为用户点击立即变化,但 Navigation 栈是否需要新增节点,要等 RouteGate 判断。也就是说“选中了某商品”和“需要 push 一个新详情页”不再是同一件事。

三、最初的写法为什么在大屏上特别容易出错

旧代码很直接:列表项点击就 push,窗口形态变化时再补一次当前详情。

// 旧实现:两个入口都可能 push 同一个详情
onProductTap(itemId: string): void {
  this.selection.select(itemId)
  this.navPathStack.pushPathByName('ProductDetail', { id: itemId })
}

onLayoutModeChanged(mode: string): void {
  if (mode === 'PARALLEL' && this.selection.currentDetailId.length > 0) {
    this.navPathStack.pushPathByName('ProductDetail', {
      id: this.selection.currentDetailId
    })
  }
}

手机单栏时,用户很少在几十毫秒内触发两条不同恢复路径,所以这个问题藏得很深。平行视界场景里,屏幕形态、窗口尺寸、左右分栏和页面可见性同时变化,原先“只会执行一次”的假设不再可靠。

更麻烦的是,单纯判断“上次点击是不是同一个 id”也不够。假设用户先看 SKU-2381,再看 SKU-2399,然后点系统返回回到 SKU-2381,这时再次点击 SKU-2381 是否应该 push,取决于当前栈顶而不是历史点击记录。所以去重条件必须围绕“当前有效路由节点”建立,而不是围绕“最近一次点击”。

四、用 RouteGate 把 push 变成一次可审计的状态变更

我把导航入口收口到一个轻量 RouteGate。它维护当前详情 id、路由序号和去重计数;真正 push 前先判断当前栈顶对应的详情是否已经是目标商品。

// service/RouteGate.ets
import { hilog } from '@kit.PerformanceAnalysisKit'

export class RouteGate {
  private routeSeq: number = 0
  private currentDetailId: string = ''
  private dedupeCount: number = 0

  constructor(private navigation: NavPathStack) {}

  openDetail(itemId: string): void {
    if (this.currentDetailId === itemId) {
      this.dedupeCount++
      hilog.warn(0x0000, 'RouteGate',
        `duplicate blocked: ${itemId}, count=${this.dedupeCount}`)
      return
    }

    this.routeSeq++
    this.currentDetailId = itemId
    hilog.info(0x0000, 'RouteGate',
      `push ${itemId} routeSeq=${this.routeSeq}`)

    this.navigation.pushPathByName('ProductDetail', {
      id: itemId,
      routeSeq: this.routeSeq
    })
  }

  getDiagnostics(): [string, number, number] {
    return [this.currentDetailId, this.routeSeq, this.dedupeCount]
  }
}

真实项目里我没有只靠 currentDetailId 这一个字段,栈发生 pop、replace、恢复时还会同步校正它。文章里先把核心判断抽出来,避免代码被业务细节淹没。

DevEco Studio 的调试图里,项目名是 ParallelCatalogLab,当前文件 RouteGate.ets,模拟器右侧显示 SKU-2381。HiLog 里先记录 push SKU-2381 routeSeq=17,紧接着同 id 的重复动作被拦下,duplicate blocked: SKU-2381, count=3。这张图真正要看的不是红圈本身,而是“代码判断—日志—模拟器当前商品”三处数据一致。

1. pop 以后必须反向同步 RouteGate

只做 push 去重会留下第二个坑:用户返回后 currentDetailId 仍然保留旧值,再次打开该商品会被错误拦截。于是 Navigation 栈变化也要反向更新 RouteGate。

// 由页面/路由监听回传当前可见详情
reconcileVisibleDetail(visibleId: string | undefined, depth: number): void {
  this.currentDetailId = visibleId ?? ''
  hilog.info(0x0000, 'NavAudit',
    `stackDepth=${depth}, currentId=${this.currentDetailId || 'EMPTY'}`)
}

// 窗口从双栏回单栏时不盲目 push,只恢复“选择状态”
onParallelModeExit(): void {
  const id = this.selection.currentDetailId
  if (id.length === 0) {
    return
  }
  this.routeGate.reconcileVisibleDetail(id, this.navPathStack.size())
}

这里的原则是:形态切换负责“对齐”,不负责“创造新的业务历史”。如果用户原本已经在看 SKU-2381,展开设备只是把同一上下文改成双栏展示,不应该凭空增加一个详情节点。

五、手机单栏不是另一套业务,只是同一状态的另一种投影

为了验证这一点,我专门保留了手机单栏调试页。图 03 顶部状态栏是 10:42、5G、Wi‑Fi、电量 86%,列表里 SKU-2381 仍然是当前详情;底部诊断区显示:

  • 布局模式:SINGLE_PANE;
  • routeSeq = 17;
  • 当前详情:SKU-2381。

这三个值与 DevEco 日志保持一致。也就是说,手机上虽然看不到平行视界双栏,但它没有重新生成一套选择状态。

我把 UI 的布局判断写成纯派生逻辑,不让它修改路由:

getContentMode(windowWidthVp: number): 'SINGLE_PANE' | 'PARALLEL' {
  const nextMode = windowWidthVp >= 840 ? 'PARALLEL' : 'SINGLE_PANE'
  if (this.selection.layoutMode !== nextMode) {
    this.selection.layoutMode = nextMode
  }
  return nextMode
}

这里的 840vp 只是这个 Demo 的业务断点示例,不是平行视界官方固定阈值。实际项目要根据设计规格、设备形态和官方适配方案确定。文章里刻意写清这一点,因为“示例常量”和“系统规则”混在一起,是技术文章最容易造成误解的地方。

六、我最后验收的不是页面长什么样,而是返回栈是否守住契约

最终调试页把路由信息直接做成可视化面板。图 04 中:

  • 当前布局 SINGLE_PANE;
  • 当前商品 SKU-2381;
  • 路由序号 17;
  • 返回栈深度 2;
  • 去重拦截次数 3;
  • 主从一致性 CONSISTENT。

路由栈只剩 CatalogHome -> ProductDetail(SKU-2381) 两层;事件记录里 #17 被标成 DUPLICATE_BLOCK,恢复校验结果是 CONSISTENT。这比“肉眼点两下没问题”可靠得多。

我把验收拆成四组:

1. 连续点击

同一商品 300ms 内连点 3 次,只允许第一次改变当前路由;另外两次只增加 dedupe 计数。不同商品连点则允许切换,因为业务意图真的变了。

2. 展开与收拢

在 SKU-2381 详情页反复展开、收拢设备,页面呈现可以在单栏与双栏之间变化,但返回栈深度不得因为形态切换增长。

3. 前后台恢复

进入后台后再回前台,恢复逻辑只能校正 selection 和可见详情,不得重复 push 已存在的详情节点。

4. 深链启动

从外部链接直接打开某个商品时,允许建立 CatalogHome + ProductDetail 的初始栈;如果应用已在前台且目标正好等于当前详情,则走去重,不再新增同页。

七、几个没有继续“抽象到底”的边界

这次没有做成一个万能路由框架,因为平行视界里还存在几类场景不应该被粗暴去重。

第一类是“同路由名、不同业务语义”。例如两个 ProductDetail 虽然 id 一样,但一个是普通浏览,一个是对比态临时页。如果只比较 productId 会误伤。更稳妥的 route key 应该是 page + productId + scene。

第二类是多窗口。用户可能在不同窗口同时打开同一商品,窗口 A 和窗口 B 的 Navigation 栈不能共用一个全局 currentDetailId。RouteGate 应该按 window/session 隔离。

第三类是进程重建。内存中的 routeSeq 只能用于本次会话诊断,不能拿它当永久业务 id。真正需要跨进程恢复时,应保存可恢复的业务状态,再重新构建栈,而不是序列化整个运行期对象。

第四类是详情内部二级页。如果右栏从商品详情继续进入评价、参数、店铺,返回关系是有意义的历史,不能为了“栈浅”把它们都 replace。去重只处理重复业务意图,不等于禁止正常导航历史。

八、真正花时间的是“谁有资格恢复页面”

把重复 push 挡住以后,我原以为问题就结束了,结果第二轮回归又暴露出一个更隐蔽的现象:栈深度不增长了,但设备展开后右栏偶尔会先显示旧商品,再瞬间跳回当前商品。肉眼看像闪屏,日志里却没有重复 push。

原因是“恢复动作”仍然有三个来源:窗口形态监听、页面 aboutToAppear、业务 Store 的持久化恢复。它们虽然不再直接创建重复路由,但都会修改 currentDetailId。也就是说,副作用从“重复建页”变成了“同一状态被多方抢写”。

我最后给恢复动作定了优先级:

  1. 活跃 Navigation 栈最高优先级。当前栈里已经有可见详情时,以它为准;
  2. 用户刚发生的显式点击次之。点击带有新的交互时间戳,可以覆盖旧的持久化状态;
  3. 持久化状态只用于冷启动兜底。页面和路由都没有可恢复信息时才使用;
  4. 窗口形态变化不创造业务选择。它只改变呈现模式,不主动替用户选择商品。

为了让这个优先级可执行,我给选择状态加了 source 和 updatedAt。恢复逻辑不再简单地“谁后执行谁覆盖”,而是先比较来源等级,再比较时间。这个改动之后,展开时右栏闪旧数据的问题才真正消失。

export enum SelectionSource {
  PERSISTED = 1,
  LAYOUT_RESTORE = 2,
  USER_ACTION = 3,
  NAV_VISIBLE = 4
}

export interface SelectionSnapshot {
  productId: string
  source: SelectionSource
  updatedAt: number
}

shouldAccept(oldValue: SelectionSnapshot, next: SelectionSnapshot): boolean {
  if (next.source !== oldValue.source) {
    return next.source > oldValue.source
  }
  return next.updatedAt >= oldValue.updatedAt
}

这个优先级不是 HarmonyOS 系统规则,是 ParallelCatalogLab 的业务恢复策略。关键价值在于把“恢复谁说了算”变成显式规则,而不是散落在生命周期回调里的隐含假设。

九、日志不能只写“打开成功”,要能复原一次路由事故

早期日志只有两行:open detail 和 back。真出问题时完全不够用,因为看不出是谁发起、当时处于单栏还是双栏、栈深度是多少、是否发生过形态变化。

我后来把每次路由动作都写成一条结构化记录,至少包含:

  • eventId:单次诊断事件序号;
  • action:OPEN / DUPLICATE_BLOCK / POP / RESTORE_CHECK;
  • productId:目标业务对象;
  • layoutMode:SINGLE_PANE / PARALLEL;
  • stackDepthBefore 与 stackDepthAfter;
  • routeSeq;
  • source:USER / LAYOUT / RESTORE / DEEP_LINK;
  • elapsedMs:从用户动作到路由稳定的耗时。

这样一份日志可以直接回答“重复入栈究竟是不是用户连点造成的”。例如这次 #17 的 source 是 LAYOUT_RESTORE,而 #16 是 USER,两者 productId 都是 SKU-2381,说明不是用户手抖,是形态恢复晚到了一次。这个结论比根据视频猜要可靠得多。

开发环境里我还加了一个 NavAudit 页面,也就是图 04 那种调试视图。正式包不会展示,但测试包保留。测试同学不需要连 DevEco,看页面就能知道当前栈深度、去重次数和最后一次恢复结果。对于折叠屏这种“步骤一多就难复现”的问题,这个小页面很省沟通成本。

十、不要为了防重,把正常的快速浏览做慢了

去重最容易走向另一个极端:加锁、加 debounce、等待稳定窗口,最后用户点下商品要过几百毫秒右栏才更新。平行视界的价值本来就是连续浏览,如果为了安全把交互做钝,等于修好了 bug 又损失体验。

我最后的处理原则是:

  • 选择状态立即更新,左栏高亮可以马上变化;
  • 同 key 重复 push 同步拒绝,这个判断只做字符串/轻量状态比较;
  • 不同商品不做 debounce,用户快速从 A 点到 B、再点 C,应该允许连续切换;
  • 重型恢复校验异步执行,不阻塞首帧,只在发现栈不一致时修正;
  • 日志写入降噪,连续重复事件只保留计数和首尾时间,不无限刷屏。

在测试机上,RouteGate 的同步判断耗时基本可以忽略,真正影响体验的仍然是详情数据加载和图片解码。所以我没有为了“架构完整”引入复杂锁或消息队列。单 UI 线程上的路由入口收口已经足够解决这个 Demo 的问题;如果未来把路由请求跨 Worker、跨窗口并发提交,再考虑更强的串行化机制。

十一、上线前我补了一张回归矩阵

最后一次回归没有再凭感觉点。测试矩阵按“设备形态 × 入口 × 当前栈”组合展开:

场景初始状态动作预期栈深度关键检查
手机单栏首页点 SKU-23812routeSeq +1
手机单栏SKU-2381 详情再点同商品2dedupe +1
展开双栏SKU-2381 详情触发展开2不新增节点
双栏SKU-2381 详情点 SKU-23992 或按业务替换策略当前详情变更
双栏详情可见连续两次恢复不增长第二次被拦截
双栏转单栏右栏详情收拢设备不增长selection 保留
前后台SKU-2381回前台不增长restore consistent
深链应用前台已有同详情打开同 SKU不增长deep-link 去重

表里“不同商品后栈深度 2 或按业务替换策略”是有意保留的:如果产品希望连续详情可逐级返回,可以 push;如果希望右栏永远只替换当前详情,可以 replace。RouteGate 负责的是同一业务意图不重复,不替产品经理决定导航历史。

做完这张矩阵后,我才敢把调试页上的 CONSISTENT 当成验收结果,而不是一个好看的绿色标签。

十二、数据加载也要跟路由 key 绑定,别让旧请求覆盖新详情

路由栈稳定之后还有一个容易被忽略的问题:用户在左栏从 SKU-2381 很快切到 SKU-2399,前一个详情请求可能后返回。如果详情组件只把接口结果写进同一份 detailState,旧请求就会把新商品覆盖掉。表面上看像平行视界右栏“自己跳回去了”,其实是典型的异步竞态。

我给每次详情加载都带上当前 route key,结果回来时再确认一次:

async loadDetail(productId: string, routeSeq: number): Promise<void> {
  const data = await this.repository.queryProduct(productId)
  const [currentId, currentSeq] = this.routeGate.getCurrentKey()
  if (currentId !== productId || currentSeq !== routeSeq) {
    hilog.warn(0x0000, 'DetailLoader',
      `stale result ignored: ${productId}, routeSeq=${routeSeq}`)
    return
  }
  this.detailState = data
}

这样 SKU-2381 的慢请求即使最后才返回,也只会被记录为 stale,不会污染 SKU-2399。这和前面的重复入栈治理本质一致:所有异步副作用都要证明自己仍然属于当前业务意图。

缓存同样要按商品 key 保存,而不是只缓存“最近一份详情”。平行视界鼓励连续浏览,用户在左右栏间高频切换时,合理缓存能明显减少白屏,但缓存命中后仍要经过当前 route key 校验。否则缓存越快,错误覆盖反而越快。

最后我还把骨架屏显示条件从“正在请求”改成“当前 key 对应的数据未就绪”。旧请求在后台继续跑,不应该让新详情的 loading 状态被它影响。至此,路由、选择、数据加载三条链才真正围绕同一个 key 收敛。

十三、这次修改真正解决的是“大屏状态失控”,不是一个返回键 bug

修完以后最大的变化是,我不再把“列表选中了哪个商品”“右栏显示了哪个商品”“Navigation 栈顶是什么”当成三个互不相关的变量。selection 负责业务意图,RouteGate 负责路由副作用,形态变化只做对齐。这样单栏、平行视界双栏、前后台恢复、深链入口都能围绕同一个契约工作。

平行视界本身提供的是更适合大屏的应用内分栏能力,开发者真正容易踩坑的地方往往在业务已有的路由、缓存、生命周期和状态恢复。页面能成功分成两栏,只能算“接入成功”;返回顺序、状态续接、重复事件和多窗口隔离都稳定,才算工程上真正可用。

另外我保留了一个很简单的上线监控指标:duplicate_block / detail_open。正常用户偶尔连点会产生少量拦截,但如果某个版本这个比例突然抬高,往往说明生命周期、窗口恢复或深链入口又出现了重复触发。它不是业务 KPI,却很适合做路由异常的早期信号。配合 stackDepth 最大值和 RESTORE_CHECK 失败次数,线上即使没有用户录屏,也能判断问题是在“用户操作层”还是“恢复机制层”。

参考资料

Logo

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

更多推荐