InsightBoard 到第五篇已经完成了一条比较完整的多形态链路:

COMPACT 手机
→ MEDIUM 折叠屏展开
→ HOVER 半折叠
→ EXPANDED 平板 / PC
→ 自由窗口
→ WindowStage 销毁与恢复

最后一篇我没有再加任何组件。

因为多形态适配最危险的阶段,往往不是“第一遍切过去”,而是用户连续切 10 次、20 次以后。

我以前做响应式页面时就见过这种情况:第一次完全正常,来回拖窗口十几轮以后,日志一口气打出 3 个 windowSizeChange;折叠屏开合几次,selectedItem 偶尔回到第一条;HoverMode 退出后 Timer 没清掉,下一次进入还会收到旧回调。

这些都不是视觉稿能发现的问题。

所以 06 只做一件事:把 InsightBoard 当成一个已经准备发版的应用,用固定矩阵做 25 轮全形态回归。

本轮固定数据:

taskId: adapt_accept_20261002_06

deviceCases:
5 / 5

cycles:
25

layoutTransitions:
80

duplicateLayoutApply:
0

stateLoss:
0

listenerLeak:
0

avgLayoutApply:
14.8ms

p95LayoutApply:
22.6ms

maxLayoutApply:
31.0ms

baselineMemory:
128.4MB

after25Cycles:
129.1MB

memoryDelta:
+0.7MB

activeWindowListeners:
1

activeFoldListeners:
1

activeTimers:
0

releaseListenersAfterDestroy:
0

status:
PASS

这些数据是 InsightBoard 当前测试工程的回归基线,不是 HarmonyOS 的系统指标。

一、最终验收不是“设备都能打开”

如果只看功能,五个场景都已经能用了。

但我把最终验收拆成六组:

Layout
State
Event
Lifecycle
Performance
Memory

每一组都有失败条件。

例如:

布局看起来对
但 layoutApply 一次窗口变化触发两遍
→ FAIL

页面没有闪退
但 selectedItem 被重置
→ FAIL

25 轮以后内存持续阶梯上涨
→ FAIL

这样 PASS 才不是“肉眼没发现问题”。

二、回归矩阵固定五种场景,不再随机点

这一轮的 5 个 case 固定为:

1. Phone COMPACT

412vp
Bottom / Stack

验证单栏、底部导航、详情 push 和 selectedItem 保持。

2. Foldable MEDIUM

824vp
Split

验证 foldStatus + windowSize 最终只 Apply 一次,insight_042 继续显示。

3. Foldable HOVER

392vp + 36vp + 487vp

验证折痕安全区、Primary / Secondary,以及退出 Hover 后 Timer 清理。

4. Tablet EXPANDED

1024vp
三栏

验证大屏信息密度和导航结构。

5. PC / Free Window

1180vp
→ 780vp
→ 520vp
→ close
→ reopen

验证 RAIL / SPLIT / STACK,以及 WindowStage 恢复。

每一轮必须按同样顺序跑。

如果随机拖窗口,很难判断第 18 次出现的错误到底对应哪条路径。

三、我给所有系统监听做了统一计数

前面几期已经分别注册过:

windowSizeChange
foldStatusChange
hoverStatus

开发时每个 Manager 自己 on/off 很容易漏。

最终我加 SubscriptionRegistry:

export class SubscriptionRegistry {
  private windowCount: number = 0
  private foldCount: number = 0
  private timerCount: number = 0

  onWindowBind(): void {
    this.windowCount++
  }

  onWindowRelease(): void {
    this.windowCount--
  }

  onFoldBind(): void {
    this.foldCount++
  }

  onFoldRelease(): void {
    this.foldCount--
  }

  onTimerCreate(): void {
    this.timerCount++
  }

  onTimerRelease(): void {
    this.timerCount--
  }

  snapshot() {
    return {
      window: this.windowCount,
      fold: this.foldCount,
      timer: this.timerCount
    }
  }
}

这不是为了替代 off(),只是让最终验收有证据。

正常运行时:

activeWindowListeners=1
activeFoldListeners=1
activeTimers=0

WindowStage Destroy 以后:

0
0
0

如果 Window listener 变成 2,就不用等用户说“拖动时有点抖”才发现。

四、Layout Apply 也要有自己的去重统计

02 做事件合并时已经有 layoutApply=1。

最后一篇把这个指标提升成全局回归项。

每次 AdaptiveSnapshot 真正发生模式变化才记一次:

export class LayoutApplyMetrics {
  private applyCount: number = 0
  private duplicateCount: number = 0
  private lastSignature: string = ''

  record(snapshot: AdaptiveSnapshot): void {
    const signature =
      `${snapshot.mode}:` +
      `${snapshot.widthVp}:` +
      `${snapshot.foldStatus}:` +
      `${snapshot.hoverMode}`

    if (signature === this.lastSignature) {
      this.duplicateCount++
      return
    }

    this.lastSignature = signature
    this.applyCount++
  }

  duplicate(): number {
    return this.duplicateCount
  }
}

这一轮:

layoutTransitions=80
duplicateLayoutApply=0

这里的 80 是 25 轮测试中真正需要发生的布局转换次数。

不是所有 windowSizeChange 都应该产生 Layout Apply。用户拖窗口时宽度可能变化很多次,但只要还在同一个 Mode、关键布局条件也没变化,就没必要重建页面结构。

五、状态丢失要定义成自动断言

以前我靠肉眼看 insight_042 还在不在。

06 改成每个 case 结束自动断言:

export interface ExpectedState {
  selectedItemId: string
  filter: string
  routeTop: string
}

export class StateContinuityAssert {
  verify(
    current: BoardRestoreState,
    expected: ExpectedState
  ): boolean {
    return (
      current.selectedItemId ===
        expected.selectedItemId &&
      current.filter ===
        expected.filter &&
      current.routeTop ===
        expected.routeTop
    )
  }
}

统一期望:

selectedItem=insight_042
filter=全部
routeTop=Detail

25 轮结束:

stateLoss=0

这样“状态连续性”终于不是文章里的形容词,而是一个可以失败的测试项。

六、布局耗时看平均值还不够,P95 更有意义

窗口重排不是每次都一样快。

第一次创建 EXPANDED 三栏需要创建更多组件,单纯看平均值会把偶发慢帧藏起来。

最终数据:

avg=14.8ms
p95=22.6ms
max=31.0ms

我没有把 16ms 当成绝对红线。

布局重排不是每帧都在执行,而且设备与数据量都会影响结果。当前项目自己的验收规则是:

P95 < 30ms
Max < 50ms

这样更适合作为版本回归基线。

下一版如果 P95 从 22.6ms 变成 45ms,就算肉眼还没明显卡,也应该回头查哪次改动让页面重排变重了。

七、List / Grid 数量大以后,不能用“全量重建”掩盖适配问题

InsightBoard 当前列表只有几十条,性能还比较轻。

API 26 已经提供 LazyDynamicLayout / LazyLayoutAlgorithm 相关能力,用于懒加载和自定义布局;setAdjustedOffset() 还能在列数、间距变化时保持可视区域相对位置。

这一篇没有为了最终验收临时换成新容器。

但我给后续扩展留了明确判断:

如果 EXPANDED 下列表数据上百
而布局变化每次都重建大量子节点
→ 先优化 Lazy Layout

不要继续靠减少动画掩盖

这也是技术连载最后应该留下的边界,而不是硬塞一个新 API。

八、内存回归关注“释放后的基线”,不只看峰值

多形态页面的峰值会变化。

EXPANDED 三栏肯定比 COMPACT 多一些组件;HoverMode 也会切不同 Pane。

真正危险的是:

每切一轮
基线都多 2MB

本轮测试:

baseline:
128.4MB

after25Cycles:
129.1MB

delta:
+0.7MB

0.7MB 不代表完全没有缓存或系统波动,但至少没有形成明显的阶梯增长。

我同时检查 Window listener、Fold listener、Timer 和恢复临时对象,最后 WindowStage 销毁后必须全部回 0。

九、资源释放代码必须和创建代码在同一个 Manager 里

最终我把这一条写成工程规则:

谁 on
谁 off

谁 setTimeout
谁 clearTimeout

谁创建 Coordinator
谁 dispose

谁保存 Window
谁在 Stage destroy 清引用

不能 A 模块注册,B 页面顺手释放。

这会让 06 的验收简单很多。

例如 ViewportManager:

export class ViewportManager {
  private mainWindow?: window.Window
  private bound: boolean = false

  bind(mainWindow: window.Window): void {
    if (this.bound) {
      return
    }

    this.mainWindow = mainWindow
    this.mainWindow.on(
      'windowSizeChange',
      this.onWindowSizeChange
    )

    SubscriptionRegistry.shared()
      .onWindowBind()

    this.bound = true
  }

  dispose(): void {
    if (!this.bound ||
        this.mainWindow === undefined) {
      return
    }

    this.mainWindow.off(
      'windowSizeChange',
      this.onWindowSizeChange
    )

    SubscriptionRegistry.shared()
      .onWindowRelease()

    this.mainWindow = undefined
    this.bound = false
  }
}

幂等 bind / dispose 是最终版本必须具备的能力。

十、测试 Runner 不能依赖人工操作节奏

如果验收仍然靠我自己“展开一下、缩一下、再点一下”,每轮条件都不一样。

所以最后新增 AdaptiveAcceptanceRunner,把五种 case 固化:

export class AdaptiveAcceptanceRunner {
  private readonly cycles: number = 25

  async run(): Promise<void> {
    for (
      let cycle = 1;
      cycle <= this.cycles;
      cycle++
    ) {
      await this.runCase(
        DeviceCase.PHONE_COMPACT
      )
      await this.runCase(
        DeviceCase.FOLDABLE_MEDIUM
      )
      await this.runCase(
        DeviceCase.FOLDABLE_HOVER
      )
      await this.runCase(
        DeviceCase.TABLET_EXPANDED
      )
      await this.runCase(
        DeviceCase.PC_FREE_WINDOW
      )

      this.assertState()
      this.assertSubscriptions()
      this.recordMemory(cycle)
    }
  }
}

这个 Runner 不属于正式用户功能,而是项目里的回归工具。

它让每个版本都有相同的“跑法”。

十一、25 轮里我故意插入了 5 次 WindowStage 重建

只做尺寸变化还不够。

第五期已经证明 WindowStage 重建是独立场景,所以最终 25 轮里,每 5 轮主动加入一次:

save snapshot
→ destroy stage
→ recreate
→ restore
→ continue matrix

五次都要求:

selectedItem=insight_042
filter=全部
scrollOffset 可恢复
routeTop=Detail

这也是 stateLoss=0 真正有价值的地方。

不是只在同一个页面树里跑了 25 次。

十二、失败时必须把第一条异常停住,不继续刷屏

自动回归一旦失败,如果继续跑后面 20 轮,HiLog 很快被淹没。

所以 Runner 的失败策略是:

发现 duplicateApply
→ 立即停止

发现 stateLoss
→ 立即停止

发现 listenerLeak
→ 立即停止

同时保留:

cycle
deviceCase
viewport
foldStatus
selectedItem
activeListeners

这样回头看第一条失败证据,比最后面对几百行重复错误更有效。

本轮没有触发停止条件。

十三、DevEco 图只保留验收矩阵

最后一张开发图不再展示某个具体布局。

它只展示:

5 device cases
25 cycles

layoutTransitions=80
duplicateApply=0
stateLoss=0
listenerLeak=0

avg=14.8ms
p95=22.6ms
max=31.0ms

memoryDelta=+0.7MB

result=PASS

HiLog:

case PHONE_COMPACT PASS
case FOLDABLE_MEDIUM PASS
case FOLDABLE_HOVER PASS
case TABLET_EXPANDED PASS
case PC_FREE_WINDOW PASS

cycle 10
stateLoss=0
listenerLeak=0

cycle 20
memory=128.9MB

cycle 25
memory=129.1MB
delta=+0.7MB

RESULT PASS

十四、最终运行图展示的是“这套适配真的跑完了”

最终结果:

统一数据:

taskId:
adapt_accept_20261002_06

cases:
5 / 5

cycles:
25

layoutTransitions:
80

duplicateApply:
0

stateLoss:
0

listenerLeak:
0

avg:
14.8ms

p95:
22.6ms

max:
31.0ms

memory:
128.4 → 129.1MB

delta:
+0.7MB

status:
PASS

到这里,InsightBoard 的 6 篇主线完整闭环。

从 01 的“窗口宽度决定布局”,一直走到 06 的“多设备回归和资源释放”,中间没有换 Demo,也没有靠重复介绍断点凑篇数。

最终项目留下的是这些边界:

Viewport 决定布局
FoldStatus 描述设备形态
HoverMode 是独立交互场景
大屏增加信息密度
WindowStage 重建恢复业务上下文
系统监听必须成对释放
适配结果必须能回归验收

这个系列在 06 正式结束。

下一轮如果继续多形态,只会开始重复 Navigation、Window 和状态保持。更合理的做法是换成明显不同的技术方向与 Demo,从新的 01 开始。

十五、回归环境也要固定,不然性能数字没有可比性

这一轮我把测试设备、系统版本、数据集和窗口初始状态都写进验收记录。

原因很简单:同样是 P95=22.6ms,如果上一次用 24 条数据,这一次用 240 条数据,两次结果不能直接比较。

当前回归环境固定:

HarmonyOS 7 / API 26
Insight 数据 48 条
默认 selectedItem=insight_042
默认 filter=全部
动画配置保持一致
窗口缩放路径保持一致

每个 case 都从干净状态开始,不复用上一个 case 的临时 Timer 和动画对象。

尤其是 HoverMode。它退出以后如果还留着上一轮 scheduleApply(),下一轮折叠屏测试就会收到一条“幽灵布局 Apply”,最终把 duplicateApply 从 0 变成 1。

所以环境初始化本身也是验收的一部分。

十六、窗口拖动阶段,不应该给每一个像素变化都加完整过渡动画

做 PC 自由窗口测试时,我还发现一个视觉问题。

如果每一次 windowSizeChange 都带完整 300ms 动画,用户拖动窗口时动画不断被打断,反而会比没有动画更抖。

最终策略是:

同一 Mode 内连续 Resize
→ 关闭结构动画
→ 只更新尺寸

Mode 真正发生变化
COMPACT ↔ MEDIUM ↔ EXPANDED
→ 才执行一次短过渡

当前过渡时长控制在 120ms 左右,而且不把它计入 14.8ms 的 Layout Apply 计算。

我把“布局计算耗时”和“视觉动画时长”分开统计。

否则一个页面动画 120ms,很容易被误解成“布局本身花了 120ms”。

这类口径如果不拆开,性能报告看起来有数字,实际上没法指导优化。

十七、内存曲线必须看趋势,不能只看第一轮和最后一轮

128.4MB → 129.1MB 这两个端点看起来很稳定。

但如果中间曾经:

128
145
162
180
129

最后又因为系统回收掉下来,单看起点和终点也会错过问题。

所以 25 轮每轮都保留释放后内存值。

本轮趋势大致是:

cycle 1   128.5
cycle 5   128.7
cycle 10  128.8
cycle 15  128.8
cycle 20  128.9
cycle 25  129.1

没有持续阶梯上升。

这条曲线和峰值是两回事。EXPANDED 或 Hover 时内存临时上涨很正常,关键是离开场景后有没有稳定回落。

最终我把验收规则写成:

释放后基线持续上升
→ FAIL

单次峰值升高但可稳定回落
→ 继续观察,不直接判泄漏

这样对资源问题的判断更克制,也更接近真实工程。

十八、最终报告必须能反推到具体 case,而不是只有一个绿色 PASS

如果最终页面只显示:

PASS

出了问题还是不知道从哪里查。

所以 AcceptanceResult 同时保存每个 case 的摘要:

PHONE_COMPACT
apply=14
stateLoss=0
listenerLeak=0

FOLDABLE_MEDIUM
apply=16
coalesceOk=true

FOLDABLE_HOVER
hingeGap=36vp
timerReleased=true

TABLET_EXPANDED
visibleItems=9
nav=RAIL

PC_FREE_WINDOW
restoreCost=64ms
stageRecovery=PASS

最后的 PASS 是这些结果的聚合,不是替代。

如果下一版只有 PC_FREE_WINDOW 失败,日志能直接停在这个 case,不需要把手机、折叠屏重新全部查一遍。

这也是我对这个系列最后的一个要求:适配不仅要“跑起来”,还要“能证明自己跑对了”。

参考资料

  • HarmonyOS 多设备通用适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
  • 多窗口布局适配:https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V14/multi-window-layout-adapt-V14
  • API 26 LazyLayoutAlgorithm:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-arkui-lazylayoutalgorithm
  • HarmonyOS 7 / API 26 升级适配:https://developer.huawei.com/consumer/en/doc/harmonyos-releases/upgrade-adaptation
Logo

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

更多推荐