第一篇已经把 DualCart 最基础的平行视界链路跑通:

ProductList
|
ProductDetail

左侧保持商品列表,右侧打开详情。

真正继续把购物流程接进去以后,问题很快出现。商品详情并不是每次都由列表直接进入,中间还有一个 ProductBridge:

列表点击
→ 过渡页
→ 参数整形
→ 详情

手机上它存在几十毫秒也没什么,页面最终仍然是单栈。

放到平行视界里以后,如果把 ProductBridge 当普通关联页,右侧会先显示一张没有业务价值的中间页,再替换成商品详情。

第一次看还以为是动画卡了一下。

所以 02 的核心不是“再加一个页面”,而是把 路由模式、过渡页语义、左侧状态连续性 整理清楚。

本轮固定数据:

taskId:
parallel_20261002_02

viewport:
824vp

mode:
PARALLEL_VIEW

leftRoute:
ProductList

rightRoute:
ProductDetail

transitionRoute:
ProductBridge

previousProduct:
product_1042 / Watch 5

selectedProduct:
product_1047 / MateBook Air

category:
电脑办公

leftScrollOffset:
960vp

routeSwitchCount:
3

selectedPreserved:
true

status:
CONTINUOUS

一、过渡页最大的问题,是“技术上是页面,体验上不是页面”

ProductBridgePage 在项目里真实存在。

它负责:

读取商品参数
确认 category
补来源字段
记录推荐位
整理 DetailParam

最开始我把它和普通页面一样 push:

ProductList
→ ProductBridge
→ ProductDetail

手机单栏没有明显问题。

大屏上第一次出现的现象是:

左栏:
ProductList

右栏:
ProductBridge
↓
ProductDetail

过渡页会占用右栏几十毫秒。

如果它还有 loading 文案,就更明显。

所以第二篇第一条规则是:

是否存在页面文件,不等于它应该成为平行视界中的可见分栏页面。

官方当前平行视界示例也专门把“配置过渡页面”列为典型场景之一,这说明过渡语义本身就是适配时需要显式考虑的部分。

二、业务层先给 Route 加语义,不用页面名字猜

这一期新增 RouteContinuityStore.ets。

我先定义路由角色:

export type RouteSemantic =
  'PRIMARY' |
  'ASSOCIATED' |
  'TRANSITION' |
  'FULLSCREEN'

export interface RouteMeta {
  name: string
  semantic: RouteSemantic
  occupyRightPane: boolean
}

export const ROUTE_META:
  Record<string, RouteMeta> = {
    ProductList: {
      name: 'ProductList',
      semantic: 'PRIMARY',
      occupyRightPane: false
    },

    ProductBridge: {
      name: 'ProductBridge',
      semantic: 'TRANSITION',
      occupyRightPane: false
    },

    ProductDetail: {
      name: 'ProductDetail',
      semantic: 'ASSOCIATED',
      occupyRightPane: true
    }
  }

这仍然是 DualCart 自己的业务镜像,不是系统 API。

真正价值是代码、日志和官方配置现在可以对齐同一种语义。

看到 TRANSITION 就知道:

它允许出现在路由链
但不应该成为稳定右栏内容

三、右栏变化,本质上是“替换内容”,不是再叠一层

第一版右栏逻辑一直 push。

点三件商品以后:

ProductDetail(1042)
ProductDetail(1044)
ProductDetail(1047)

都留在右侧路径里。

返回时用户会倒着看三件商品,这和“列表选中项驱动右侧详情”不一致。

所以 DualCart 的购物浏览模式采用:

左栏列表固定
右栏详情替换

示意代码:

transitionToProduct(
  productId: string
): void {
  this.transientRoute = 'ProductBridge'

  ProductSelectionStore.shared()
    .select(productId)

  const param: ProductRouteParam = {
    productId,
    categoryId: this.categoryId
  }

  this.rightPathStack.replacePathByName(
    ShopRoute.PRODUCT_DETAIL,
    param
  )

  this.transientRoute = null
  this.routeSwitchCount++
}

这里最重要的是 replace 的语义。

如果业务希望“右侧详情有自己的历史”,当然也可以 push。

但 DualCart 当前是购物浏览:

左边选什么
右边看什么

所以 replace 更符合用户预期。

四、ProductBridge 存在于流程,但不进入稳定右栏

中间页并没有被删掉。

它仍然可以负责参数准备:

export class ProductBridgeService {
  async normalize(
    rawProductId: string,
    source: string
  ): Promise<ProductRouteParam> {
    const product =
      await ProductRepository.shared()
        .getById(rawProductId)

    return {
      productId: product.id,
      categoryId: product.categoryId,
      source
    }
  }
}

真正改变的是:

它不再决定右栏 UI

流程改成:

ProductList
→ Bridge Service / Transition Semantic
→ replace ProductDetail

如果平行视界配置中需要标记过渡页面,则以当前官方示例和 SDK 对应配置为准;应用侧只维护业务语义,不自己伪造一套系统协议。

这样文章不会因为某一版配置字段变化,就把整个业务代码绑死。

五、左栏不能因为右栏替换而重新加载

解决过渡页闪现以后,我又遇到第二个问题:右侧从 Watch 5 切到 MateBook Air 时,左侧列表会回顶部。

这比闪一下更影响购物体验。

原因不是平行视界,而是我把 Category 列表数据绑定在页面重新进入生命周期里。

右侧替换触发状态变化以后,父层重新构建,列表又执行了一次初始化。

最终把列表上下文提升:

export interface ProductListContext {
  categoryId: string
  scrollOffsetVp: number
  selectedProductId: string
}

export class ProductListState {
  private context: ProductListContext = {
    categoryId: 'computer_office',
    scrollOffsetVp: 960,
    selectedProductId: 'product_1047'
  }

  snapshot(): ProductListContext {
    return { ...this.context }
  }

  updateSelection(id: string): void {
    this.context.selectedProductId = id
  }

  updateScroll(offsetVp: number): void {
    this.context.scrollOffsetVp = offsetVp
  }
}

右侧换产品只更新:

selectedProductId

不重新创建整个列表。

六、selectedProduct 从 1042 变 1047,category 和 scroll 不变

这一轮最关键的状态变化:

previous:
product_1042
Watch 5

current:
product_1047
MateBook Air

同时保持:

category=电脑办公
scrollOffset=960vp

这就是第二篇的“连续性”。

不是页面没崩。

而是用户在左侧已经浏览到电脑办公列表中段,点击新商品后,不会突然回第一屏。

七、路由模式要和业务场景一致,不能所有页面都采用同一种历史策略

DualCart 当前至少有三类路由:

1. Browsing

列表 → 详情

右栏 replace。

2. Workflow

购物车
→ 确认订单
→ 支付

这类需要明确历史,通常不能简单替换。

3. Fullscreen

商品大图
视频介绍

后面会请求全屏,不应该留在普通右栏。

所以我没有写一个全局:

所有关联页都 replace

而是增加路由模式:

export enum RouteMode {
  BROWSING = 'BROWSING',
  WORKFLOW = 'WORKFLOW',
  IMMERSIVE = 'IMMERSIVE'
}

export class RouteModePolicy {
  resolve(route: string): RouteMode {
    if (route === ShopRoute.PRODUCT_DETAIL) {
      return RouteMode.BROWSING
    }

    if (
      route === 'Checkout' ||
      route === 'Payment'
    ) {
      return RouteMode.WORKFLOW
    }

    return RouteMode.IMMERSIVE
  }
}

02 只把 Browsing 模式做完整。

后面 03 才会处理全屏和横屏。

八、过渡页还要防止慢请求覆盖新选择

快速点两件商品时又出现一个真实工程问题。

用户先点:

product_1042

Bridge 开始请求。

马上又点:

product_1047

如果 1042 的请求更晚回来,它可能反过来覆盖右栏。

所以过渡流程加请求序号:

private requestSeq: number = 0

async selectProduct(
  productId: string
): Promise<void> {
  const seq = ++this.requestSeq

  const param =
    await this.bridgeService
      .normalize(productId, 'product_list')

  if (seq !== this.requestSeq) {
    return
  }

  this.transitionToProduct(
    param.productId
  )
}

这不是平行视界专属问题。

但双栏里它更明显,因为左侧当前高亮已经是 1047,右侧如果被旧请求改回 1042,用户一眼就能看到左右不一致。

九、DevEco 图里要同时看到“过渡路由”和“最终右栏”

开发图:

本轮 HiLog:

taskId=parallel_20261002_02

transition:
ProductList → ProductBridge

bridge semantic:
TRANSITION

right pane replace:
ProductDetail
product_1042 → product_1047

left state:
category=电脑办公
scroll=960vp

routeSwitchCount=3

selectedPreserved=true

status=CONTINUOUS

这里 ProductBridge 会出现在日志,但不会成为稳定右栏。

这是判断配置和业务逻辑是否正确的一个很直观证据。

十、运行图要看左右两边是否一致

最终运行图:

左侧:

电脑办公
MateBook Air 高亮
scroll=960vp

右侧:

ProductDetail
product_1047
MateBook Air

诊断区:

leftRoute=ProductList
rightRoute=ProductDetail
transitionRoute=ProductBridge
status=CONTINUOUS

没有“左侧还选 Watch 5,右侧已经 MateBook Air”的状态分裂。

十一、这次没有把 EasyGo 配置字段硬编码进业务代码

这里我刻意保持一条边界。

平行视界是系统级适配能力,官方当前示例明确采用“Navigation 路由 + 配置”实现;配置格式和具体能力应以当前官方样例、工具和文档为准。

DualCart 业务代码只保存:

route name
route semantic
selected state
transition state

不要让页面直接依赖某个配置文件字段。

这样后续调整:

路由模式
过渡页
虚拟容器

不会把 ProductList / ProductDetail 的业务实现改得面目全非。

十二、这一轮还专门做了三组状态连续性测试

为了确认问题不是“刚好这次没丢”,我把操作固定成三组。

第一组,左侧滚到 960vp,再连续点击三件商品。右侧应该替换三次,左侧不回顶部。

第二组,先点 Watch 5,马上点 MateBook Air,故意让前一个 Bridge 请求更晚返回。最终必须保持 product_1047,旧请求不能覆盖新选择。

第三组,在右侧详情已经显示后切换分类,再切回“电脑办公”。此时列表可以重新加载分类数据,但恢复后仍然以当前 category 自己的 selected state 为准,不能拿上一分类的滚动位置硬套。

这三组测试说明:

连续性不是永远不变
而是该保留的状态保留
该重新计算的状态重新计算

这和前面多形态系列的思路其实一致,但这次边界落在路由关系而不是窗口尺寸。

十三、右栏替换完成后,返回动作也要保持语义稳定

BROWSING 模式里右栏用 replace 以后,返回键不应该按商品历史一件件倒退。

当前策略:

右侧 ProductDetail
→ 返回
→ 关闭当前详情语义
→ 左侧 ProductList 保持

手机单栏则仍然按照 Navigation 栈返回上一页。

这说明“返回”也不能只看 route name,还要看当前展示模式。

DualCart 没有在页面里直接判断设备,而是由 RouteModePolicy + current presentation state 共同决定返回行为。

这样后面 03 全屏视频退出时,也能复用同一套思路:退出沉浸态,回到原双栏上下文,而不是重新打开列表。

十四、第二篇结束以后,下一步就是“哪些页面必须离开双栏”

目前双栏浏览已经稳定。

但商品详情里还有两个入口:

查看商品视频
查看高清大图

这两个页面如果继续硬塞右栏,体验并不好。

所以 03 会沿用 DualCart 继续做:

请求全屏
请求横屏
沉浸页退出后回到原双栏
selectedProduct 不丢

也就是官方平行视界示例七个典型场景里的下一组。

04 再继续做虚拟容器和商品比价模式。

整个系列会固定到 06,不会在当前两篇后重新换项目。

十五、过渡页的加载状态不能继续写进右栏 UI

第二篇还有一个小坑:Bridge 请求超过 100ms 时,产品经理希望“给用户一个加载提示”。

最直接的写法是在右栏显示 ProductBridgePage 的 loading。

我最后没有这么做。

因为这会重新把 TRANSITION 变成稳定 UI。

当前做法是把加载状态挂在列表选中项和右栏详情骨架上:

左侧 product_1047
→ 显示轻量 loading 标记

右侧旧详情
→ 保持
→ 新详情准备好后原位替换

这样用户始终知道自己点了哪一项,但右栏不会出现一张只存在几十毫秒的中间页面。

这也是“过渡页语义”真正落地的地方:它可以有业务过程,但不一定有视觉页面。

十六、右栏替换要防止重复提交同一商品

快速双击同一商品时,旧版会产生两次 Bridge 请求和两次 replace。

结果视觉没变,日志却多了两次 routeSwitch,后面埋点也会重复。

所以选择入口先做幂等:

async selectProduct(
  productId: string
): Promise<void> {
  if (
    productId ===
    ProductSelectionStore.shared().get()
  ) {
    return
  }

  const seq = ++this.requestSeq
  const param = await this.bridgeService
    .normalize(productId, 'product_list')

  if (seq !== this.requestSeq) {
    return
  }

  this.transitionToProduct(param.productId)
}

这里的 return 不只是性能优化。

它还能保证“同一个选中态不产生新的导航历史和曝光事件”。

十七、日志里增加 route transaction,排查顺序会清楚很多

双栏以后,一个点击会经过:

左栏事件
Bridge
参数准备
右栏 replace
详情完成

如果每层各打自己的日志,很难看出它们是不是同一次操作。

所以这一篇新增 routeTxId:

routeTxId=rt_20261002_0203

同一次选择所有日志都带它:

rt_0203 select product_1047
rt_0203 bridge start
rt_0203 bridge done
rt_0203 right replace ProductDetail
rt_0203 detail ready

下一次点击生成新 transaction。

这样出现“右栏显示旧商品”时,只要比对最后两个 transaction 的顺序,就能判断是慢请求覆盖,还是 Store 恢复错误。

这比只看 routeSwitchCount=3 更适合真实项目排障。

十八、从大屏退回手机时,过渡状态必须被清空

第二篇最后专门测了一条容易遗漏的路径:

平行视界正在切商品
→ 用户折叠设备
→ 页面回到单栏

如果 transientRoute=ProductBridge 没有清掉,单栏恢复时可能误以为当前顶部页面还是过渡页。

所以 presentation mode 变化时会做一次收口:

onPresentationModeChange(
  parallel: boolean
): void {
  if (!parallel) {
    this.transientRoute = null
    this.syncSingleStackFromSelection()
  }
}

这里不是把路由全部清空,而是根据当前 selectedProductId 把单栏页面栈整理成可解释状态。

当前选中是 product_1047,折叠以后应该直接看到 MateBook Air 详情;按返回再回 ProductList。

十九、第二篇最终验收不只看 CONTINUOUS

我把 02 的验收固定成:

1. ProductBridge 不稳定占用右栏
2. product_1042 → product_1047 右栏原位替换
3. 左栏 category 保持电脑办公
4. 左栏 scroll 保持 960vp
5. 慢请求不能覆盖新选择
6. 重复点击同商品不新增 transaction
7. 从平行视界退回单栏时 transient route 被清理
8. 返回行为符合 BROWSING 模式

这些条件都通过以后,CONTINUOUS 才不是一张绿色状态卡,而是可以被工程验证的结论。

二十、最后再测一次数据刷新:商品下架时右栏要主动降级

如果当前选中 product_1047,后台刷新后这件商品已经下架,左侧列表会自然移除它。

右侧如果仍停留在旧详情,就会形成另一种状态分裂。

所以刷新完成后会校验:

selectedProductId
是否仍存在于当前 category 数据集

如果不存在,优先选择当前可用的第一项;如果列表为空,则退出 ProductDetail 稳定右栏,进入空状态。

这说明 selected state 也不是“永远保持不变”。连续性的真正含义是:在业务数据仍有效时保持用户上下文;数据本身已经失效时,必须有可解释的降级策略。

参考资料

  • HarmonyOS 7 官方示例:平行视界:https://developer.huawei.com/consumer/cn/samples/
  • 2026 年 7 月开发者月刊:平行视界:https://developer.huawei.com/consumer/cn/monthly/202607
  • HarmonyOS 多设备适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
  • Navigation 分栏开发:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode
Logo

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

更多推荐