ArkUI 动态布局(对照 Android):用条件渲染 + 窗口变化把页面结构写成规则

上一篇 GridRow / GridCol 讲的是“同一组卡片在不同宽度下排几列”。再往前走一步,页面结构会开始变化:窄屏只能纵向堆叠,宽屏可以把信息拆成主区和侧栏。

本文用一个设置面板做例子:

  • 窄屏:顶部状态卡片 + 设置项列表,按纵向排列。
  • 宽屏:左侧是设置项列表,右侧固定展示账号状态与设备状态。
  • 窗口拖宽或拖窄时,只改变布局结构,不重写业务组件。

这篇先讲“窗口/容器尺寸变化如何驱动布局结构”。主例子使用 onAreaChange,后面再补两段最小代码,说明媒体查询和窗口尺寸监听分别放在哪里。

动态布局先写成一张规则表

动态布局容易写乱,是因为判断条件常常散落在 UI 树里:

if (isTablet) { ... }
if (isLandscape) { ... }
if (width > 720) { ... }

更好维护的做法,是先把结构模式写成一张表:

可用宽度 布局模式 页面结构
< 720vp compact 状态卡片在上,设置项在下
>= 720vp expanded 设置项在左,状态卡片在右

然后 UI 只读一个结果:当前是 compact 还是 expanded。这和第 14 篇的栅格规则类似:先定规则,再写组件树。

ArkUI:用 `onAreaChange` 把尺寸变化变成状态

ArkUI 的条件渲染可以根据状态变量切换 UI 分支;onAreaChange 可以把容器尺寸变化接进来。两者放在一起,就能把“窗口变宽”变成“布局模式变化”。

@Entry
@Component
struct SettingsAdaptiveDemo {
  @State private hostWidthVp: number = 0
  @State private selectedKey: string = 'account'

  build() {
    Column() {
      if (this.hostWidthVp >= 720) {
        Row() {
          SettingsMainPanel({
            selectedKey: this.selectedKey,
            onSelect: (key: string) => {
              this.selectedKey = key
            }
          })
            .layoutWeight(2)

          SettingsStatusPanel()
            .width(280)
            .margin({ left: 16 })
        }
        .alignItems(VerticalAlign.Top)
      } else {
        Column() {
          SettingsStatusPanel()
            .margin({ bottom: 12 })

          SettingsMainPanel({
            selectedKey: this.selectedKey,
            onSelect: (key: string) => {
              this.selectedKey = key
            }
          })
        }
      }
    }
    .width('100%')
    .padding(16)
    .onAreaChange((_, newArea: Area) => {
      this.hostWidthVp = Number.parseFloat(newArea.width.toString())
    })
  }
}

这段代码的重点不是 720 这个数字,而是三个边界:

  1. 尺寸变化只在一个入口处理onAreaChange 负责读取容器宽度。
  2. 条件分支直接读取状态build() 里的 if 直接依赖 this.hostWidthVp
  3. 业务组件复用SettingsMainPanelSettingsStatusPanel 在两种结构里复用,不复制一套页面。

这里用 vp 口径描述阈值,和前面尺寸单位文章保持一致。实际项目里,阈值应该来自设计规范或统一的布局配置,而不是散落在每个页面里。

这里有一个容易忽略的小点:不要把分支写成 if (this.isExpanded) 之后再依赖 getter 间接读取状态。实际验证里,直接在 build() 的条件里写 this.hostWidthVp >= 720,更容易触发你预期的布局切换;如果要在预览里模拟宽屏,也可以临时把 onAreaChange 里的赋值改成 this.hostWidthVp = 1000。这说明动态布局示例里,@State 最好出现在 UI 分支直接读取的位置,后面再把阈值抽成统一配置。

把内容组件补完整

如果只贴 SettingsMainPanel() / SettingsStatusPanel() 的名字,读者还是不知道怎么落地。下面补一个最小组件骨架,重点看状态和回调如何复用。

@Component
struct SettingsMainPanel {
  selectedKey: string = 'account'
  onSelect: (key: string) => void = () => {}

  build() {
    Column({ space: 8 }) {
      this.item('account', '账号与安全')
      this.item('notification', '通知设置')
      this.item('display', '显示与亮度')
    }
    .width('100%')
  }

  @Builder
  item(key: string, title: string) {
    Row() {
      Text(title)
        .fontSize(15)
        .layoutWeight(1)
      if (this.selectedKey === key) {
        Text('已选')
          .fontSize(12)
          .fontColor('#2563EB')
      }
    }
    .width('100%')
    .padding(14)
    .borderRadius(10)
    .backgroundColor(this.selectedKey === key ? '#EFF6FF' : $r('sys.color.background_secondary'))
    .onClick(() => {
      this.onSelect(key)
    })
  }
}

@Component
struct SettingsStatusPanel {
  build() {
    Column({ space: 10 }) {
      Text('当前账号')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
      Text('已登录 · 云同步开启')
        .fontSize(13)
        .fontColor('#64748B')
      Divider()
      Text('设备状态')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
      Text('网络正常 · 存储空间充足')
        .fontSize(13)
        .fontColor('#64748B')
    }
    .width('100%')
    .padding(16)
    .borderRadius(12)
    .backgroundColor($r('sys.color.background_secondary'))
  }
}

这里没有把状态面板和设置项写成两套。窄屏和宽屏只是排列方式不同,内容组件仍然是同一组。动态布局最怕“每个尺寸一套组件”,那样后面改字段、加埋点、做无障碍都会变成重复劳动。

在这个示例里,窄屏和宽屏的区别只落在 hostWidthVp >= 720 这一处:窄屏分支使用纵向 Column,宽屏分支使用横向 Row。内容组件没有因为屏幕变宽而复制一份,这也是动态布局能长期维护的关键。

窄屏预览结果如下,状态面板在上,设置项列表在下:

动态布局窄屏预览:状态面板在上,设置项列表在下

宽屏预览结果如下,设置项列在左,状态面板在右:

动态布局宽屏预览:设置项列在左,状态面板在右

什么时候用 `onAreaChange`,什么时候用媒体查询

这篇用 onAreaChange 是为了讲清楚“容器尺寸变化 → 状态 → 条件渲染”这条最小链路。真实项目里可以按场景选择:

场景 ArkUI 抓手 适合解决的问题
某个容器宽度变化 onAreaChange 卡片、面板、局部区域根据自身宽度切结构
屏幕方向、宽高、深色模式等系统条件 @ohos.mediaquery 页面级响应式设计、横竖屏、设备形态条件
应用窗口大小变化 windowSizeChange 监听 多窗口、智慧多窗、窗口拖拽后的全局布局更新

如果只是一个面板内部的结构调整,onAreaChange 足够直观;如果要做全页面的“手机/平板/横屏”策略,媒体查询和窗口尺寸监听会更合适。下面把另外两种也写成最小用法。

`@ohos.mediaquery`:把页面级条件收敛成状态

媒体查询适合处理“页面级条件”:例如横屏时把设置页改成左右结构,或者设备宽度达到某个阈值时显示侧栏。它不关心某个子容器最终有多宽,而是关心当前窗口/屏幕是否满足某个条件。

import mediaquery from '@ohos.mediaquery'

@Entry
@Component
struct SettingsMediaQueryDemo {
  @State private isWideScreen: boolean = false
  private wideScreenListener: mediaquery.MediaQueryListener =
    mediaquery.matchMediaSync('(min-width: 720vp)')

  aboutToAppear() {
    this.isWideScreen = this.wideScreenListener.matches
    this.wideScreenListener.on('change', (result: mediaquery.MediaQueryResult) => {
      this.isWideScreen = result.matches
    })
  }

  aboutToDisappear() {
    this.wideScreenListener.off('change')
  }

  build() {
    if (this.isWideScreen) {
      Row() {
        SettingsMainPanel()
          .layoutWeight(2)
        SettingsStatusPanel()
          .width(280)
          .margin({ left: 16 })
      }
      .padding(16)
    } else {
      Column() {
        SettingsStatusPanel()
          .margin({ bottom: 12 })
        SettingsMainPanel()
      }
      .padding(16)
    }
  }
}

这类写法适合把“宽屏 / 窄屏 / 横屏 / 深色模式”当成页面规则。规则一旦成立,页面里的多个区域都可以复用同一个状态,不需要每个容器都挂一次 onAreaChange

`windowSizeChange`:在窗口层记录全局尺寸

windowSizeChange 更靠近应用窗口本身,适合处理多窗口、自由拖拽窗口、桌面模式这类全局变化。它通常放在 UIAbilityonWindowStageCreate() 里,拿到主窗口后监听窗口尺寸,再把结果写入 AppStorage 或自己的全局状态管理。

import { UIAbility } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage) {
    const mainWindow = windowStage.getMainWindowSync()

    mainWindow.on('windowSizeChange', (size: window.Size) => {
      AppStorage.setOrCreate('appWindowWidthPx', size.width)
      AppStorage.setOrCreate('appWindowHeightPx', size.height)
    })
  }
}

页面侧再读取这个全局窗口宽度,决定当前结构:

@Entry
@Component
struct SettingsWindowSizeDemo {
  @StorageLink('appWindowWidthPx') windowWidthPx: number = 0

  build() {
    if (px2vp(this.windowWidthPx) >= 720) {
      Row() {
        SettingsMainPanel()
          .layoutWeight(2)
        SettingsStatusPanel()
          .width(280)
          .margin({ left: 16 })
      }
      .padding(16)
    } else {
      Column() {
        SettingsStatusPanel()
          .margin({ bottom: 12 })
        SettingsMainPanel()
      }
      .padding(16)
    }
  }
}

这类写法不要滥用。只有当多个页面都需要响应同一个窗口尺寸,或者窗口大小变化会影响导航、侧栏、列表密度等全局策略时,再把尺寸放到窗口层统一管理。

Android:同一个结构切换,在 View/Compose 里怎么写

View/XML:资源限定符切换结构

传统 View 体系常用资源限定符来切换结构:

  • res/layout/fragment_settings.xml:窄屏结构,状态卡片在上,设置项在下。
  • res/layout-sw600dp/fragment_settings.xml:宽屏结构,主区 + 侧栏。

它的优点是结构天然分离;缺点是共享逻辑要自己抽干净。对应到 ArkUI,经验可以转成一句话:结构可以变,但内容组件不要复制。

Compose:`BoxWithConstraints` / `WindowSizeClass`

Compose 里,如果结构只依赖当前容器宽度,可以用 BoxWithConstraints

@Composable
fun SettingsAdaptivePanel() {
  BoxWithConstraints {
    val expanded = maxWidth >= 720.dp
    if (expanded) {
      Row {
        SettingsMainPanel(Modifier.weight(2f))
        SettingsStatusPanel(Modifier.width(280.dp))
      }
    } else {
      Column {
        SettingsStatusPanel()
        SettingsMainPanel()
      }
    }
  }
}

如果产品已经面向平板、折叠屏、桌面窗口化,建议升级到 WindowSizeClass 或 Android adaptive apps 的分档体系,把“窗口分类”从 UI 细节里抽出来。

对照表:动态布局真正对齐的是什么

问题 ArkUI(HarmonyOS) Android View/XML Android Compose
怎么拿到尺寸变化? onAreaChange / 媒体查询 / 窗口尺寸监听 资源限定符、配置变化 BoxWithConstraints / WindowSizeClass
怎么切结构? 条件渲染,但集中在一个模式判断 多份 layout 文件 条件渲染,但用规则函数收敛
怎么复用内容? SettingsMainPanel / SettingsStatusPanel 抽自定义 View / Fragment 子结构 抽 composable
怎么避免判断散落? 阈值和模式统一管理 让资源目录承担结构差异 用 size class / rules 输出模式

检查清单:把动态布局写成可维护规则

  1. 先写“结构模式”表:窄屏是什么结构,宽屏是什么结构。
  2. 把尺寸变化收敛成一个状态输入:不要在多个组件里各自读宽度。
  3. 让 UI 只判断模式:例如 compact / expanded,不要到处写阈值。
  4. 内容组件只写一份:结构切换只负责组合方式,不复制业务 UI。
  5. 写回归用例:模拟窄到宽、宽到窄,确认选中项、输入状态、滚动位置没有丢。

参考素材(精选)

// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-06-05

上一篇