商品列表、设置卡片、设备面板都会遇到类似排版:手机上一列,横屏或宽屏下两列。麻烦出在卡片内部:里面可能有 TextInput、Toggle、Scroll。用户刚输了一半的昵称、刚切开的开关,不能因为外层从列表换成网格就丢掉。
以前写这种动态布局,常见做法是在 build() 里判断宽度:
if (width >= 720) {
Row() { /* cards */ }
} else {
Column() { /* cards */ }
}
页面本身从上下结构变成左右结构时,直接在 build() 里分支就够用。HarmonyOS API 24 的 DynamicLayout 处理得更细:孩子还是那批孩子,外层换成 RowLayoutAlgorithm、ColumnLayoutAlgorithm、GridLayoutAlgorithm 或自定义算法。官方文档明确提到,运行时切换不同布局算法时,子组件状态可以保持。
先看 if/else 的代价
最常见的写法是根据窗口宽度选择结构:
build() {
if (this.hostWidthVp >= 720) {
Row() {
SettingsMainPanel()
.layoutWeight(1)
SettingsStatusPanel()
.width(280)
}
} else {
Column() {
SettingsStatusPanel()
SettingsMainPanel()
}
}
}
这类代码适合页面级结构变化。手机上先看状态卡片,平板上左边列表、右边详情;两套结构里的组件关系确实不同。
它的代价也很直接:分支越多,组件树越容易重复。SettingsMainPanel() 如果内部还有输入框、滚动列表、展开项,就要小心分支切换时组件身份是否稳定。页面结构变化越大,状态管理就越要前置设计。
onAreaChange、mediaquery、窗口尺寸监听和条件渲染这套组合,解决的是“什么时候换页面结构”。它很适合页面级分支,不太适合一批卡片只换排列方式的场景。
DynamicLayout 把排列方式变成对象
DynamicLayout 入口只有一个关键参数:布局算法。
DynamicLayout(this.algorithm) {
// children
}
this.algorithm 是 LayoutAlgorithm 类型。API 24 这批算法大致对应几种常见容器:
| 算法 | 对应直觉 | 适合场景 |
|---|---|---|
RowLayoutAlgorithm |
水平方向线性排列 | 工具栏、横向卡片 |
ColumnLayoutAlgorithm |
垂直方向线性排列 | 表单、设置项列表 |
StackLayoutAlgorithm |
层叠与定位 | 角标、浮层、卡片叠放 |
GridLayoutAlgorithm |
网格排列 | 商品、图片、入口宫格 |
CustomLayoutAlgorithm |
自己测量和摆放 | 瀑布流、标签云、非规则布局 |
差别在 child 一侧。if/else 通常会替换一段 UI 结构;DynamicLayout 只替换外层算法,里面的孩子仍在同一个容器里。
用设置卡片写下来,大概是下面这个形状:
import {
DynamicLayout, DynamicLayoutAttribute,
ColumnLayoutAlgorithm, GridLayoutAlgorithm,
LayoutAlgorithm, LengthMetrics
} from '@kit.ArkUI'
enum ShelfMode {
List,
Grid
}
interface ShelfItem {
id: string
title: string
desc: string
}
@Entry
@ComponentV2
struct DynamicShelfPage {
@Local mode: ShelfMode = ShelfMode.List
@Local algorithm: LayoutAlgorithm = new ColumnLayoutAlgorithm({
space: LengthMetrics.vp(12)
})
private items: ShelfItem[] = [
{ id: 'account', title: '账号资料', desc: '昵称、头像、手机号' },
{ id: 'sync', title: '云同步', desc: '备份、跨端同步' },
{ id: 'privacy', title: '隐私设置', desc: '权限、数据导出' },
{ id: 'notify', title: '通知提醒', desc: '声音、横幅、角标' }
]
private switchMode(mode: ShelfMode) {
this.mode = mode
if (mode === ShelfMode.List) {
this.algorithm = new ColumnLayoutAlgorithm({
space: LengthMetrics.vp(12)
})
return
}
this.algorithm = new GridLayoutAlgorithm({
columnsTemplate: '1fr 1fr',
columnsGap: LengthMetrics.vp(10),
rowsGap: LengthMetrics.vp(10)
})
}
build() {
Column({ space: 16 }) {
Row({ space: 10 }) {
Button('列表')
.onClick(() => this.switchMode(ShelfMode.List))
Button('网格')
.onClick(() => this.switchMode(ShelfMode.Grid))
}
DynamicLayout(this.algorithm) {
ForEach(
this.items,
(item: ShelfItem) => {
this.SettingCard(item)
},
(item: ShelfItem) => item.id
)
}
.width('100%')
.padding(12)
.backgroundColor('#F6F8FA')
.borderRadius(12)
}
.width('100%')
.padding(16)
}
@Builder
SettingCard(item: ShelfItem) {
Column({ space: 8 }) {
Text(item.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(item.desc)
.fontSize(13)
.fontColor('#64748B')
if (item.id === 'account') {
TextInput({ placeholder: '临时输入昵称' })
.width('100%')
}
if (item.id === 'sync') {
Toggle({ type: ToggleType.Switch, isOn: true })
}
}
.width('100%')
.padding(14)
.backgroundColor(Color.White)
.borderRadius(10)
}
}
列表和网格只改 algorithm,卡片没有复制两份。ForEach 的 key 也要保留,不然“同一批 child”这个前提就站不住。官方文档提到状态保留,这里仍然要注意 child identity:容器可以保留孩子,开发者也要把稳定 key 和状态归属写对。
当然,直接分支也能做:
if (this.mode === ShelfMode.List) {
Column() {
// cards
}
} else {
Grid() {
// cards
}
}
简单页面这么写没什么问题。卡片越多、卡片内部状态越多,重复组件树越容易出错;这时把排列方式收进 algorithm,后续改动会少绕一点。
放到 Android 里,大概接近 ViewGroup / Compose Layout
Android View 体系里,普通页面通常用 LinearLayout、ConstraintLayout、RecyclerView 这些现成容器。真要自己写布局容器,就会继承 ViewGroup,在 onMeasure() 里测量 child,在 onLayout() 里给 child 摆位置。
Compose 里也有类似层次:常规页面先用 Row、Column、Box、LazyVerticalGrid;遇到现成容器表达不了的关系,再用 Layout 自己写 measure/place 逻辑。
DynamicLayout 加进来以后,ArkUI 这一侧可以分成三层看:
| 问题 | Android / Compose 直觉 | ArkUI 选择 |
|---|---|---|
| 固定线性、层叠、网格 | LinearLayout / Row / Column / LazyGrid |
Row、Column、Stack、Grid |
| 同一批 child 在多种排列间切换 | 复用 child,只改外层布局策略 | DynamicLayout + 内置 LayoutAlgorithm |
| 非规则布局 | ViewGroup.onMeasure/onLayout、Compose Layout |
CustomLayoutAlgorithm |
| 画图形和像素 | Canvas / Drawable / Compose Canvas |
ArkUI Canvas、RenderNode 等 |
DynamicLayout 不是用来替代 Row、Column、Grid 的新容器。更准确地说,它给 ArkUI 加了一个“布局算法插槽”:子组件仍然按声明式方式写,外层布局策略变成可替换对象。
CustomLayoutAlgorithm 已经碰到测量和摆放
CustomLayoutAlgorithm 已经进入测量和摆放层。它通过重写 onMeasure 和 onLayout 工作:
这段代码可以按两个阶段读:
| 阶段 | 代码里做什么 | 对 Android 自定义 ViewGroup 的感觉 |
|---|---|---|
onMeasure |
逐个拿到 child,给 child 一份测量约束,调用 measure(),最后设置容器自己的 measured size |
接近 onMeasure() 里 measureChild() / setMeasuredDimension() 的工作 |
onLayout |
读取 child 测量后的尺寸,计算每个 child 的 x/y,调用 layout() 放到具体位置 |
接近 onLayout() 里给每个子 View 摆位置 |
下面的代码只做一件事:把所有子组件按固定 150 x 150 测量,然后从左到右排开。用途是把自定义布局算法的执行顺序露出来,实际项目里普通横向排列仍然优先用 RowLayoutAlgorithm。
class MyCustomLayout extends CustomLayoutAlgorithm {
onMeasure(self: FrameNode, constraint: LayoutConstraint): void {
// 1. self 是当前 DynamicLayout 容器对应的 FrameNode。
// 先拿到子节点数量,后面逐个给 child 下测量约束。
const childCount = self.getChildrenCount()
let totalWidth = 0
let maxHeight = 0
for (let i = 0; i < childCount; i++) {
const child = self.getChild(i)
if (!child) {
continue
}
// 2. 这里把每个 child 都按 150 x 150 测量。
// 真实瀑布流或标签云会根据容器宽度、内容尺寸生成不同约束。
child.measure({
maxSize: { width: 150, height: 150 },
minSize: { width: 150, height: 150 },
percentReference: constraint.percentReference
})
// 3. child.measure() 之后才能读取测量结果。
// 这里把所有 child 宽度累加,并记录较大的高度。
const childSize = child.getMeasuredSize()
totalWidth += childSize.width
maxHeight = Math.max(maxHeight, childSize.height)
}
// 4. 容器自己的测量尺寸不能超过外部传入的 maxSize。
// 这一步对应 Android ViewGroup 里设置自身 measured width/height。
self.setMeasuredSize({
width: Math.min(totalWidth, constraint.maxSize.width),
height: Math.min(maxHeight, constraint.maxSize.height)
})
}
onLayout(self: FrameNode, position: Position): void {
// 5. layout 阶段不再重新测量,只读取 onMeasure 阶段留下的尺寸。
let offsetX = 0
for (let i = 0; i < self.getChildrenCount(); i++) {
const child = self.getChild(i)
if (!child) {
continue
}
// 6. 当前例子从左到右排列:第一个 child 放 x=0,
// 后一个 child 的 x 等于前面宽度的累加。
child.layout({ x: offsetX, y: 0 })
offsetX += child.getMeasuredSize().width
}
// 7. 最后把容器本身放到父容器给定的位置。
self.setLayoutPosition(position)
}
}
这已经不是普通声明式排版了,写法很像 Android 自定义 ViewGroup:先拿 child,给 child 约束,测量 child,读测量结果,再摆位置。差别在于 ArkUI 这里面对的是 FrameNode 和 LayoutConstraint,布局算法以对象形式传给 DynamicLayout。
限制也要跟着写在旁边:
| 限制 | 工程含义 |
|---|---|
布局算法类使用 @ObservedV2,不支持 @State |
算法对象是可观察的布局策略,不是页面状态容器 |
onMeasure / onLayout 中不允许修改状态变量 |
测量和摆放阶段只计算尺寸与位置,不做业务状态变更 |
这类代码要理解测量约束、子组件尺寸、布局坐标和重排触发时机。普通业务页不要为了“用了新 API”而上自定义算法。
什么时候放进项目里
可以按场景拆:
| 场景 | 建议 |
|---|---|
| 页面从单栏变双栏,导航区域也变化 | 用条件渲染、媒体查询或窗口尺寸监听;把分支放在页面结构层 |
| 同一组卡片在列表和网格之间切换 | 用 DynamicLayout + ColumnLayoutAlgorithm / GridLayoutAlgorithm |
| 切换布局时要保留输入框、开关、滚动状态 | 优先考虑 DynamicLayout,并保证 child identity 稳定 |
| 只是在 Row 和 Column 之间做一次简单切换 | 条件渲染也可以,别为了 API 新就引入复杂度 |
| 瀑布流、标签云、不规则卡片墙 | 评估 CustomLayoutAlgorithm |
| 图表、涂鸦、像素级绘制 | 看 Canvas / RenderNode,不要强行塞进布局算法 |
放到最近几篇自定义控件讨论里,DynamicLayout 解决的是“布局策略复用”这一块。它不能自动解决 ArkUI 组合组件对基础属性开放不足的问题,也不会让跨仓组件库突然拥有 Android TextView 继承那种宽接口。
它有用的地方在布局策略。商品卡片瀑布流、标签云、仪表盘小组件墙这类场景,可以把“怎么测量和摆放”收进算法,页面里少写几套 if/else + Row/Column/Grid。
总结
DynamicLayout 适合放在布局算法层处理问题:
- 页面结构要换:用条件渲染、媒体查询和窗口尺寸监听。
- children 不变,只换排列方式:用
DynamicLayout+ 内置LayoutAlgorithm。 - 内置算法不够:写
CustomLayoutAlgorithm,自己处理 measure/layout。 - 像素绘制、图表和特效:走
Canvas/RenderNode,不要塞进布局算法。
HarmonyOS 自定义 UI 能力分在几层:组合组件负责结构封装,AttributeModifier / @Extend 处理属性复用,DynamicLayout / CustomLayoutAlgorithm 处理布局策略,Canvas / RenderNode 处理绘制。迁移或做组件治理时,先判断属于哪一类问题,再选方法。
参考材料
- HarmonyOS 官方文档:动态布局 (DynamicLayout)
- HarmonyOS API 参考:DynamicLayout
- HarmonyOS API 参考:LayoutAlgorithm
- Android Developers:ViewGroup API reference
- Android Developers:Custom layouts in Compose
// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-07-10