HarmonyOS 7 DualCart 平行视界适配实录 02:Navigation × EasyGo:路由模式、过渡页与双栏状态连续性【鸿蒙心迹】
第一篇已经把 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
更多推荐



所有评论(0)