01 已经把 InsightBoard 的页面布局从“设备类型判断”改成了“窗口宽度断点”。

412vp 是 COMPACT,824vp 是 MEDIUM,>=840vp 进入 EXPANDED。折叠屏展开时,insight_042 也不会因为页面重排丢失。

真正接入折叠状态以后,我遇到的下一个问题很典型:

一次展开动作
foldStatusChange 触发一次
windowSizeChange 又触发一次
两个回调都在改 Navigation

单次操作看不出问题,快速开合几轮以后,偶尔会看到页面先变成双栏,紧接着又刷新一次;如果其中一个回调顺手重置了页面栈,详情还会短暂跳回第一条。

所以第二篇不再增加新的布局模式,而是只处理事件职责。

本轮固定数据:

taskId: adapt_20261002_02

fold:
FOLDED → EXPANDED

viewport:
412vp → 824vp

layout:
COMPACT → MEDIUM

navigation:
STACK → SPLIT

foldEventSeq: 17
windowEventSeq: 18
coalesce: 80 ms
layoutApply: 1
transitionCost: 42 ms

selectedItem:
insight_042

scrollOffset:
1280vp

status:
CONTINUOUS

一、FoldStatus 和 WindowSize 解决的根本不是同一个问题

我先把结论写在工程里:

FoldStatus
= 设备形态语义

WindowSize
= 当前可用空间

Layout
= 由 WindowSize 决定

Experience Policy
= 可以参考 FoldStatus

这四行把后面很多判断都理顺了。

官方折叠屏设计强调的是体验连续:设备可以随时折叠展开,内容布局要优化,同时应用运行状态要保持连续。

而 HarmonyOS 的多窗口适配又明确要求应用按窗口空间变化调整布局。

也就是说,折叠状态很重要,但它不能替代窗口尺寸。

同一个 EXPANDED 状态下,应用可能:

全屏
自由窗口
应用内分屏

真实 viewport 并不一定一样。

二、FoldStatus 回调只记形态,不直接切页面结构

InsightBoard 第二篇新增 FoldStateCoordinator.ets。

第一段代码解决的是“foldStatusChange 回调里直接重建 Navigation”的问题:

import { display } from '@kit.ArkUI'

export class FoldStateCoordinator {
  private foldEventSeq: number = 0
  private currentFoldStatus:
    display.FoldStatus =
      display.FoldStatus.FOLD_STATUS_UNKNOWN

  initFoldListener(): void {
    if (!display.isFoldable()) {
      return
    }

    this.currentFoldStatus =
      display.getFoldStatus()

    display.on(
      'foldStatusChange',
      this.onFoldStatusChange
    )
  }

  private onFoldStatusChange = (
    status: display.FoldStatus
  ): void => {
    if (status === this.currentFoldStatus) {
      return
    }

    this.currentFoldStatus = status
    this.foldEventSeq++

    InsightBoardStore.shared()
      .updateFoldStatus(status)

    this.scheduleApply()
  }
}

这里没有:

if EXPANDED → Split
if FOLDED → Stack

因为这会把“设备形态”和“实际空间”重新绑死。

FoldStatus 的主要作用是:

记录当前形态
提供悬停/折叠等语义
帮助后续选择体验策略
触发一次状态协调

实际布局还是等 WindowSize。

官方和 HarmonyOS 示例里都使用 display.getFoldStatus() 与 display.on('foldStatusChange') 感知折叠状态;官方案例也会先过滤同一个状态的重复事件,这一点我继续保留。

三、WindowSize 回调只更新 viewport,不负责清业务状态

第二个回调职责更窄:

import { window } from '@kit.ArkUI'

export class FoldStateCoordinator {
  private windowEventSeq: number = 0
  private pendingWidthVp: number = 412
  private pendingHeightVp: number = 915

  bindWindow(
    mainWindow: window.Window,
    px2vp: (px: number) => number
  ): void {
    mainWindow.on(
      'windowSizeChange',
      (size) => {
        this.windowEventSeq++

        this.pendingWidthVp =
          px2vp(size.width)

        this.pendingHeightVp =
          px2vp(size.height)

        this.scheduleApply()
      }
    )
  }
}

这里也不立即切布局。

它只是更新:

pendingWidthVp
pendingHeightVp
windowEventSeq

因为一次开合动作里,FoldStatus 和 WindowSize 很可能相隔很短时间到达。

如果两个回调都立即执行完整重排,实际上是在对同一个用户动作做两次工作。

四、80ms 合并窗口是 InsightBoard 的工程策略,不是系统常量

这一轮我加了一个很简单的 coalesce。

private applyTimer: number = -1

private scheduleApply(): void {
  if (this.applyTimer >= 0) {
    clearTimeout(this.applyTimer)
  }

  this.applyTimer = setTimeout(() => {
    this.applyTimer = -1
    this.applyStableState()
  }, 80)
}

80ms 不是 HarmonyOS 给出的推荐值,也不是必须参数。

它只是当前 Demo 的工程策略:等待一小段时间,把同一次开合动作里的多个系统事件合成一次页面重排。

本轮日志里:

foldStatusChange seq=17
windowSizeChange seq=18
coalesce=80ms
layoutApply=1

说明这一轮两个事件最终只应用了一次布局。

如果真实产品里窗口动画和业务更复杂,这个值还需要重新测试。

五、真正 Apply 的时候,只看最终 Snapshot

applyStableState() 才是第二篇的核心。

private applyStableState(): void {
  const mode =
    BreakpointPolicy.resolve(
      this.pendingWidthVp
    )

  const snapshot = {
    taskId: 'adapt_20261002_02',
    foldStatus: this.currentFoldStatus,
    widthVp: this.pendingWidthVp,
    heightVp: this.pendingHeightVp,
    mode,
    foldEventSeq: this.foldEventSeq,
    windowEventSeq: this.windowEventSeq
  }

  InsightBoardStore.shared()
    .applyAdaptiveSnapshot(snapshot)

  this.restoreBoardState()
}

这里的 mode 仍然来自 width。

FoldStatus 只是被一起记录进 Snapshot,后续 03 做悬停态布局时才会真正参与体验策略。

这次展开后的最终结果:

FoldStatus = EXPANDED
width = 824vp
mode = MEDIUM

如果未来 EXPANDED 以后用户立刻把应用缩到 520vp,自然会重新变成 COMPACT。

这才符合“窗口决定布局”。

六、连续性真正靠 Store,不靠延时

80ms 只能减少重复重排,它不能自己保存业务状态。

InsightBoard 这次固定保留两项:

selectedItemId = insight_042
listScrollOffsetVp = 1280

布局应用完成后,再恢复到新结构里:

private restoreBoardState(): void {
  const store = InsightBoardStore.shared()

  const selected =
    store.selectedItemId()

  const offset =
    store.listScrollOffsetVp()

  this.navController
    .restoreSelectedItem(selected)

  this.listController
    .scrollToOffset(offset)
}

这段代码解决的是“结构切换以后状态虽然还在 Store,但新组件没有恢复”的问题。

我没有直接保存某个 ArkUI 组件实例。

因为 COMPACT 和 MEDIUM 用的组件树并不一样。真正应该跨形态保存的是:

业务 ID
滚动位置
筛选条件
用户输入
任务进度

不是旧页面对象本身。

七、开合时最不应该做的是释放再重建所有业务资源

这一轮虽然没有相机,但我刻意把资源边界写出来。

如果是媒体、地图、相机这类重资源页面,很容易在 foldStatusChange 里做:

释放 Session
重建播放器
重新请求数据
重建页面

而 windowSizeChange 又走一遍。

官方近期社区里也有一个很典型的 HarmonyOS 7 问题:折叠屏开合后,相机预览卡死,原因之一就是 foldStatusChange 与 windowSizeChange 同时触发后,在两个回调里混合了 UI 重算、资源释放与重建。

InsightBoard 虽然只是内容看板,但第二篇先把架构限制好:

FoldStatus → 语义
WindowSize → 布局
业务资源 → 由自己的生命周期管理

以后 03 如果加视频卡片,也不会因为折叠状态一变就把播放任务重建一遍。

八、监听器本身也有生命周期

第二篇还补了一处容易忘的收尾:on() 不是只注册不释放。

Coordinator 现在保存 Window 引用,并提供统一 dispose():

private mainWindow?: window.Window

dispose(): void {
  try {
    display.off(
      'foldStatusChange',
      this.onFoldStatusChange
    )
  } catch (_) {
  }

  if (this.mainWindow !== undefined) {
    this.mainWindow.off(
      'windowSizeChange'
    )
  }

  if (this.applyTimer >= 0) {
    clearTimeout(this.applyTimer)
    this.applyTimer = -1
  }
}

真实工程要按当前接口签名传匹配 callback。

这里最重要的是原则:Coordinator 创建的监听和 Timer 都由 Coordinator 自己释放。

否则页面多次进入以后,foldEventSeq 会一次跳 2、3、4,最后误以为系统事件重复,实际是自己的 Listener 注册了多份。

九、DevEco 图里最值得看的,是两个事件只落成一次 Layout

开发截图如下:

本轮 HiLog:

foldStatusChange
seq=17
FOLDED → EXPANDED

windowSizeChange
seq=18
1236x2745 → 2472x2745
412vp → 824vp

coalesce=80ms
layoutApply=1
mode=MEDIUM

restore
selectedItem=insight_042
scrollOffset=1280vp

transitionCost=42ms
status=CONTINUOUS

如果未来看到:

foldEventSeq +1
windowEventSeq +1
layoutApply +2

就说明事件合并逻辑出了问题。

这个指标比“肉眼感觉闪了一下”更容易排查。

十、最终运行结果:形态变了,用户上下文没变

运行图如下:

统一状态:

taskId: adapt_20261002_02
fold: FOLDED → EXPANDED
viewport: 412vp → 824vp
layout: COMPACT → MEDIUM
navigation: STACK → SPLIT

foldEventSeq: 17
windowEventSeq: 18
layoutApply: 1
coalesce: 80ms
transitionCost: 42ms

selectedItem: insight_042
scrollOffset: 1280vp
status: CONTINUOUS

这一篇做完以后,InsightBoard 的“折叠屏适配”才不是简单的 if/else。

现在已经有了两个稳定边界:

窗口宽度决定布局
折叠状态提供设备语义

而业务状态独立保存,不跟任何一个系统事件一起销毁。

下一篇会继续这个项目,但开始处理真正的折叠屏增值场景:悬停态和上下区域。HarmonyOS 7 / API 26 的 ArkUI 已经增加了面向折叠区域的能力和 FoldSplitContainer 相关接口。

03 会把现在的“普通宽度响应”推进到“悬停姿态下如何避让折痕、重新组织上下屏内容”,而不是再重复一遍 412/824vp 的断点。

十一、事件顺序不能当成契约,Coordinator 只认“最终稳定状态”

第二篇测试时还有一个很容易写进代码的隐含假设:以为每次一定先收到 foldStatusChange,再收到 windowSizeChange。

实际工程里不应该依赖这个顺序。

不同设备、不同窗口状态和系统调度下,事件到达时机可能不同。只要业务写成:

第一个回调做 A
第二个回调做 B

就会把正确性绑定到事件顺序。

所以现在 Coordinator 不关心“谁先来”,只做两件事:每个事件更新自己负责的 pending 状态,然后重新启动 80ms 合并计时。真正 Apply 时读取的是当前最新的 FoldStatus 和 viewport。

这也是为什么日志里虽然记录 seq=17、18,但序号只用于排查,不用于业务判断。

如果某一轮先收到 WindowSize,再收到 FoldStatus,最终 Snapshot 仍然应该一样:

fold=EXPANDED
width=824vp
mode=MEDIUM

只要最终状态一致,页面结构就应该一致。

十二、重复监听是比系统重复事件更常见的“假问题”

我专门做了一次页面反复进入测试。

旧版本 Coordinator 放在页面组件里,每次 aboutToAppear() 都执行 display.on(),但退出时没有严格对应 off()。第三次进入页面以后,一次开合会收到三份回调。

日志看起来像:

foldStatusChange seq=21
foldStatusChange seq=22
foldStatusChange seq=23

第一反应很容易去怀疑系统重复通知。

真正原因是应用自己注册了三次。

现在 Coordinator 的生命周期提升到 Ability / Window 级别,并增加 started 标志。多页面共享同一份监听;Window 真正销毁时再统一 dispose。页面只订阅 Store,不直接订阅 Display。

这次修改以后,我连续进入/退出 Dashboard 10 次,再执行一次折叠展开,foldEventSeq 仍然只增加 1。

这个验证会继续留在后面的回归脚本里。监听器泄漏不像内存泄漏那么直观,但它会让一次状态变化变成多次业务动作,最后非常难排查。

十三、状态恢复要区分“可序列化状态”和“运行时对象”

insight_042 和 1280vp 能直接保存,NavigationController、Scroller 实例不能这么处理。

InsightBoard 现在把连续性数据分成两类:

可序列化:
selectedItemId
filterKey
scrollOffsetVp
searchText

运行时对象:
NavPathStack
Scroller
Window
Display listener

前一类进入 Store,可以跨组件重建;后一类在新组件出现后重新绑定。

这点看起来有点“架构洁癖”,真正折叠开合时却很实用。COMPACT 的列表 Scroller 和 MEDIUM 左栏的 Scroller 本来就不是同一个对象,强行保留旧引用反而容易出现无效调用。

现在恢复顺序固定为:

新布局创建完成
→ 绑定新的 Controller / Scroller
→ 恢复 selectedItem
→ 恢复 scrollOffset
→ 恢复筛选和输入

业务状态连续,运行时对象则顺着新组件生命周期重新建立。

十四、42ms 不是“系统开合耗时”,而是业务布局恢复耗时

运行页里显示 transitionCost=42ms,这个数字我专门做了限定。

它不是从用户开始折叠设备到物理屏幕完成变化的时间,也不是 HarmonyOS 系统性能指标。

计时起点是 Coordinator 决定应用新 Snapshot,终点是 InsightBoard 完成一次布局切换和业务状态恢复。它只描述当前 Demo 自己的业务路径。

我保留这个指标,是为了后面工程变复杂以后做回归。

例如 04 加入大屏更多卡片、05 加入多窗口恢复以后,如果同一套测试从 42ms 涨到 180ms,就说明应用自己的重排成本变高了。

这种项目内指标比“感觉展开以后慢了一点”更容易定位变化。

十五、第二篇最终做了 20 次开合连续性回归

最终测试不是只跑截图这一轮。

我固定:

FOLDED → EXPANDED → FOLDED
连续 20 轮

每轮都检查:

foldEvent 是否只增加预期次数
layoutApply 是否每次动作只执行一次
selectedItem 是否仍为 insight_042
scrollOffset 是否能恢复
监听器数量是否稳定
Timer 是否已经清零

20 轮结束以后没有出现选中项跳回、列表回顶部或 layoutApply 翻倍,才把状态标成 CONTINUOUS。

做到这里以后,我才敢在下一篇引入真正的悬停态和折痕区域。因为如果普通开合的事件边界还没理清,直接上 FoldSplitContainer 只会让状态更多、问题更难分辨。

十六、事件解耦以后,日志也变得更容易读

这一篇最后还有一个变化,是我不再把所有日志都打成“fold changed”。

现在日志分成三类:

[FoldState] 设备形态
[Viewport]  窗口空间
[Adaptive]  最终布局应用

一次正常展开会看到:

[FoldState] FOLDED -> EXPANDED
[Viewport] 412vp -> 824vp
[Adaptive] COMPACT -> MEDIUM apply=1

如果只出现 FoldState,没有 Viewport,说明窗口没有进入预期尺寸变化;如果 FoldState 和 Viewport 都正常,但 Adaptive 执行两次,就应该查 Coordinator;如果 Adaptive 只有一次而选中项丢失,就去查 Store 恢复。

这套分层日志看起来只是输出格式变化,实际把问题边界划得很清楚。

以前出现“折叠后页面不对”时,排查方向非常宽;现在至少能先回答:是设备形态事件异常、窗口空间异常、还是应用自己的状态恢复异常。

对后面悬停态尤其重要。03 会增加折痕区域和上下屏布局,如果日志边界仍然混在一起,很容易把一个布局问题误判成 FoldStatus 问题。

另外,我把当前 FoldStatus、viewport 和业务状态都写进一次 AdaptiveSnapshot,截图和 HiLog 都从同一份 Snapshot 读取。这样调试图上看到的 824vp、MEDIUM、insight_042 不会分别来自三个对象。后续状态更多以后,这种“单一快照”能明显减少图文和日志互相矛盾的问题,也方便做自动回归断言。

参考资料

  • HarmonyOS 多设备通用适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
  • 折叠屏体验设计原则:https://developer.huawei.com/consumer/cn/doc/doccenter-ux-design/design-principles-0000001957023989
  • HarmonyOS 多设备 Navigation 适配案例:https://gitee.com/harmonyos-cases/cases/blob/master/CommonAppDevelopment/doc/MULTIDEVICE_ADAPTATION.md
  • ArkUI API 26 FoldSplitContainer 变更:https://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/js-apidiff-arkui-b031
Logo

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

更多推荐