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/
Logo

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

更多推荐