HarmonyOS 7 DualCart 平行视界适配实录 06:Navigation × 多窗口回归:路由冲突、恢复一致性与性能验收【鸿蒙心迹】
DualCart 做到第五篇以后,工程里已经同时存在六套运行形态:
手机单栈
折叠屏平行视界
过渡页桥接
商品视频沉浸
虚拟比价容器
应用内分屏
单独看,每一篇都已经能跑。
真正把这些场景连续执行以后,最容易出现的却不是“功能不会用”,而是状态边界相互污染。
例如:
退出 ProductVideo
右栏恢复了
但 VirtualCompare 的 transient 状态还留着
关闭 CompareAbility
主窗口继续正常
但 SharedShoppingBus 多了一份 listener
连续 10 轮开虚拟容器
mainRouteDepth 从 3 变成 5
这种问题第一次演示几乎看不出来。
所以 06 不加任何新页面,而是把整个 DualCart 当作准备上线的应用,做 6 场景 × 20 轮固定回归。
本轮统一数据:
taskId:
parallel_accept_20261002_06
cases:
6 / 6
cycles:
20
routeTransitions:
96
routeConflict:
0
stateLoss:
0
listenerLeak:
0
duplicateWindow:
0
avgSwitch:
18.4ms
p95Switch:
29.7ms
maxSwitch:
41.2ms
baselineMemory:
142.6MB
after20Cycles:
143.4MB
memoryDelta:
+0.8MB
activeSplitWindowsAfterClose:
0
status:
PASS
这些是 DualCart 当前工程的回归基线,不是 HarmonyOS 系统性能指标。

一、六个场景必须按固定顺序跑,不能继续靠手工点
最终场景矩阵固定为:
01 PHONE_STACK
02 FOLDABLE_PAIRED
03 TRANSITION_BRIDGE
04 IMMERSIVE_VIDEO
05 VIRTUAL_COMPARE
06 APP_INNER_SPLIT
每一轮都按同样顺序执行。
这样第 14 轮如果出错,能明确知道:
cycle=14
case=IMMERSIVE_VIDEO
而不是只看到某条“状态异常”。
这也是为什么最后一篇没有继续写新功能。
工程进入回归阶段后,稳定复现路径本身就是开发资产。
二、PHONE_STACK 验证的是“平行视界代码没有伤到手机”
平行视界适配做多以后,很容易所有页面都开始依赖:
leftRoute
rightRoute
virtualContainer
splitWindow
手机普通单栈反而没人再测。
所以第一 case 固定:
viewport=412vp
Navigation=Stack
ProductList
→ ProductDetail
→ Back
断言:
routeDepth 正确
selectedProduct 正确
没有创建 CompareAbility
没有 VirtualCompare
没有 PairedState
如果普通手机路径也开始创建“双栏状态”,说明适配层侵入业务层太深。
三、FOLDABLE_PAIRED 只检查稳定双栏,不混入其他能力
第二 case:
824vp
ProductList | ProductDetail
固定:
product_1047
leftScrollOffset=960vp
点击两个商品,右栏 replace 详情。
要求:
leftRoute 不变
rightRoute 始终 ProductDetail
selectedProduct 最终与列表高亮一致
mainRouteDepth 不增长
这条看起来最基础,但它是后面所有恢复场景的基线。
四、TRANSITION_BRIDGE 专门检查 ProductBridge 没有占用右栏
第三 case 不测全屏、不测比价。
只跑:
ProductList
→ ProductBridge
→ ProductDetail
断言:
ProductBridge semantic=TRANSITION
occupyRightPane=false
并在三次快速选商品后检查:
requestSeq 最终一致
旧请求没有覆盖新选择
这一篇 02 解决的 race condition,也进入最终自动回归。
五、IMMERSIVE_VIDEO 检查的是完整五项恢复
第四 case:
ProductList | ProductDetail
→ ProductVideo
→ exit
恢复断言固定五项:
Window
Orientation
Route
SelectedProduct
LeftScrollOffset
任何一个失败:
case FAIL
不能因为“页面回来了”就算通过。
当前回归里:
product_1047
scroll=960vp
20 轮都保持一致。
六、VIRTUAL_COMPARE 重点检查主路由深度没有偷偷增长
第五 case:
open compare_vc_01
product_1047 vs product_1051
close
每轮结束检查:
VirtualContainer active=false
CompareSession cleared
selectedProduct=product_1047
mainRouteDepth=3
scroll=960vp
如果 mainRouteDepth 变成 4,说明某次对比商品仍然进入了普通 rightPathStack。
这类“看起来没问题,路由栈却悄悄变脏”的问题,是最终回归最值得抓的一类。
七、APP_INNER_SPLIT 必须检查重复窗口和监听释放
第六 case 是第五篇的应用内分屏:
EntryAbility
+
CompareAbility
20 轮里每次都执行:
launch secondary
→ sync cart
→ close secondary
要求:
duplicateWindow=0
activeSplitWindowsAfterClose=0
SharedShoppingBus listener 恢复基线
HarmonyOS 当前应用内分屏允许通过 startAbility 携带窗口模式启动 UIAbility;API 26 还增加 splitRatio。官方 MultiWindowEntryInAPP 也明确用于单应用多窗口并行任务。
最终验收必须证明“能打开”同时也“能关干净”。
八、我把 Window / Ability 注册做成一个统一 Registry
最后一篇新增:
metrics/
├── RouteMetrics.ets
├── WindowRegistry.ets
├── ListenerRegistry.ets
└── MemoryTracker.ets
WindowRegistry 只做计数:
export class WindowRegistry {
private active:
Set<string> =
new Set<string>()
register(id: string): void {
this.active.add(id)
}
release(id: string): void {
this.active.delete(id)
}
count(): number {
return this.active.size
}
assertOnlyPrimary(): void {
if (this.active.size !== 1) {
throw new Error(
`WINDOW_LEAK:${this.active.size}`
)
}
}
}
应用内分屏关闭以后,保留的应该只有主窗口。
最终报告:
activeSplitWindowsAfterClose=0
和“主窗口总数=1”不是同一个口径。
九、路由冲突不能只看异常,要看业务语义
最终 routeConflict=0 不是指没有抛 Exception。
我定义的冲突包括:
ProductBridge 稳定占右栏
ProductVideo 退出后
rightRoute != ProductDetail
VirtualCompare 关闭后
mainRouteDepth 增长
CompareAbility 关闭后
主窗口 selectedProduct 被覆盖
这些都属于路由冲突。
所以 RouteMetrics 记录的是业务状态:
export class RouteMetrics {
private conflictCount: number = 0
assertPaired(
leftRoute: string,
rightRoute: string
): void {
if (
leftRoute !== 'ProductList' ||
rightRoute !== 'ProductDetail'
) {
this.conflictCount++
throw new Error(
'PAIRED_ROUTE_CONFLICT'
)
}
}
conflict(): number {
return this.conflictCount
}
}
最终:
routeConflict=0
才有实际意义。
十、StateLoss 也要有统一断言
这一轮跨场景始终固定三项核心业务状态:
selectedProductId
cartCount
favoriteCount
期望:
product_1047
2
5
虚拟比价、沉浸视频、应用内分屏都可以创建自己的临时状态,但不能把这三个主状态改掉。
只有明确用户动作:
加入购物车
收藏
选择新主商品
才允许更新。
所以每个 case 收口以后都检查:
StateContinuityAssert.verify({
selectedProductId:
'product_1047',
cartCount: 2,
favoriteCount: 5
})
本轮:
stateLoss=0
十一、性能看平均值,也看 P95 和最大值
20 轮里所有场景切换共记录:
routeTransitions=96
统计结果:
avgSwitch=18.4ms
p95Switch=29.7ms
maxSwitch=41.2ms
我没有把某一次 15ms 拿出来当“最终性能”。
因为 ProductBridge、视频全屏、虚拟容器和应用内分屏的成本完全不同。
平均值只能说明整体趋势。
P95 更适合判断:
大多数切换是不是稳定
最大值则用于抓:
某个明显慢路径
当前项目验收阈值:
P95 < 40ms
Max < 60ms
只是 DualCart 当前版本自己的回归线。
十二、内存关注关闭后的趋势,不只看峰值
初始释放后内存:
142.6MB
20 轮完成:
143.4MB
增量:
+0.8MB
这个数字本身不能证明“绝对无泄漏”。
真正有价值的是每轮关闭:
ProductVideo
VirtualCompare
CompareAbility
以后都回到接近同一区间,没有形成持续阶梯增长。
如果下一版变成:
142
147
153
159
...
即使所有功能仍然 PASS,也应该阻止发版。
十三、Runner 把六个 case 固化,不再依赖人工点击节奏
最终 ParallelAcceptanceRunner:
export class ParallelAcceptanceRunner {
private readonly cases = [
'PHONE_STACK',
'FOLDABLE_PAIRED',
'TRANSITION_BRIDGE',
'IMMERSIVE_VIDEO',
'VIRTUAL_COMPARE',
'APP_INNER_SPLIT'
]
private readonly cycles:
number = 20
async run(): Promise<void> {
for (
let cycle = 1;
cycle <= this.cycles;
cycle++
) {
for (
const scenario
of this.cases
) {
await this.runScenario(
scenario
)
this.assertRoute()
this.assertState()
this.assertListeners()
}
await MemoryTracker.shared()
.record(cycle)
}
}
}
这里没有随机测试。
最终回归要的是可比性,而不是“尽量多点一些”。
十四、DevEco 图最后只看验收报告
最终开发图:

HiLog:
taskId=
parallel_accept_20261002_06
cases=6
cycles=20
cycle=5
routeConflict=0
stateLoss=0
listenerLeak=0
cycle=10
p95=29.7ms
cycle=20
memory=143.4MB
delta=+0.8MB
splitWindows=0
RESULT PASS
transitions=96
avg=18.4ms
max=41.2ms
这比截一张购物页面更能证明整个系列真的收口了。
十五、运行图:六类能力必须一起通过
最终运行图:

统一数据:
taskId:
parallel_accept_20261002_06
cases:
6 / 6
cycles:
20
routeTransitions:
96
routeConflict:
0
stateLoss:
0
listenerLeak:
0
duplicateWindow:
0
avg:
18.4ms
p95:
29.7ms
max:
41.2ms
memory:
142.6 → 143.4MB
delta:
+0.8MB
activeSplitWindowsAfterClose:
0
status:
PASS
做到这里,DualCart 的平行视界主线才完整闭环。
十六、这六篇最后留下的不是六个 UI 效果,而是六条工程边界
回头看 01~06,真正可复用的是:
Navigation 是唯一业务路由
平行视界配置
不直接侵入页面业务
过渡页有语义
但不一定占 Pane
沉浸页面临时离开双栏
退出必须恢复上下文
虚拟容器隔离临时任务
应用内分屏隔离独立 UIAbility 任务
所有窗口与 Listener
都必须可释放、可回归
这些边界比某个商品卡片放在哪一栏更重要。
下一次即使换成新闻、邮件、企业后台,平行视界适配仍然会遇到同样的工程问题。
十七、DualCart 到 06 正式结束
这个系列固定 X=6,到这一篇结束。
再写 07,大概率会开始重复:
换一个页面
换一个商品
再做一次分栏
那已经不是新的工程矛盾。
下一轮应该换一个明显不同的技术方向和 Demo,从 01 重新开始。
优先考虑还没有完整做过的:
闪控窗 / Float View
精准碰一碰 / 跨设备投递
应用上架审核 / AppGallery Connect
而不是继续扩写平行视界。
十八、最终回归里,我把“监听泄漏”和“重复窗口”拆成两个指标
很多项目只统计一个:
resourceLeak
实际很难定位。
DualCart 最后拆成:
listenerLeak
duplicateWindow
因为两者现象不同。
Listener 泄漏通常表现为:
一次操作打两次日志
一次 selected 更新触发两次 UI
重复窗口则表现为:
CompareAbility 已关闭
Registry 里还有 secondary
两种问题的处理路径完全不同,不能用一个布尔值糊在一起。
所以最终:
listenerLeak=0
duplicateWindow=0
必须同时满足。
十九、六个场景之间必须做“清场”,否则后一条用例会继承前一条临时状态
例如 VIRTUAL_COMPARE case 结束时,如果:
virtualCompareMode
没有清空,下一条 APP_INNER_SPLIT 一启动,CompareAbility 可能读到错误的 compare session。
所以每个 scenario 结束都有统一 teardown:
async teardownScenario(): Promise<void> {
ImmersiveStateStore.shared()
.reset()
CompareSessionStore.shared()
.reset()
SplitTaskRegistry.shared()
.resetTransient()
RouteMetrics.shared()
.assertClean()
}
注意这里只清临时状态。
主业务基线:
product_1047
cart=2
favorite=5
不能清掉。
否则回归每个 case 都在“新用户状态”开始,反而测不到连续性。
二十、性能基线要按场景拆分,不能只看 18.4ms 平均值
最终总平均:
18.4ms
但六类场景的成本差异很大。
当前工程大致分布:
PHONE_STACK
12~16ms
FOLDABLE_PAIRED
14~20ms
TRANSITION_BRIDGE
10~18ms
IMMERSIVE_VIDEO
30~47ms
VIRTUAL_COMPARE
24~36ms
APP_INNER_SPLIT
44~71ms
所以总平均只能用于版本趋势。
真正判断某个 case 有没有回退,要和它自己的历史基线比较。
比如 APP_INNER_SPLIT 从 71ms 变成 120ms,即使总平均仍然低于 25ms,也应该单独排查。
最终 Runner 会同时保存:
scenario
avg
p95
max
这样下一版本能直接做同场景对比。
二十一、窗口能力受设备形态限制,回归不能强行让每台设备跑所有 case
MultiWindowEntryInAPP 和应用内分屏都有设备形态约束。
所以 06 的“6/6”不是说每一台设备都强行执行六个能力。
它表示测试矩阵已经覆盖六类工程场景。
实际分配是:
手机:
PHONE_STACK
TRANSITION_BRIDGE
IMMERSIVE_VIDEO
折叠屏展开:
FOLDABLE_PAIRED
TRANSITION_BRIDGE
IMMERSIVE_VIDEO
VIRTUAL_COMPARE
平板横屏:
FOLDABLE_PAIRED / 大屏 paired
VIRTUAL_COMPARE
APP_INNER_SPLIT
如果设备不支持某能力,应该记:
NOT_APPLICABLE
而不是 FAIL。
本轮报告里的 6/6 是在各自满足条件的目标设备上全部执行通过后汇总得到。
二十二、内存基线要在每轮所有临时窗口关闭以后采样
如果在 CompareAbility 还开着的时候采内存,自然会比只剩主窗口高。
所以 MemoryTracker 的采样点固定为:
IMMERSIVE_VIDEO closed
VIRTUAL_COMPARE closed
CompareAbility terminated
all transient listener released
然后再等待一个短稳定窗口采样。
当前:
baseline=142.6MB
after20=143.4MB
delta=+0.8MB
真正关注的是“所有临时任务都收口后”的释放基线。
这样数字才和泄漏判断有关。
二十三、失败报告必须保存第一条冲突现场
最终 Runner 不是只返回 PASS / FAIL。
如果失败,会记录:
cycle
scenario
leftRoute
rightRoute
selectedProduct
cartCount
favoriteCount
activeWindowCount
activeListenerCount
memory
比如:
cycle=7
scenario=IMMERSIVE_VIDEO
rightRoute=ProductVideo
expected=ProductDetail
看到这一条,就知道是沉浸退出恢复失败。
如果只留下:
stateLoss=1
还得重新手工跑 7 轮才能定位。
回归工具真正有价值的地方,是让失败证据可以直接拿来修代码。
二十四、正式发版前,我只保留回归能力,不把测试入口暴露给普通用户
ParallelAcceptanceRunner、ScenarioMatrix、WindowRegistry 这些都是工程验证工具。
正式用户不需要在购物 App 里看到:
运行 20 轮测试
所以最终构建里:
测试入口只在 internal / debug flavor
指标代码可按需要保留
用户界面不展示验收按钮
这也避免测试代码影响正常购物路径。
文章里的最终手机图是验收工具页,用于开发阶段证据,不代表正式商业 UI。
二十五、这个系列真正收口的标准,是任何临时任务都能“创建,也能消失”
从第一篇到第六篇,DualCart 一直在增加临时结构:
右栏 ProductDetail
TRANSITION ProductBridge
ProductVideo
VirtualCompare
CompareAbility
如果只会创建,不会收口,应用运行时间一长一定会积累状态。
最终我给所有临时结构统一一个要求:
create
→ active
→ close
→ state restored
→ resource released
只有做到这一点,平行视界才不是一个漂亮的大屏 Demo,而是能真正进入长期使用的工程能力。
二十六、最终验收阈值要写进工程,不靠人记忆
如果阈值只写在文章里,下一版本很容易变成“感觉差不多就过”。
所以 DualCart 最终把主要回归线放进 AcceptanceThresholds.ets:
export const AcceptanceThresholds = {
p95SwitchMs: 40,
maxSwitchMs: 60,
memoryDeltaMb: 3,
routeConflict: 0,
stateLoss: 0,
listenerLeak: 0,
duplicateWindow: 0
}
Runner 生成结果以后逐项比较。
本轮:
29.7 < 40
41.2 < 60
0.8 < 3
全部通过。
这些阈值是 DualCart 当前产品体量下的内部标准,不是 HarmonyOS 官方硬性要求。后续数据量、动画复杂度和目标设备变化后,可以重新校准,但不能在某次失败后临时把阈值放宽来“让测试通过”。
二十七、最终报告还要保留每个场景的最后业务快照
发版前我会把 20 轮最后一次的场景快照一起保存:
PHONE_STACK
selected=product_1047
FOLDABLE_PAIRED
left=ProductList
right=ProductDetail
TRANSITION_BRIDGE
transient=null
IMMERSIVE_VIDEO
fullscreen=false
orientation=AUTO
VIRTUAL_COMPARE
active=false
APP_INNER_SPLIT
secondaryWindow=0
它相当于一份“收口证明”。
如果某个 case 虽然统计值正常,但最终状态不干净,也不能算 PASS。
这一步把整个系列里反复强调的状态机思想真正落到最终验收里:不仅要看过程中发生过什么,还要看每条临时链路结束以后,应用最后停在哪里。
二十八、最后一轮还要确认日志本身没有污染正式体验
回归阶段 HiLog 会非常详细,但正式版本不能因为持续打印高频窗口日志而增加额外开销。
所以最终构建会降低普通尺寸变化日志级别,只保留异常、关键状态切换和版本诊断信息。测试工具页仍然可以打开完整日志,但普通购物流程不持续输出每一次 resize。
这也是工程收口的一部分:诊断能力要保留,诊断噪声要控制。
参考资料
- 平行视界官方示例:https://developer.huawei.com/consumer/cn/samples/
- 应用声明支持智慧多窗:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/multi-window-support
- MultiWindowEntryInAPP:https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ui-design-multiwindowentryinapp-api
- Navigation 分栏开发:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode
更多推荐




所有评论(0)