上一篇 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 这个数字,而是三个边界:
- 尺寸变化只在一个入口处理:
onAreaChange负责读取容器宽度。 - 条件分支直接读取状态:
build()里的if直接依赖this.hostWidthVp。 - 业务组件复用:
SettingsMainPanel和SettingsStatusPanel在两种结构里复用,不复制一套页面。
这里用 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 更靠近应用窗口本身,适合处理多窗口、自由拖拽窗口、桌面模式这类全局变化。它通常放在 UIAbility 的 onWindowStageCreate() 里,拿到主窗口后监听窗口尺寸,再把结果写入 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 输出模式 |
检查清单:把动态布局写成可维护规则
- 先写“结构模式”表:窄屏是什么结构,宽屏是什么结构。
- 把尺寸变化收敛成一个状态输入:不要在多个组件里各自读宽度。
- 让 UI 只判断模式:例如
compact/expanded,不要到处写阈值。 - 内容组件只写一份:结构切换只负责组合方式,不复制业务 UI。
- 写回归用例:模拟窄到宽、宽到窄,确认选中项、输入状态、滚动位置没有丢。
参考素材(精选)
- HarmonyOS:if/else 条件渲染
- HarmonyOS:媒体查询(@ohos.mediaquery)
- HarmonyOS:onAreaChange 区域变化事件
- HarmonyOS Code Linter:window-size-change-listener-check
- Android Developers:Support different screen sizes
- Android Developers:Build adaptive apps with Compose
// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-06-05