在这里插入图片描述
推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源
前言 – 人工智能学习网

当 Flex、Column、Row 这些系统容器撑不起你的想象时,ArkUI 留了一扇后门——两个回调函数,让你接管布局的全部控制权。

一、为什么需要自定义布局

HarmonyOS ArkUI 提供了丰富的布局容器:线性布局(Row/Column)、层叠布局(Stack)、弹性布局(Flex)、网格布局(Grid)、相对布局(RelativeContainer)。这些容器覆盖了 90% 以上的日常布局场景。但当遇到以下需求时,系统容器就显得力不从心:

  • 不规则瀑布流:卡片高度不一,需要动态分配到最短列,且列数随屏幕宽度变化
  • 圆形排列:头像围绕中心点均匀分布,间距和半径随子组件数量动态调整
  • 递增/递减尺寸:子组件大小按特定算法递增,且位置与大小联动
  • 自适应折行:标签流式排列,换行时机和间距完全可控,系统 Flex 的间距行为不够灵活

这些场景的共同点是:子组件的尺寸和位置之间存在复杂依赖关系,无法用简单的排列规则描述。ArkUI 为此开放了 onMeasureSizeonPlaceChildren 两个布局回调,让开发者在自定义组件中接管测量与放置的完整逻辑。

二、两个核心回调

自定义布局的本质是把布局过程拆成两步:先测量,后放置。这和 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 包含 widthheight 两个字段,代表自定义组件自身最终占据的尺寸。

onPlaceChildren:放置阶段

onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Array<Layoutable>, constraint: ConstraintSizeOptions): void

测量完成后立即触发。在这里遍历子组件,调用 child.layout({ x, y }) 设置每个子组件相对于自定义组件内部坐标系的位置。

注意参数类型的变化:Measurable 变成了 LayoutableLayoutable 接口在测量结果的基础上增加了 layout() 方法,同时保留了 measureResult 属性,让你可以在放置阶段读取之前测量的尺寸。

执行顺序

父组件布局 → onMeasureSize(测量子组件 + 返回自身尺寸)→ onPlaceChildren(放置子组件位置)→ 子组件渲染

onMeasureSize 一定先于 onPlaceChildren 执行。在 onPlaceChildren 中,每个子组件的 measureResult 已经包含了测量阶段的尺寸数据,可以直接读取。

三、核心类型速查

理解自定义布局,需要先搞清楚涉及的类型关系:

类型 角色 关键方法/属性
GeometryInfo 组件自身信息 widthheightdirection
Measurable 子组件测量接口 measure(constraint): MeasureResult
MeasureResult 测量结果 widthheight
Layoutable 子组件放置接口 layout({x, y})measureResult
ConstraintSizeOptions 尺寸约束 minWidthmaxWidthminHeightmaxHeight
SizeResult 组件自身尺寸 widthheight

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: maxWidthmaxHeight: 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() 将传入的子组件渲染到组件树中,这样 onMeasureSizeonPlaceChildren 才能拿到这些子组件。

如果不调用 this.builder(),子组件不会被渲染,两个布局回调中的 children 数组将为空。

九、性能考量

测量是计算密集操作

child.measure() 会触发子组件内部的布局计算。如果一个自定义布局组件包含大量子组件,且测量逻辑复杂(如瀑布流需要维护列高度数组),需要注意性能影响。

在实际项目中,如果子组件数量超过 50 个,建议考虑以下优化:

  • 将不需要精确测量的子组件用固定尺寸代替 measure() 调用
  • onMeasureSize 中缓存测量结果,避免 onPlaceChildren 中重复计算(虽然 measureResult 已经缓存了,但换行逻辑等仍需重算)
  • 对于瀑布流等场景,考虑配合 LazyForEach 在数据层面做懒加载(但注意自定义布局暂不支持 LazyForEach 写法,需要用其他方式实现)

避免在布局回调中修改状态

onMeasureSizeonPlaceChildren 是布局阶段的回调,不应该在其中修改 @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.widthmeasureResult.height,不需要再次调用 measure()

4. 返回的 SizeResult 影响父组件布局

onMeasureSize 返回的 SizeResult 是自定义组件自身的尺寸,它会影响父组件对自定义组件的布局。如果返回的尺寸过大,可能导致父容器溢出;如果返回的尺寸过小,可能导致子组件被裁剪。

5. offset 属性的特殊处理

官方示例中子组件使用了 .offset({ x: 10, y: 20 })offset 是在布局完成后的额外偏移,它不影响 onMeasureSize 的测量结果和 onPlaceChildren 的放置位置。子组件的最终位置 = layout() 设置的位置 + offset

十一、布局效果总览

本文实现了四种自定义布局,覆盖了从简单到复杂的典型场景:

布局类型 测量策略 放置策略 适用场景
递增 Stack 固定初始尺寸 + 递增 右下角对齐 装饰性层叠效果
流式布局 按内容测量 + 换行 逐行排列 标签云、芯片组
圆形排列 固定尺寸 三角函数计算位置 头像环绕、菜单环
瀑布流 按列宽测量 放入最短列 图片流、商品列表

十二、总结

onMeasureSizeonPlaceChildren 赋予了开发者对布局的完全控制权。掌握这两个回调,意味着在 ArkUI 中没有实现不了的布局——任何排列方式都可以通过自定义测量和放置逻辑来实现。

核心心法只有两条:

  1. onMeasureSize 负责"量":遍历子组件,调用 child.measure() 获取每个子组件的尺寸,根据业务逻辑计算并返回组件自身尺寸
  2. onPlaceChildren 负责"放":遍历子组件,根据测量结果和业务算法计算每个子组件的位置,调用 child.layout() 放置

理解了测量与放置的分离,理解了坐标系和约束传递,剩下的就是纯粹的算法设计——如何根据子组件的尺寸和数量,计算出最优的排列方式。这正是自定义布局的魅力所在:布局不再受限于系统容器的规则,而是由你的算法定义。

推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源
前言 – 人工智能学习网
推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源
前言 – 人工智能学习网
推荐大家体验用AI编程,人工智能学习小站如下,已整理好相应高质量资源
前言 – 人工智能学习网

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐