自定义布局实战:用 onMeasureSize 与 onPlaceChildren 打破 ArkUI 布局天花板

推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源:
前言 – 人工智能学习网
当 Flex、Column、Row 这些系统容器撑不起你的想象时,ArkUI 留了一扇后门——两个回调函数,让你接管布局的全部控制权。
一、为什么需要自定义布局
HarmonyOS ArkUI 提供了丰富的布局容器:线性布局(Row/Column)、层叠布局(Stack)、弹性布局(Flex)、网格布局(Grid)、相对布局(RelativeContainer)。这些容器覆盖了 90% 以上的日常布局场景。但当遇到以下需求时,系统容器就显得力不从心:
- 不规则瀑布流:卡片高度不一,需要动态分配到最短列,且列数随屏幕宽度变化
- 圆形排列:头像围绕中心点均匀分布,间距和半径随子组件数量动态调整
- 递增/递减尺寸:子组件大小按特定算法递增,且位置与大小联动
- 自适应折行:标签流式排列,换行时机和间距完全可控,系统 Flex 的间距行为不够灵活
这些场景的共同点是:子组件的尺寸和位置之间存在复杂依赖关系,无法用简单的排列规则描述。ArkUI 为此开放了 onMeasureSize 和 onPlaceChildren 两个布局回调,让开发者在自定义组件中接管测量与放置的完整逻辑。
二、两个核心回调
自定义布局的本质是把布局过程拆成两步:先测量,后放置。这和 Android 自定义 View 的 onMeasure / onLayout 如出一辙,熟悉 Android 开发的开发者会感到非常亲切。
onMeasureSize:测量阶段
onMeasureSize(selfLayoutInfo: GeometryInfo, children: Array<Measurable>, constraint: ConstraintSizeOptions): SizeResult
这个回调在组件每次布局时触发,开发者在这里遍历所有子组件,调用 child.measure() 测量每个子组件的尺寸,最终返回自定义组件自身的尺寸。
三个参数的含义:
| 参数 | 类型 | 说明 |
|---|---|---|
selfLayoutInfo |
GeometryInfo |
组件自身的布局信息,包含 width、height、direction 等 |
children |
Array<Measurable> |
子组件测量接口数组,每个元素提供 measure() 方法 |
constraint |
ConstraintSizeOptions |
父组件施加的尺寸约束(minWidth、maxWidth、minHeight、maxHeight) |
返回值 SizeResult 包含 width 和 height 两个字段,代表自定义组件自身最终占据的尺寸。
onPlaceChildren:放置阶段
onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Array<Layoutable>, constraint: ConstraintSizeOptions): void
测量完成后立即触发。在这里遍历子组件,调用 child.layout({ x, y }) 设置每个子组件相对于自定义组件内部坐标系的位置。
注意参数类型的变化:Measurable 变成了 Layoutable。Layoutable 接口在测量结果的基础上增加了 layout() 方法,同时保留了 measureResult 属性,让你可以在放置阶段读取之前测量的尺寸。
执行顺序
父组件布局 → onMeasureSize(测量子组件 + 返回自身尺寸)→ onPlaceChildren(放置子组件位置)→ 子组件渲染
onMeasureSize 一定先于 onPlaceChildren 执行。在 onPlaceChildren 中,每个子组件的 measureResult 已经包含了测量阶段的尺寸数据,可以直接读取。
三、核心类型速查
理解自定义布局,需要先搞清楚涉及的类型关系:
| 类型 | 角色 | 关键方法/属性 |
|---|---|---|
GeometryInfo |
组件自身信息 | width、height、direction |
Measurable |
子组件测量接口 | measure(constraint): MeasureResult |
MeasureResult |
测量结果 | width、height |
Layoutable |
子组件放置接口 | layout({x, y})、measureResult |
ConstraintSizeOptions |
尺寸约束 | minWidth、maxWidth、minHeight、maxHeight |
SizeResult |
组件自身尺寸 | width、height |
child.measure() 接收 ConstraintSizeOptions,告诉子组件"你的尺寸必须在 min 和 max 之间"。子组件根据自身内容在这个范围内确定最终尺寸,返回 MeasureResult。比如一个 Text 组件,在 maxWidth 限制下会自动换行,返回换行后的实际高度。
四、实战一:递增尺寸 + 右下角对齐
这是官方示例的完整实现。核心思路:子组件大小逐个递增,所有子组件右下角对齐到同一个点。
子组件传入方式
子组件通过 @Builder 函数构建,再通过 @BuilderParam 传入自定义组件:
@Entry
@Component
struct Index {
build() {
Column() {
CustomLayout({ builder: columnChildren })
}
}
}
@Builder
function columnChildren() {
ForEach([1, 2, 3], (index: number) => {
Text('S' + index)
.fontSize(30)
.width(100)
.height(100)
.borderWidth(2)
.offset({ x: 10, y: 20 })
})
}
这里有个细节:ForEach 暂不支持 LazyForEach 的写法传入自定义布局组件。子组件直接作为自定义组件的一级子组件,不经过 Column 等容器包裹。
自定义布局组件
@Component
struct CustomLayout {
@Builder
doNothingBuilder() {
};
@BuilderParam builder: () => void = this.doNothingBuilder;
result: SizeResult = {
width: 0,
height: 0
};
// 第一步:计算各子组件的大小
onMeasureSize(selfLayoutInfo: GeometryInfo, children: Array<Measurable>, constraint: ConstraintSizeOptions) {
let size = 100;
children.forEach((child) => {
let result: MeasureResult = child.measure({
minHeight: size, minWidth: size, maxWidth: size, maxHeight: size
})
size += result.width / 2;
})
this.result.width = 100;
this.result.height = 400;
return this.result;
}
// 第二步:放置各子组件的位置
onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Array<Layoutable>, constraint: ConstraintSizeOptions) {
let startPos = 300;
children.forEach((child) => {
let pos = startPos - child.measureResult.height;
child.layout({ x: pos, y: pos })
})
}
build() {
this.builder()
}
}
逻辑解析
测量阶段的递增算法:初始 size = 100,第一个子组件被约束为 100×100。测量完成后,size += result.width / 2,即 size = 100 + 50 = 150。第二个子组件被约束为 150×150,测量后 size = 150 + 75 = 225。第三个子组件被约束为 225×225。三个子组件尺寸依次为 100、150、225,形成递增序列。
放置阶段的右下角对齐:startPos = 300,每个子组件的位置 pos = 300 - height。第一个子组件 height=100,pos=200,放置在 (200, 200),右下角到达 (300, 300)。第二个 height=150,pos=150,放置在 (150, 150),右下角同样到达 (300, 300)。第三个以此类推。三个子组件右下角重合在 (300, 300) 这个顶点上,形成了类似 Stack 但方向相反的层叠效果。
尺寸推演
| 子组件 | 测量约束 | 实际尺寸 | 放置位置 | 右下角坐标 |
|---|---|---|---|---|
| S1 | 100×100 | 100×100 | (200, 200) | (300, 300) |
| S2 | 150×150 | 150×150 | (150, 150) | (300, 300) |
| S3 | 225×225 | 225×225 | (75, 75) | (300, 300) |
五、实战二:流式布局(Flow Wrap)
系统 Flex 的 wrap: FlexWrap.Wrap 可以实现换行,但对行间距、列间距、对齐方式的控制粒度不够。用自定义布局实现流式排列,可以精确控制每个维度。
测量逻辑
onMeasureSize(selfLayoutInfo: GeometryInfo, children: Array<Measurable>, constraint: ConstraintSizeOptions) {
let maxWidth = selfLayoutInfo.width;
let currentX = 0;
let currentY = 0;
let lineMaxHeight = 0;
children.forEach((child) => {
let measureResult: MeasureResult = child.measure({
minWidth: 0, maxWidth: maxWidth,
minHeight: 0, maxHeight: Infinity
});
// 当前行放不下,换行
if (currentX + measureResult.width > maxWidth && currentX > 0) {
currentY += lineMaxHeight;
currentX = 0;
lineMaxHeight = 0;
}
currentX += measureResult.width;
if (measureResult.height > lineMaxHeight) {
lineMaxHeight = measureResult.height;
}
});
this.result.width = maxWidth;
this.result.height = currentY + lineMaxHeight;
return this.result;
}
关键点在于 child.measure() 传入了 maxWidth: maxWidth 和 maxHeight: Infinity。这告诉子组件"宽度不超过容器宽度,高度不受限制"。Text 组件在这种约束下会按自身内容计算实际宽高。
换行判断 currentX + measureResult.width > maxWidth && currentX > 0 中,currentX > 0 防止第一个子组件就超出容器宽度时被错误地换行(它应该直接放在第一行起始位置,即使溢出)。
放置逻辑
onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Array<Layoutable>, constraint: ConstraintSizeOptions) {
let maxWidth = selfLayoutInfo.width;
let currentX = 0;
let currentY = 0;
let lineMaxHeight = 0;
children.forEach((child) => {
let childWidth = child.measureResult.width;
let childHeight = child.measureResult.height;
if (currentX + childWidth > maxWidth && currentX > 0) {
currentY += lineMaxHeight;
currentX = 0;
lineMaxHeight = 0;
}
child.layout({ x: currentX, y: currentY });
currentX += childWidth;
if (childHeight > lineMaxHeight) {
lineMaxHeight = childHeight;
}
});
}
放置逻辑完整重复了测量阶段的换行判断。这里有一个设计上的取舍:是否需要在测量阶段记录每个子组件的位置,放置时直接读取?
两种方案各有优劣。记录位置的方式代码更简洁,放置阶段不需要重复换行逻辑,但需要额外维护一个位置数组。重复换行逻辑的方式代码有冗余,但每次回调独立计算,不依赖跨回调的状态传递。在子组件数量不多的情况下,两种方案性能差异可以忽略。
与系统 Flex 的对比
// 系统 Flex
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(['ArkTS', 'HarmonyOS', 'ArkUI'], (item: string) => {
Text(item).fontSize(14).padding(8)
})
}
系统 Flex(wrap) 在换行时无法精确控制行间距——space 参数同时影响主轴和交叉轴间距。自定义流式布局可以分别控制水平间距和垂直间距,甚至实现行间渐变间距、首行缩进等复杂效果。
六、实战三:圆形排列
将子组件均匀分布在圆周上,是一个经典的自定义布局场景。系统容器无法实现这种效果。
@Component
struct CircleLayout {
@Builder
doNothingBuilder() {};
@BuilderParam builder: () => void = this.doNothingBuilder;
result: SizeResult = { width: 0, height: 0 };
radius: number = 120;
onMeasureSize(selfLayoutInfo: GeometryInfo, children: Array<Measurable>, constraint: ConstraintSizeOptions) {
let containerSize = Math.min(selfLayoutInfo.width, 300);
this.radius = (containerSize - 60) / 2;
children.forEach((child) => {
child.measure({
minWidth: 50, maxWidth: 50,
minHeight: 50, maxHeight: 50
});
});
this.result.width = containerSize;
this.result.height = containerSize;
return this.result;
}
onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Array<Layoutable>, constraint: ConstraintSizeOptions) {
let count = children.length;
let centerX = this.result.width / 2;
let centerY = this.result.height / 2;
children.forEach((child, index) => {
let angle = (index / count) * 2 * Math.PI - Math.PI / 2;
let x = centerX + this.radius * Math.cos(angle) - child.measureResult.width / 2;
let y = centerY + this.radius * Math.sin(angle) - child.measureResult.height / 2;
child.layout({ x: x, y: y });
});
}
build() {
this.builder()
}
}
角度计算 (index / count) * 2 * Math.PI - Math.PI / 2 中,-Math.PI / 2 让第一个子组件从正上方(12 点钟方向)开始排列。如果去掉这个偏移,第一个子组件会从正右方(3 点钟方向)开始。
位置计算中 centerX + radius * Math.cos(angle) - child.measureResult.width / 2,减去子组件宽度的一半是为了让子组件中心点落在圆周上,而不是左上角落在圆周上。这是圆形排列的常见处理方式。
七、实战四:瀑布流布局
瀑布流是电商和内容类应用的常见布局。核心逻辑是将每个子组件放入当前最短的列。
@Component
struct WaterfallLayout {
@Builder
doNothingBuilder() {};
@BuilderParam builder: () => void = this.doNothingBuilder;
result: SizeResult = { width: 0, height: 0 };
columns: number = 2;
gap: number = 8;
onMeasureSize(selfLayoutInfo: GeometryInfo, children: Array<Measurable>, constraint: ConstraintSizeOptions) {
let containerWidth = selfLayoutInfo.width;
let columnWidth = (containerWidth - this.gap * (this.columns - 1)) / this.columns;
let columnHeights: number[] = new Array(this.columns).fill(0);
children.forEach((child) => {
let measureResult: MeasureResult = child.measure({
minWidth: 0, maxWidth: columnWidth,
minHeight: 0, maxHeight: Infinity
});
let shortestCol = columnHeights.indexOf(Math.min(...columnHeights));
columnHeights[shortestCol] += measureResult.height + this.gap;
});
this.result.width = containerWidth;
this.result.height = Math.max(...columnHeights) - this.gap;
return this.result;
}
onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Array<Layoutable>, constraint: ConstraintSizeOptions) {
let containerWidth = selfLayoutInfo.width;
let columnWidth = (containerWidth - this.gap * (this.columns - 1)) / this.columns;
let columnHeights: number[] = new Array(this.columns).fill(0);
children.forEach((child) => {
let shortestCol = columnHeights.indexOf(Math.min(...columnHeights));
let x = shortestCol * (columnWidth + this.gap);
let y = columnHeights[shortestCol];
child.layout({ x: x, y: y });
columnHeights[shortestCol] += child.measureResult.height + this.gap;
});
}
build() {
this.builder()
}
}
Math.min(...columnHeights) 找到当前最矮的列索引,Math.max(...columnHeights) - this.gap 计算组件总高度(减去最后一个子组件底部多余的 gap)。
列宽计算 (containerWidth - gap * (columns - 1)) / columns 中,gap 乘以 (columns - 1) 是因为 N 列之间只有 N-1 个间距。这是一个容易出错的细节:如果误写成 gap * columns,会导致最后一列右边缘出现多余间距。
八、BuilderParam 的作用
自定义布局组件必须通过 @BuilderParam 接收子组件。这是因为 ArkUI 的自定义布局回调操作的是组件树中的直接子组件,而 @BuilderParam 恰好将传入的内容作为自定义组件的一级子组件挂载。
@Component
struct CustomLayout {
@Builder
doNothingBuilder() {
};
@BuilderParam builder: () => void = this.doNothingBuilder;
build() {
this.builder()
}
}
doNothingBuilder 是一个空 Builder,作为默认值防止 builder 为 undefined 时报错。build() 方法中调用 this.builder() 将传入的子组件渲染到组件树中,这样 onMeasureSize 和 onPlaceChildren 才能拿到这些子组件。
如果不调用 this.builder(),子组件不会被渲染,两个布局回调中的 children 数组将为空。
九、性能考量
测量是计算密集操作
child.measure() 会触发子组件内部的布局计算。如果一个自定义布局组件包含大量子组件,且测量逻辑复杂(如瀑布流需要维护列高度数组),需要注意性能影响。
在实际项目中,如果子组件数量超过 50 个,建议考虑以下优化:
- 将不需要精确测量的子组件用固定尺寸代替
measure()调用 - 在
onMeasureSize中缓存测量结果,避免onPlaceChildren中重复计算(虽然measureResult已经缓存了,但换行逻辑等仍需重算) - 对于瀑布流等场景,考虑配合
LazyForEach在数据层面做懒加载(但注意自定义布局暂不支持LazyForEach写法,需要用其他方式实现)
避免在布局回调中修改状态
onMeasureSize 和 onPlaceChildren 是布局阶段的回调,不应该在其中修改 @State 变量或触发重新渲染。虽然代码中修改了 this.result,但 result 是普通成员变量而非状态变量,不会触发渲染循环。如果误在回调中修改 @State,会导致布局→渲染→布局的无限循环。
坐标系
child.layout({ x, y }) 中的坐标是相对于自定义组件内部坐标系的,原点在组件左上角。这意味着如果自定义组件本身有 padding 或 margin,子组件的坐标不会受到 padding 影响——padding 影响的是自定义组件自身的边界,而非内部坐标系的原点。
十、常见问题与避坑
1. ForEach 不支持 LazyForEach
自定义布局组件的子组件目前不支持 LazyForEach 写法。如果数据量大,需要在数据层做分页或虚拟列表,将当前可见的子组件通过 ForEach 传入。
2. measure() 约束必须合理
child.measure() 传入的约束必须覆盖子组件可能需要的尺寸范围。如果 maxWidth 设得太小,Text 组件会频繁换行导致高度膨胀;如果 maxHeight 设为 0 或负数,测量结果可能不符合预期。
推荐的做法:宽度方向传 0 ~ containerWidth,高度方向传 0 ~ Infinity,让子组件按内容自适应。除非有明确的尺寸需求(如圆形排列中固定 50×50),否则不要过度约束。
3. onPlaceChildren 中读取 measureResult
onPlaceChildren 的参数类型是 Layoutable,它包含 measureResult 属性。这个属性存储了 onMeasureSize 阶段的测量结果,可以直接读取 measureResult.width 和 measureResult.height,不需要再次调用 measure()。
4. 返回的 SizeResult 影响父组件布局
onMeasureSize 返回的 SizeResult 是自定义组件自身的尺寸,它会影响父组件对自定义组件的布局。如果返回的尺寸过大,可能导致父容器溢出;如果返回的尺寸过小,可能导致子组件被裁剪。
5. offset 属性的特殊处理
官方示例中子组件使用了 .offset({ x: 10, y: 20 })。offset 是在布局完成后的额外偏移,它不影响 onMeasureSize 的测量结果和 onPlaceChildren 的放置位置。子组件的最终位置 = layout() 设置的位置 + offset。
十一、布局效果总览
本文实现了四种自定义布局,覆盖了从简单到复杂的典型场景:
| 布局类型 | 测量策略 | 放置策略 | 适用场景 |
|---|---|---|---|
| 递增 Stack | 固定初始尺寸 + 递增 | 右下角对齐 | 装饰性层叠效果 |
| 流式布局 | 按内容测量 + 换行 | 逐行排列 | 标签云、芯片组 |
| 圆形排列 | 固定尺寸 | 三角函数计算位置 | 头像环绕、菜单环 |
| 瀑布流 | 按列宽测量 | 放入最短列 | 图片流、商品列表 |
十二、总结
onMeasureSize 和 onPlaceChildren 赋予了开发者对布局的完全控制权。掌握这两个回调,意味着在 ArkUI 中没有实现不了的布局——任何排列方式都可以通过自定义测量和放置逻辑来实现。
核心心法只有两条:
- onMeasureSize 负责"量":遍历子组件,调用
child.measure()获取每个子组件的尺寸,根据业务逻辑计算并返回组件自身尺寸 - onPlaceChildren 负责"放":遍历子组件,根据测量结果和业务算法计算每个子组件的位置,调用
child.layout()放置
理解了测量与放置的分离,理解了坐标系和约束传递,剩下的就是纯粹的算法设计——如何根据子组件的尺寸和数量,计算出最优的排列方式。这正是自定义布局的魅力所在:布局不再受限于系统容器的规则,而是由你的算法定义。
推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源:
前言 – 人工智能学习网
推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源:
前言 – 人工智能学习网
推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源:
前言 – 人工智能学习网
更多推荐


所有评论(0)