HarmonyOS 7 InsightBoard 多形态适配实录 02:Display × Window:foldStatusChange 解耦与折叠状态连续性【鸿蒙心迹】
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
更多推荐



所有评论(0)