鸿蒙原生 ArkTS 自定义布局深度解析:onMeasure / onLayout 实战
一、引言
ArkTS 提供了Column、Row、Stack、Flex、Grid等内置布局容器,覆盖绝大多数日常场景。但当你需要非标准排列规则时——比如标签云自动换行、瀑布流、可拖拽仪表盘、环形菜单——内置布局就力不从心了。这时需要深入布局引擎内部,通过onMeasure和onLayout两个核心生命周期方法,亲手掌控"测量"与"放置"的全过程。
本文以一个错落式流式布局(Staggered FlowLayout)为实战案例,从零讲解 HarmonyOS NEXT 中自定义布局的实现,并深入剖析两阶段布局底层原理。
二、布局底层原理:两阶段模型
ArkUI 渲染管线中,组件从数据到屏幕像素经历三个阶段:
Build(构建) → Layout(布局) → Render(绘制)Layout 阶段又细分为两个子阶段:
Layout ├── ❶ onMeasure(测量) │ ├─ 父节点传入 LayoutConstraint(约束) │ ├─ 依次调用每个子节点的 measure() │ └─ 调用 setMeasuredSize() 确定自身尺寸 │ └── ❷ onLayout(放置) ├─ 根据测量结果为每个子节点计算位置 └─ 依次调用每个子节点的 layout(Position)为什么需要两个阶段?
这是布局领域经典决策——先测量,后放置。原因有二:
原因一:子组件尺寸可能依赖父容器约束。比如Text组件设为width('100%'),它需要知道父容器多宽才能确定自己多宽。若父容器也是自适应模式,就会形成循环依赖。两阶段模型通过"自上而下传约束、自下而上汇报尺寸"完美解决。
原因二:父容器尺寸可能依赖所有子组件尺寸之和。流式布局中,父容器必须先测量所有子组件宽度,才能决定"一行放几个"和"总高度是多少"。
LayoutConstraint:约束即契约
LayoutConstraint { maxSize: Size // 父容器允许的最大尺寸 minSize: Size // 父容器要求的最小尺寸 percentReference: Size // 百分比参考尺寸 }三种约束模式:
| 模式 | 含义 | 场景 |
|---|---|---|
| EXACTLY | 精确尺寸 | 固定宽高的组件 |
| AT_MOST | 最大尺寸 | wrap_content 但有限制 |
| UNSPECIFIED | 不限制 | 可滚动容器内部 |
三、实战:错落式流式布局
目标布局规则:
- 子组件从左到右排列,放满一行自动换行;
- 偶数索引子组件 Y 轴下移 8px,奇数索引上移 8px;
- 容器高度自适应。
3.1 架构设计
采用声明式架构 + 自定义布局引擎策略,不直接继承FrameNode:
CustomLayoutDemo (@Entry @Component) ├── CustomLayoutEngine(纯逻辑类) │ ├─ measure(constraint) → 模拟 onMeasure │ ├─ layout(width) → 模拟 onLayout │ └─ childSizes / childPositions └── Stack(position 模式) → 声明式容器 ├─ Card 0(engine 提供坐标) ├─ Card 1(engine 提供坐标) └─ ...优势:逻辑与视图分离,纯 TS 类便于单测;声明式语法编译器和 IDE 支持良好。
3.2 数据模型
interfaceMeasureSize{width:number;height:number;}interfaceLayoutPosition{x:number;y:number;}interfaceLayoutConstraint{maxWidth:number;maxHeight:number;}interfaceCardItem{bgColor:ResourceColor;label:string;}interfaceLayoutItemData{position:LayoutPosition;card:CardItem;size:MeasureSize;index:number;}为何不直接用框架的Size/Position?因为FrameNodeAPI 的属性是Length(number|string),而我们的引擎只需要纯数字计算,轻量接口更简洁。
3.3 实现布局引擎
阶段一:measure(对应 onMeasure)
measure(constraint:LayoutConstraint,childCount:number,childTexts:string[]):void{this.myConstraint=constraint;this.childSizes=[];constavailableWidth=constraint.maxWidth-PADDING*2;letcursorX=PADDING,cursorY=PADDING,rowMaxHeight=0,maxUsedWidth=PADDING;for(leti=0;i<childCount;i++){constchildW=Math.min((childTexts[i]?.length||8)*10,availableWidth);constchildH=CHILD_HEIGHT;this.childSizes.push({width:childW,height:childH});// 换行if(cursorX+childW>availableWidth+PADDING&&cursorX>PADDING){cursorX=PADDING;cursorY+=rowMaxHeight+VERTICAL_GAP;rowMaxHeight=0;}rowMaxHeight=Math.max(rowMaxHeight,childH);cursorX+=childW+HORIZONTAL_GAP;maxUsedWidth=Math.max(maxUsedWidth,cursorX-HORIZONTAL_GAP+PADDING);}// 对应 setMeasuredSize()this.totalWidth=Math.min(maxUsedWidth,constraint.maxWidth);this.totalHeight=cursorY+rowMaxHeight+PADDING;}关键逻辑:
- 遍历测量:为每个子组件计算期望尺寸(生产环境应用
MeasureText精确测量); - 换行策略:当前行剩余空间不足时换行;
- 确定容器尺寸:当父约束为 AT_MOST 时取内容宽度,EXACTLY 时取约束宽度。
阶段二:layout(对应 onLayout)
layout(containerWidth:number):LayoutPosition[]{this.childPositions=[];letcursorX=PADDING,cursorY=PADDING,rowMaxHeight=0,rowStartIndex=0;for(leti=0;i<this.myChildCount;i++){const{width:childW,height:childH}=this.childSizes[i]||{width:0,height:0};if(!this.childSizes[i])continue;// 换行if(cursorX+childW>containerWidth-PADDING&&cursorX>PADDING){this.applyStaggerOffset(rowStartIndex,i-1,cursorY,rowMaxHeight);cursorX=PADDING;cursorY+=rowMaxHeight+VERTICAL_GAP;rowMaxHeight=0;rowStartIndex=i;}rowMaxHeight=Math.max(rowMaxHeight,childH);this.childPositions.push({x:cursorX,y:cursorY+(rowMaxHeight-childH)/2});cursorX+=childW+HORIZONTAL_GAP;}this.applyStaggerOffset(rowStartIndex,this.myChildCount-1,cursorY,rowMaxHeight);returnthis.childPositions;}layout()与measure()高度对称——同样的排列策略在两个阶段各执行一次,这是两阶段布局的设计哲学。
点睛之笔:错落偏移
privateapplyStaggerOffset(start:number,end:number,rowY:number,rowH:number):void{for(leti=start;i<=end;i++){constpos=this.childPositions[i];constsize=this.childSizes[i];if(!pos||!size)continue;pos.y=rowY+(rowH-size.height)/2+(i%2===0?8:-8);}}这就是自定义布局的"签名"——偶数下移、奇数上移,产生错落视觉效果,让观察者一眼看出这不是默认布局。
四、衔接声明式 UI
4.1 Stack + position 模式
Stack(){ForEach(this.getLayoutItems(),(item:LayoutItemData)=>{this.buildLayoutCard(item)},(item:LayoutItemData)=>item.index.toString())}.width(this.containerWidth).height(this.containerHeight).clip(true).position()即是标准 API 中child.layout(Position)的声明式等价物。
4.2 buildLayoutCard
@BuilderbuildLayoutCard(item:LayoutItemData):void{Stack(){Text(item.card.label).fontSize(12).fontColor('#FFFFFFFF').width('100%').height('100%')Text(item.index%2===0?'V 偶数':'^ 奇数').fontSize(9).fontColor(item.index%2===0?'#FF4CAF50':'#FFFF5252').position({x:4,y:2})}.width(item.size.width).height(item.size.height).backgroundColor(item.card.bgColor).borderRadius(8).shadow({radius:4,color:'#33000000',offsetX:1,offsetY:2}).position({x:item.position.x,y:item.position.y})// ← 关键}4.3 响应布局变化
.onAreaChange((_oldValue:Area,newValue:Area)=>{constnewW=newValue.widthasnumber;if(newW>0&&Math.abs(newW-360)>1){this.performLayout(newW);}})新尺寸传入performLayout→ 调用engine.measure()+engine.layout()→ 更新@State→ 触发 UI 重渲染。
五、最佳实践与常见问题
5.1 何时使用自定义布局
| 应该使用 | 不应该使用 |
|---|---|
| 排列规则非标准 | Row / Column / Flex 能搞定 |
| 需精确控制每个坐标 | 只需简单对齐和间距 |
| 布局规则动态计算 | 布局静态 |
| 子组件中等数量 (<200) | 大量子组件(应使用 LazyForEach) |
5.2 性能优化
① 避免 measure 中重计算。onMeasure可能被频繁调用,不应包含 I/O、网络或复杂数据处理。
② 用 LazyForEach 代替 ForEach。超过 20 个子组件时,确保只有可见区域才被布局和渲染。
③ 缓存测量结果。布局规则短时间不变时,缓存上次结果,跳过重复测量。
5.3 常见陷阱
陷阱 1:忘记调用 setMeasuredSize。会导致容器尺寸为 0,UI 完全不显示。
陷阱 2:measure 和 layout 排版逻辑不一致。导致子组件位置错乱。将排版逻辑抽为独立方法,在 measure 和 layout 中共用。
陷阱 3:未考虑子组件的 margin。须通过getUserConfigMargin()获取 margin 值,在计算位置时纳入考量。
六、扩展:超越流式布局
掌握原理后,可以构建几乎任何布局形态:
环形布局
for(leti=0;i<childCount;i++){constangle=(i/childCount)*2*Math.PI;positions.push({x:cx+r*Math.cos(angle)-childW/2,y:cy+r*Math.sin(angle)-childH/2});}瀑布流布局
constcolumnHeights=newArray(columnCount).fill(PADDING);for(leti=0;i<childCount;i++){constminCol=argmin(columnHeights);columnHeights[minCol]+=childSizes[i].height+GAP;positions[i]={x:colX[minCol],y:columnHeights[minCol]};}自定义响应式网格
结合onAreaChange获取容器宽度,动态计算列数,实现类似 CSS Grid 的auto-fill效果。
七、总结
本文通过错落式流式布局实战,深入剖析了 HarmonyOS NEXT 自定义布局的两阶段模型:
onMeasure:父子组件的"契约谈判"。父传约束,子报尺寸,父综合确定自身尺寸。onLayout:根据测量结果为每个子组件分配 (x, y) 坐标,完成排兵布阵。Stack + position模式:将布局引擎结果映射到声明式 UI 的标准方法论。
自定义布局是鸿蒙应用开发的"高阶技能",但理解"先测量后放置"这一基本原则后,你就能从内置布局的局限中解放出来,自由构建任何想要的 UI 形态。
附录:源码结构
CustomLayoutDemo.ets(约 574 行) ├── 常量定义 & 接口 ├── CustomLayoutEngine 类 │ ├─ measure() / layout() / applyStaggerOffset() ├── @Entry @Component CustomLayoutDemo │ ├─ 状态声明 & performLayout() │ ├─ build() │ └─ @Builder 方法群`