HarmonyOS 7 InsightBoard 多形态适配实录 01:ArkUI × Window:windowSizeChange 驱动的三档断点与导航重排【鸿蒙心迹】
PixelBridge 系列结束以后,这次我把工程切到完全不同的方向:不再处理 Native、CMake 和图像 Buffer,而是做一个真正会在手机、折叠屏、平板和自由窗口里来回变化的 ArkUI 应用。
新项目叫 InsightBoard,定位是一套信息洞察看板。它本身不复杂:左边是内容列表,右边是详情;在大窗口里还会出现固定导航。真正麻烦的是,同一套页面放到不同窗口宽度以后,不能只是“把组件拉宽”。
这条连载固定 6 篇,01 先解决最基础也最容易被写乱的问题:页面布局到底应该由设备型号决定,还是由当前窗口的真实宽度决定。
我最后选的是后者。
本轮任务固定为:
taskId: adapt_20261002_01
折叠态窗口:
1236 × 2745 px
density: 3.0
viewport: 412 × 915 vp
mode: COMPACT
展开态窗口:
2472 × 2745 px
density: 3.0
viewport: 824 × 915 vp
mode: MEDIUM
当前选中项:
insight_042
最终状态:
READY
项目自己的三档断点定义为:
COMPACT < 600vp
MEDIUM 600~839vp
EXPANDED >= 840vp
这些不是 HarmonyOS 强制尺寸,只是 InsightBoard 当前信息密度下的工程规则。

一、第一版的问题,是把“设备类型”写进了页面判断
刚开始我写过一版非常直觉的代码:
如果是手机
→ 单栏
如果是折叠屏展开
→ 双栏
如果是平板
→ 三栏
单独跑设备没有问题,真正把应用拖进自由窗口以后,这套判断立刻失效。
平板并不意味着窗口一直很宽;折叠屏展开以后也不意味着应用一定占满整个屏幕。用户进入分屏、自由窗口、应用内多窗以后,真实可用空间和设备分类已经不是一回事。
官方多设备适配指南现在也把“窗口空间变化”放在核心位置:使用响应式布局、断点能力和窗口管理机制,让界面随设备形态与窗口空间变化。
所以 InsightBoard 的第一个约束变成:
页面结构只看 viewport,设备形态只作为额外语义,不直接决定布局。
后面第二篇才会接折叠状态。
二、断点模型先独立出来,不让页面自己算宽度
我先新建 models/LayoutMode.ets。
这段代码解决的是“多个页面各写一遍 600 / 840,后面阈值改动不一致”的问题:
export enum LayoutMode {
COMPACT = 'COMPACT',
MEDIUM = 'MEDIUM',
EXPANDED = 'EXPANDED'
}
export interface LayoutSnapshot {
taskId: string
widthPx: number
heightPx: number
widthVp: number
heightVp: number
mode: LayoutMode
}
export class BreakpointPolicy {
static resolve(widthVp: number): LayoutMode {
if (widthVp < 600) {
return LayoutMode.COMPACT
}
if (widthVp < 840) {
return LayoutMode.MEDIUM
}
return LayoutMode.EXPANDED
}
}
这里我没有写“PHONE / TABLET / PC”。
因为 LayoutMode 描述的是当前空间模式,不是硬件身份。
一台平板缩到 520vp 的自由窗口,也应该进入 COMPACT;一个足够宽的 PC 窗口才能进入 EXPANDED。
这个命名会让后面的状态判断干净很多。
三、初始尺寸和后续变化必须走同一个入口
另一个很常见的问题,是初始页面按一套逻辑算布局,windowSizeChange 回调又按另一套逻辑改状态。
最后就会出现:
首次进入是 MEDIUM
拖一下窗口
同样宽度却变成 COMPACT
InsightBoard 统一交给 ViewportManager:
import { window } from '@kit.ArkUI'
export class ViewportManager {
private current: LayoutSnapshot = {
taskId: 'adapt_20261002_01',
widthPx: 0,
heightPx: 0,
widthVp: 0,
heightVp: 0,
mode: LayoutMode.COMPACT
}
private toVp: (px: number) => number =
(px: number) => px
bind(
mainWindow: window.Window,
px2vp: (px: number) => number
): void {
this.toVp = px2vp
const rect =
mainWindow.getWindowProperties().windowRect
this.applySize(rect.width, rect.height)
mainWindow.on('windowSizeChange', (size) => {
this.applySize(size.width, size.height)
})
}
private applySize(
widthPx: number,
heightPx: number
): void {
const widthVp = this.toVp(widthPx)
const heightVp = this.toVp(heightPx)
this.current = {
taskId: 'adapt_20261002_01',
widthPx,
heightPx,
widthVp,
heightVp,
mode: BreakpointPolicy.resolve(widthVp)
}
InsightBoardStore.shared()
.updateLayout(this.current)
}
snapshot(): LayoutSnapshot {
return { ...this.current }
}
}
官方多窗口适配文档也明确建议监听窗口 windowSizeChange,在窗口尺寸改变时调整布局。
我这里把 px 转 vp 的动作从页面传进 Manager,是因为断点使用 vp 更符合 ArkUI 组件布局语义;同时保留 px,调试时可以和 Window 日志直接对照。
四、COMPACT、MEDIUM、EXPANDED 改的是结构,不只是列数
01 里我没有用一个 Grid 然后把 columns 从 1 改成 2、3。
InsightBoard 三档真正变化的是导航结构。
1. COMPACT:单页栈
412vp 时:
底部导航
+
列表页
↓ 点击
详情页
这种宽度下如果硬塞左右双栏,列表标题、摘要和详情都被挤得很窄。
所以 COMPACT 使用 NavigationMode.Stack,把列表和详情看成连续页面。
2. MEDIUM:列表 + 详情
824vp 时,空间已经足够让列表和详情同时出现。
当前测试把宽度大致分成:
列表:约 300vp
详情:剩余空间
此时改为 Split 结构,但业务选中项仍然是 insight_042。
3. EXPANDED:导航栏 + 列表 + 详情
=840vp 后再增加左侧固定导航,让首页、数据、洞察和设置入口不必继续塞在底部。
也就是说三档的变化是:
COMPACT
BottomBar + Stack
MEDIUM
Split List/Detail
EXPANDED
NavRail + List + Detail
这样大屏增加的是信息密度,而不是单纯“把手机页面放大”。
五、布局变化不能顺手把业务状态重建
第一版从 COMPACT 切到 MEDIUM 时,我直接重建了整个 Navigation。
运行看起来正确,但当前选中的详情丢了。
折叠前正在看:
insight_042
展开以后详情栏回到了第一条。
这个问题本质上不是折叠屏问题,而是我把“布局状态”和“业务状态”放进同一个组件生命周期里了。
现在 Store 分开保存:
export interface BoardState {
selectedItemId: string
listScrollOffsetVp: number
}
export interface UiState {
layout: LayoutSnapshot
}
export class InsightBoardStore {
private board: BoardState = {
selectedItemId: 'insight_042',
listScrollOffsetVp: 1280
}
private ui: UiState = {
layout: {
taskId: 'adapt_20261002_01',
widthPx: 1236,
heightPx: 2745,
widthVp: 412,
heightVp: 915,
mode: LayoutMode.COMPACT
}
}
updateLayout(layout: LayoutSnapshot): void {
this.ui.layout = layout
}
selectItem(id: string): void {
this.board.selectedItemId = id
}
}
断点只改 UiState。
selectedItemId 和滚动位置不跟着布局一起初始化。
这是整个系列后面所有连续性处理的基础。
六、页面层只消费 Mode,不再感知 px
DashboardPage 最后变得很薄。
这段代码解决的是“布局判断散落在页面多个 if 中”的问题:
@Entry
@Component
struct DashboardPage {
@State mode: LayoutMode =
LayoutMode.COMPACT
@State selectedItemId: string =
'insight_042'
build() {
if (this.mode === LayoutMode.COMPACT) {
CompactBoard({
selectedItemId: this.selectedItemId
})
} else if (
this.mode === LayoutMode.MEDIUM
) {
MediumBoard({
selectedItemId: this.selectedItemId
})
} else {
ExpandedBoard({
selectedItemId: this.selectedItemId
})
}
}
}
页面不再关心 600、840,也不关心当前是不是折叠屏。
这两个判断分别归到:
BreakpointPolicy
Display/Fold Coordinator(下一篇)
业务页只知道:“我现在应该用哪种结构”。
七、DevEco 里最值得看的是一次完整的 412 → 824
这次开发截图里,我保留了窗口变化日志。

当前 HiLog:
windowSizeChange 1236x2745
viewport 412x915vp
mode=COMPACT
windowSizeChange 2472x2745
viewport 824x915vp
mode=MEDIUM
selectedItem preserved insight_042
taskId=adapt_20261002_01
columns=2
status=READY
这里没有打印“设备=折叠屏就双栏”。
日志只证明一件事:
窗口真的从 412vp 变到 824vp
BreakpointPolicy 得到 MEDIUM
这样后面在平板自由窗口里复测,仍然使用同一条证据链。
八、运行结果:设备展开了,但页面没有重新开始
最终运行图保持同一套数据:

当前页面:
taskId: adapt_20261002_01
device: Foldable / Unfolded
window: 2472 × 2745 px
density: 3.0
viewport: 824 × 915 vp
layout: MEDIUM
navigation: SPLIT
selected: insight_042
visible columns: 2
status: READY
真正让我觉得 01 可以结束的,不是“左右两栏终于出来了”。
而是折叠前后的 insight_042 没变。
这说明布局已经开始从业务状态里拆出来。
下一篇会接入 display.isFoldable()、display.getFoldStatus() 和 foldStatusChange。重点不是再写一遍“展开就双栏”,而是处理一个更麻烦的问题:
一次开合动作
可能先触发 FoldStatus
又触发 WindowSize
两个回调如果都去重建页面
就会重复刷新甚至打乱状态
02 会专门把这两个事件拆开:FoldStatus 只描述设备形态,WindowSize 才决定最终布局。
九、断点附近最容易出现的不是错位,而是布局抖动
把三档断点跑通以后,我又做了一轮自由窗口拖拽测试。问题出现在 600vp 和 840vp 附近。
用户拖动窗口时,windowSizeChange 会连续到达。如果窗口宽度在 598、601、599、602vp 之间来回波动,页面就会在 COMPACT 和 MEDIUM 之间快速切换。代码逻辑没有错,视觉上却会明显“抖”一下,Navigation 也可能连续重组。
我没有给系统事件加一个粗暴的长延时,而是在 BreakpointPolicy 外面增加很小的迟滞区间。当前策略是:从 COMPACT 升到 MEDIUM 要真正达到 608vp;已经在 MEDIUM 时,只有低于 592vp 才退回 COMPACT。840vp 边界同理。
这组 8vp 也只是 InsightBoard 的项目参数,不是平台标准。它的目的很单纯:窗口正在边界附近微调时,避免因为一两个 vp 的变化反复重建组件树。
export class StableBreakpointPolicy {
static resolve(
widthVp: number,
previous: LayoutMode
): LayoutMode {
if (previous === LayoutMode.COMPACT) {
return widthVp >= 608
? LayoutMode.MEDIUM
: LayoutMode.COMPACT
}
if (previous === LayoutMode.MEDIUM) {
if (widthVp < 592) {
return LayoutMode.COMPACT
}
if (widthVp >= 848) {
return LayoutMode.EXPANDED
}
return LayoutMode.MEDIUM
}
return widthVp < 832
? LayoutMode.MEDIUM
: LayoutMode.EXPANDED
}
}
这里解决的是“断点稳定性”,不是减少 windowSizeChange 的数量。窗口尺寸仍然按真实值记录,只有结构模式增加迟滞。
十、同一个 Mode 内变化,不应该每次都重建 Navigation
拖动窗口还有一个很实际的问题:从 700vp 拉到 780vp,两次都属于 MEDIUM。
如果 applySize() 每次都重新设置 Navigation 结构,业务上没有任何收益,反而会增加组件重建次数。
现在 Store 会区分两种变化:
viewport changed
layout mode unchanged
和:
viewport changed
layout mode changed
第一种只更新可用宽高,让 Flex、Grid 和百分比区域自然重新测量;第二种才触发 COMPACT / MEDIUM / EXPANDED 结构切换。
这点对自由窗口尤其明显。用户把 MEDIUM 窗口从 680vp 缓慢拖到 820vp 时,详情区域可以持续变宽,但 Navigation 不需要重新从 Stack/Split 里切换。
我在 HiLog 里单独加了:
viewportUpdateCount
layoutModeChangeCount
一轮拖拽可能收到几十次 viewport 更新,但 layoutModeChangeCount 只应该在真正越过断点时增加。
这个指标也会留到 06 做性能验收,避免后面页面复杂以后,窗口拖拽变成一次隐性的“组件重建压测”。
十一、px、vp 和安全区最好在同一层处理
这一篇还有一个小细节,我没有让每个页面自己写 px2vp()。
Window 给我的尺寸更接近窗口管理层数据,ArkUI 页面布局则使用 vp。只要转换散落到三个页面,很快就会出现有的拿 px 做断点、有的拿 vp 做断点。
所以 ViewportManager 输出的 Snapshot 同时保留两套值:
widthPx / heightPx
widthVp / heightVp
日志和系统问题排查优先看 px;业务断点和 ArkUI 组件布局看 vp。
后面接沉浸式、安全区和折痕区域时,也会统一在“可用空间计算层”处理,而不是把状态栏、导航栏、折痕避让分别塞进业务组件。
实际做多设备适配,我越来越觉得这层很重要。很多所谓“折叠屏 UI bug”,并不是组件不会写,而是窗口坐标、vp 布局和业务状态混在了一起。现在先把空间数据集中,后面的复杂形态才不会继续复制判断。
十二、这一篇我按五种窗口状态做回归,而不是只测折叠屏开合
最终回归我固定了五组:
412vp 手机/折叠态 COMPACT
599vp 边界下方 COMPACT
640vp 中等自由窗口 MEDIUM
824vp 折叠屏展开 MEDIUM
960vp 平板/大窗口 EXPANDED
每一组都检查三件事:布局模式、Navigation 结构、selectedItemId。
这比只在折叠屏上做“合上—展开”更有价值,因为第一篇的目标本来就不是做一个折叠屏专用页面,而是建立一套真正能够覆盖手机、折叠屏、平板和 PC 窗口的空间规则。
最终我把验收条件写成:
同样的 viewport 必须得到同样的 LayoutMode
结构变化不能清空业务选择
同一 Mode 内 resize 不重复重建 Navigation
这三条都满足以后,01 才算真正把多形态项目的地基打稳。
十三、这个断点方案最后还做了一次“反向验证”
我还故意把设备和窗口类型打乱测试。
在折叠屏展开态下,把应用拖成 560vp 的窄自由窗口,结果必须回到 COMPACT;在平板上把窗口拉到 760vp,应该进入 MEDIUM;在大屏设备上即使硬件空间很大,只要当前应用窗口没有超过 840vp,也不能提前进入 EXPANDED。
这组反向验证很重要,因为它能证明当前实现真的已经摆脱“设备类型决定页面”的旧逻辑。
最后我把一条规则写进代码注释:
hardware capability describes what the device can do;
viewport describes what the current page can use.
硬件能力决定“有没有折叠、有没有更大屏幕、是否支持某种交互”,viewport 决定当前这一个窗口实际能放下多少内容。
两者分开以后,后面做折叠屏、平板和 PC 不再需要维护三套页面。真正需要变化的只是布局策略和交互密度。
这也是 InsightBoard 这个系列后续继续推进的基础:02 处理形态事件,03 处理悬停态,04 再把同一套信息结构放到平板和 PC。只要空间规则始终稳定,项目就不会因为设备越来越多而不断复制页面。
补完这一轮之后,我又把断点配置移到统一常量文件,并给每个模式增加了一条调试快照。这样设计师调整阈值时,不需要搜索整个项目;测试同学也能从日志里直接确认本次运行使用的是哪一版断点。对多形态项目来说,规则可追溯比在页面里临时补一个 if 更重要。后续如果平板和 PC 需要不同的信息密度,只扩展 Policy,不复制 Dashboard 页面。
这次还把五组窗口状态做成固定测试数据,后续每次修改布局组件都重复跑同一套输入。这样断点、导航和选中项能形成稳定回归基线,不会因为某次视觉调整悄悄破坏多设备连续性。
参考资料
- 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
- HarmonyOS 7 新能力与 API 26:https://developer.huawei.com/consumer/cn/features/
更多推荐



所有评论(0)