03 刚刚解决了商品视频和高清大图的沉浸路由:短时间离开 ProductList | ProductDetail,进入全屏或横屏,退出以后再把原来的双栏上下文恢复回来。
第四篇做的是另一种完全相反的交互。
用户不是想"离开购物流",而是想在原来的购物过程中临时比较两件商品:
text
MateBook Air
VS
MatePad Pro
如果我直接再 push 一层详情,主路由会变成:
text
ProductList
→ ProductDetail(product_1047)
→ ProductDetail(product_1051)
关闭比价以后还要倒退页面栈,而且原来的右栏语义也变得不清楚。
当前 HarmonyOS 官方"平行视界"示例把"开启虚拟容器能力"明确列为七个典型场景之一。2026 年 6 月开发者月刊还提到,HarmonyOS 7 的平行视界在大屏分栏基础上新增了 1:2、2:1 等比例配置,目的就是让不同业务能用更合适的分栏比例承载内容。
所以 04 的工程目标不是"再造两个 Detail",而是:
text
保存当前主路由上下文
→ 打开一个独立比较容器
→ 容器里同时承载两个商品详情
→ 关闭容器
→ 原 ProductList | ProductDetail 原样恢复
本轮固定数据:
text
taskId:
parallel_20261002_04
viewport:
824vp
mode:
VIRTUAL_CONTAINER
containerId:
compare_vc_01
ratio:
50:50
primaryProduct:
product_1047 / MateBook Air
compareProduct:
product_1051 / MatePad Pro
category:
电脑办公
mainRoute:
ProductList | ProductDetail
leftScrollOffset:
960vp
closeCompareCost:
36ms
status:
VIRTUAL_READY

一、比价场景为什么不应该继续污染主 Navigation
双栏浏览阶段,主路由非常简单:
text
左:
ProductList
右:
ProductDetail(product_1047)
比价如果直接使用同一个 rightPathStack:
text
ProductDetail(1047)
→ ProductDetail(1051)
用户视觉上虽然看到了第二个商品,但系统和业务上已经失去了"主商品 / 对比商品"的区别。
更糟糕的是关闭比价时:
text
到底 pop 一层
还是 replace 回 1047
还是重新加载 ProductList
每一种做法都在主路由上继续打补丁。
所以第四篇第一条规则是:
比价属于一个临时工作上下文,不应该改写原来的主购物上下文。
这和 03 的全屏快照很像,但方向相反:03 是单个沉浸页独占画面;04 是临时容器内部再并行两个详情。
二、应用侧只维护 VirtualCompareContext,不伪造系统配置字段
我没有在 ArkTS 页面里直接写某个 EasyGo 配置字段。
当前官方示例确认了"虚拟容器能力"这个场景,但具体配置应以当前示例工程和 SDK 配套规则为准。
DualCart 业务侧只维护一个稳定的数据模型:
ts
export interface VirtualCompareContext {
taskId: string
containerId: string
primaryProductId: string
compareProductId: string
categoryId: string
ratio: '50:50' | '1:2' | '2:1'
mainLeftRoute: string
mainRightRoute: string
leftScrollOffsetVp: number
}
export const compareContext:
VirtualCompareContext = {
taskId:
'parallel_20261002_04',
containerId:
'compare_vc_01',
primaryProductId:
'product_1047',
compareProductId:
'product_1051',
categoryId:
'computer_office',
ratio:
'50:50',
mainLeftRoute:
'ProductList',
mainRightRoute:
'ProductDetail',
leftScrollOffsetVp:
960
}
业务层只描述"我要比较什么"。
系统级虚拟容器配置由适配层完成。
这样后面官方配置字段变化,不会迫使 ProductDetail 改业务模型。
三、进入比价以前,主路由必须冻结成一份快照
第四篇新增 VirtualCompareCoordinator.ets。
第一段核心逻辑:
ts
export class VirtualCompareCoordinator {
private mainSnapshot:
PairedStateSnapshot | null = null
async open(
primaryId: string,
compareId: string
): Promise<void> {
this.mainSnapshot =
ParallelStateStore.shared()
.snapshot()
CompareSessionStore.shared()
.open({
containerId:
'compare_vc_01',
primaryProductId:
primaryId,
compareProductId:
compareId,
categoryId:
'computer_office',
ratio:
'50:50'
})
AppStorage.setOrCreate(
'virtualCompareMode',
true
)
}
async close(): Promise<void> {
CompareSessionStore.shared()
.close()
AppStorage.setOrCreate(
'virtualCompareMode',
false
)
if (this.mainSnapshot !== null) {
await ParallelRouteManager.shared()
.restorePairedState(
this.mainSnapshot
)
}
this.mainSnapshot = null
}
}
这段代码解决的是"比价关闭以后主页面到底恢复到哪"的问题。
恢复来源只有一个:
text
mainSnapshot
而不是根据当前 Route 猜。
四、主商品和对比商品必须有明确角色
一开始我只是用:
text
leftProduct
rightProduct
后来发现容器比例一变,left/right 语义会误导。
例如未来使用:
text
1:2
并不代表右边就是主商品。
所以改成:
text
primaryProduct
compareProduct
当前:
text
primary:
product_1047
MateBook Air
compare:
product_1051
MatePad Pro
即使以后 2:1 或 1:2,业务语义也不变。
页面位置只是表现层。
五、分栏比例要由内容类型决定,而不是只看屏幕宽度
2026 年 6 月官方月刊已经提到 HarmonyOS 7 平行视界新增 1:2 和 2:1 分栏配置,说明系统适配本身开始支持更多业务比例。
DualCart 里我没有立刻把所有比价都用 50:50。
当前策略:
ts
export class CompareRatioPolicy {
resolve(
primaryKind: string,
compareKind: string
): '50:50' | '1:2' | '2:1' {
if (
primaryKind ===
'SPEC_DENSE' &&
compareKind ===
'SPEC_DENSE'
) {
return '50:50'
}
if (
primaryKind ===
'VISUAL' &&
compareKind ===
'SPEC_DENSE'
) {
return '2:1'
}
return '1:2'
}
}
本轮两件商品都属于参数密集型:
text
MateBook Air
MatePad Pro
所以使用:
text
50:50
这样两边规格表更容易直接对齐比较。
六、双详情不能共享同一个 selectedProductId
这是这篇最容易踩的状态坑。
主购物流只有一个:
text
selectedProductId
比价里却有:
text
primaryProductId
compareProductId
如果继续复用 selectedProductId,两边切换会互相覆盖。
所以 CompareSessionStore 独立保存:
ts
export class CompareSessionStore {
@State
primaryProductId: string =
'product_1047'
@State
compareProductId: string =
'product_1051'
@State
ratio: string =
'50:50'
@State
containerId: string =
'compare_vc_01'
open(
context:
VirtualCompareContext
): void {
this.primaryProductId =
context.primaryProductId
this.compareProductId =
context.compareProductId
this.ratio =
context.ratio
this.containerId =
context.containerId
}
close(): void {
this.containerId = ''
}
}
主流的 selectedProductId=product_1047 保持不动。
关闭比价以后,右栏自然还是 MateBook Air。
七、比价容器里两个详情页可以共享数据仓,但不能共享页面状态
两个商品都来自同一个 ProductRepository,没有必要各自重新请求全部基础数据。
但页面状态不能共享:
text
主商品规格 Tab
对比商品规格 Tab
主商品图片索引
对比商品图片索引
主商品数量
对比商品数量
都应该独立。
所以当前 CompareDetailState:
ts
export interface CompareDetailState {
productId: string
activeTab: string
galleryIndex: number
quantity: number
}
容器有两份实例。
这样左边切到"参数",不会把右边也切成"参数"。
八、关闭容器以后,恢复速度比打开速度更重要
比价打开时用户有心理预期:
text
我要进入一个新模式
关闭时则希望马上回到原浏览位置。
本轮:
text
closeCompareCost=36ms
恢复包括:
text
关 CompareSession
恢复 ProductList | ProductDetail
恢复 selected product_1047
恢复 scroll=960vp
我把 36ms 单独记下来。
如果以后虚拟容器里加入更多卡片,关闭时间持续变长,就能从版本基线里看出来。
九、DevEco 图必须同时看到"容器状态"和"主路由快照"
开发图:

本轮 HiLog:
text
taskId=parallel_20261002_04
openCompare
container=compare_vc_01
ratio=50:50
primary=
product_1047
compare=
product_1051
main routes preserved:
ProductList | ProductDetail
leftScrollOffset=960vp
closeCompare
cost=36ms
restore selected=
product_1047
status=
VIRTUAL_READY
如果比价页正常,但关闭后回到了 ProductList 首页,那虚拟容器本身可能没问题,错的是主路由快照。
所以调试一定要同时看两层。
十、最终运行图:两个详情并行,但主购物流没有被改写
运行图:

当前比较:
text
MateBook Air
product_1047
VS
MatePad Pro
product_1051
容器:
text
compare_vc_01
50:50
主上下文仍然是:
text
ProductList | ProductDetail
selected=product_1047
category=电脑办公
scroll=960vp
关闭比价以后:
text
ProductList | ProductDetail
继续浏览。
这就是虚拟容器对 DualCart 最有价值的地方:临时并行任务不污染主任务路由。
十一、为什么这一篇不直接做 1:2 / 2:1 的所有组合
官方当前已经明确 HarmonyOS 7 平行视界扩展了更多分栏比例。
但 DualCart 没有把这一篇写成"比例大全"。
比例的意义不是配置越多越高级,而是:
text
内容有没有主次
两侧信息密度是否相当
是否需要突出视觉内容
当前双参数详情最适合 50:50。
未来如果:
text
左边 3D 商品展示
右边规格参数
才更适合测试 2:1。
把比例和场景绑起来,比为了演示功能不停切比例更像真实产品。
十二、下一篇会进入"平行世界里的应用内分屏"
到 04 为止,DualCart 解决的仍然是:
text
一个应用
一个主要购物任务
内部双栏和临时虚拟容器
05 会进入官方示例里的最后一类大场景:
text
平行世界下触发应用内分屏
这时候会出现两个更复杂的问题:
text
主购物任务
+
辅助购物任务
多个窗口 / 容器
+
共享商品状态
也就是说,05 不再只是 route replace,而是要处理任务级状态同步、窗口关系和关闭某一侧以后另一侧怎么继续。
十三、虚拟容器里的数据请求也要避免重复加载两份相同基础信息
比价一打开,最容易写成:
text
左详情请求一次 product_1047
右详情请求一次 product_1051
看起来没问题。
但两个 Detail 组件还会分别请求:
text
公共规格字典
品牌信息
配送规则
优惠规则
如果这些数据每个 Pane 都重新加载一遍,比价模式会比普通详情明显更重。
所以我把数据拆成两层:
text
ProductRepository
→ 商品主体数据,可缓存
CompareDetailState
→ 每个 Pane 独立的 UI 状态
共享的是只读业务数据,独立的是:
text
activeTab
galleryIndex
quantity
展开/收起状态
这样能避免"为了状态隔离,把所有网络请求也重复一遍"。
十四、比价商品失效时,容器必须能降级,而不是整个页面报错
还有一个真实场景:
用户选择:
text
product_1047
product_1051
进入比价前,第二件商品刚好下架。
如果虚拟容器必须等两个 Detail 都成功,任何一个失败都会让整个 Compare 页面空白。
现在的降级规则是:
text
primaryProduct 必须存在
compareProduct 不存在
→ 保留 primary
→ 右侧显示"商品已不可用"
→ 允许重新选择
只有主商品本身失效,才直接关闭比价并回到 ProductList。
这种处理很重要,因为"临时并行任务"不应该破坏主购物流。
即使对比失败,用户仍然应该回到原来的 MateBook Air 详情继续浏览。
十五、分栏比例切换时,两个 Detail 的内部状态不能跟着重置
第四篇提到 50:50、1:2、2:1 只是展示策略。
真正测试时我主动把比例做了来回切换:
text
50:50
→ 2:1
→ 1:2
→ 50:50
要求:
text
primary activeTab 不变
compare activeTab 不变
两边 galleryIndex 不变
quantity 不变
如果比例变化导致 Detail 组件被完整销毁重建,用户正在看的规格位置会跳。
所以 CompareDetailState 的生命周期高于具体布局组件。
布局只拿状态重新渲染,不重新初始化状态。
这一点和前面平行视界的 selectedProduct 连续性是同一种思路:结构变化不能顺手清业务状态。
十六、关闭比价时,我没有直接 pop 容器页面
关闭按钮最开始就是:
text
pop()
问题是 pop 只处理可见路由,不知道主上下文快照是否已经恢复。
最后关闭顺序改成:
text
冻结 CompareSession
→ 保存必要的 compare history
→ 关闭系统虚拟容器适配层
→ restore mainSnapshot
→ restore selected product
→ restore left scroll
→ 清 CompareSession
真正的 closeCompareCost=36ms 从"点击关闭"开始,到:
text
ProductList | ProductDetail
product_1047
scroll=960vp
全部恢复完成为止。
这比统计一个容器动画结束时间更有业务意义。
十七、04 最后做了五轮循环,专门看主路由有没有被污染
比价不是一次性页面,所以我最后做了:
text
打开比价
→ 关闭
连续五轮。
每一轮选不同的 compareProduct,但主商品一直保持:
text
product_1047
MateBook Air
结束后检查:
text
mainRouteDepth 不增长
selectedProduct 仍为 product_1047
leftScrollOffset 仍为 960vp
category 仍为 电脑办公
CompareSession 已关闭
第一版在第三轮以后,主路径里悄悄多了一个 ProductDetail。
原因就是某次对比商品点击仍然走了普通 rightPathStack。
把所有对比入口收口到 VirtualCompareCoordinator 后,这个问题才消失。
十八、虚拟容器真正的价值,是隔离"临时任务"和"主任务"
做完这一篇以后,我对虚拟容器的理解更明确了。
它不是"可以同时显示两个页面"这么简单。
对于 DualCart 来说,它提供的是一个很清楚的业务边界:
text
主购物流:
找商品
看详情
加入购物车
临时任务:
对比两件商品
临时任务可以有自己的:
text
两个 Detail
比例策略
UI 状态
错误降级
但关闭以后,主购物流完全不需要知道里面发生过多少次切换。
这种隔离能力比"多一个分栏效果"更有工程价值。
也正因为主任务和临时任务开始清楚分层,下一篇进入应用内分屏时,才有基础继续讨论:
text
两个真正并行的购物任务
如何共享商品数据
又如何避免互相覆盖状态
十九、比价入口也要做幂等,避免重复创建同一个容器
比价按钮连续点击两次时,早期版本会创建两个相同的 compare session。
页面看起来只有一层,但关闭一次以后,后台状态还残留一份。
现在 openCompare() 会先检查:
text
containerId
primaryProductId
compareProductId
active
如果同一个 compare_vc_01 已经 ACTIVE,就直接返回当前 Session,不再重复初始化。
如果用户换了 compareProduct,则只更新对比商品,不重新创建主容器。
这条保护让 5 轮循环测试稳定很多,也避免后面做应用内分屏时出现"一个临时任务被创建两份"的隐蔽问题。
二十、商品比价关闭后,历史可以保留,但活跃状态必须清空
我还保留一份轻量历史:
text
lastCompare:
product_1047 vs product_1051
lastRatio:
50:50
它用于下次快速重新进入比价,不参与当前主路由。
真正活跃的:
text
containerId
activeTab
galleryIndex
quantity
关闭后必须清空。
这样"历史记录"和"活跃容器状态"不会混在一起。后面即使用户从收藏页重新发起比价,也不会继承上一轮已经无效的 UI 对象。
参考资料
- HarmonyOS 官方示例:平行视界:https://developer.huawei.com/consumer/cn/samples/
- 2026 年 6 月开发者月刊:平行视界新增分栏比例:https://developer.huawei.com/consumer/cn/monthly/202606
- HarmonyOS 多设备适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
- Navigation 分栏开发:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode