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
Logo

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

更多推荐