【HarmonyOS学习笔记】2026-07-19 | 布局性能实验:百分比vs固定值vs预计算
date: 2026-07-19 tags: HarmonyOS, 布局性能, Profiler, 渲染管线, onDidBuild, onAreaChange type: 实验笔记
1️⃣ 现象(发生了什么?)
教材提到:百分比布局在 Measure 阶段存在递归依赖------组件告诉系统"我宽50%",系统得先问父容器"你多宽?",等父容器算完才能折算。而固定值是 Exact 模式,组件直接告诉系统"我宽200",系统不需要额外计算。
我产生了一个疑问:如果我在 JS 层提前算好 screenWidth × 0.9,把具体数字传给控件,是不是就能绕开系统的百分比计算引擎?这个"预计算方案"的效果如何?
更进一步------如果三种写法在实际运行中差异很小,那教材提到的"百分比更慢"到底在什么场景下才值得专门优化?
2️⃣ 解决办法(我是怎么做的?)
实验设计
我设计了一个递归嵌套组件 NestedItem,depth 从 0 递归到 500,每层包含一个 Text、一个 Row(3个子Text)、和下一层 NestedItem。三种模式通过按钮切换:
- 固定值 :
width = 200 - 百分比 :
width = '100%' - 预计算 :
width = screenWidth × 0.9(每层乘 0.9 递减)
为什么选递归嵌套而不是平铺?因为我推测平铺(兄弟节点)是并行计算的,影响不大;只有深层嵌套才会暴露"串行依赖"的累积效应。
代码落地中的踩坑
写实验代码的过程中遇到了不少问题:
@Param 属性名冲突 :我最初用 @Param width: number,编译报错------width 与基类的 width() 方法冲突,number 类型无法赋给函数类型。我改成 @Param itemWidth: number 才通过。类似容易踩坑的名字还有 height、margin、padding、backgroundColor 等。我的做法是加业务前缀。
build() 内部不能有 let/const/return:这是 ArkUI 编译器的硬限制,arkts_check 抓不到,构建时才报。build() 内只能放 UI 组件调用、if/else、ForEach/LazyForEach。所有计算逻辑我移到了 getter 和普通方法中。
px2vp 废弃 :API 18 起废弃了 px2vp(),原因是它是全局函数,多窗口场景不知道该用哪个密度换算。我改用 px / densityPixels,数学上等价。
Date.now() 仅限单元测试 :ArkTS 没有 performance 全局对象,Date.now() 在正式代码中不推荐使用。我换成了 systemDateTime.getTime(false)(同步,毫秒级,需 import @kit.BasicServicesKit)。
display.getDefaultDisplaySync() 可能抛异常:这个同步 API 在获取屏幕信息时可能失败,我加了 try/catch,并在属性声明时给安全默认值 360,在 aboutToAppear 里赋值。
预览器 mock 返回 undefined :预览器里所有系统 API 都是 mock 的,不抛异常但返回 undefined。我的 computed 模式用 disp.width / disp.densityPixels 计算宽度,undefined 参与除法得到 NaN,NaN 传给 width 属性不报错但组件不渲染。我加了 isNaN 检查来兜底。
@ComponentV2 与 @Reusable 不兼容:@Reusable 是 V1 的复用装饰器,V2 目前没有对应机制,只能二选一。我的实验用了 V2 语法,放弃了复用优化。
计时机制的迭代
测"布局渲染耗时"这件事,比我想象的难得多。我前后试了四种方案:
v1:onAreaChange 挂在 wrapper Column 上
我在包裹 NestedItem 的外层 Column 上挂 onAreaChange,切换模式时记录开始时间,onAreaChange 触发时记录结束时间。
结果:不滑动不触发,必须滑动屏幕才有输出。而且同一模式多次测试差异巨大(fixed 从 1396ms 到 3972ms),fixed 比 percent 还慢。
后来我意识到:wrapper Column 的 width='100%' 不随模式切换而改变,onAreaChange 不触发。只有滑动时 Scroll 容器的可视区域变了才间接触发。所以我测到的是"点击按钮到滑动屏幕的操作间隔",不是渲染耗时。
v2:onAreaChange 挂在根 NestedItem 实例上
NestedItem 的根 Column 宽度会随模式变化(fixed=200, percent='100%', computed≈325),切换模式后 onAreaChange 应该立即触发。
真机数据:computed 7-9ms,percent 9-14ms,fixed 13-20ms。数字太小,fixed 又是最慢的,不合理。
我推测:onAreaChange 挂在根上,只代表根节点自己完成了 Measure+Layout,不代表 300 层子树都完成了。布局是自顶向下的,根最先完成,叶子最后完成。
v3:onDidBuild + onLeafDone 回调链
我换成 onDidBuild 生命周期------它在 build() 执行完触发,不受视口限制。叶子节点触发时通过回调链通知页面层。
第一个问题:onDidBuild 只在首次创建时触发,重新渲染不回调。我加了 renderActive 开关------切换模式时先 renderActive=false 销毁整棵树,50ms 后 renderActive=true 重建,onDidBuild 就会重新触发。
真机数据(depth=500):percent 326-341ms,computed 330-334ms,fixed 336ms。三种模式几乎没差异。
这是关键认知:onDidBuild 在 build() 执行完就触发,但 native 的 Measure/Layout 还没开始。330ms 是纯 JS 组件树构建时间,三种模式的 JS 构建成本完全一样,差异在 native Layout 阶段,onDidBuild 测不到。
v4:双标记方案 → 转用 Profiler
我设计了双标记:onDidBuild 测 JS 构建,onSizeChange/onAreaChange 测完整渲染。但最终决定不再代码内打点,而是用 DevEco Profiler 工具做精细拆分。
我把实验代码精简到最纯净状态------去掉所有日志、计时回调,只保留模式切换和 renderActive 重建机制。
3️⃣ 为什么能解决?(刨根问底)
📖 官方怎么说
通过搜索官方文档,我了解到 ArkUI 渲染管线的时序:
scss
State Change → Diff → build() → onDidBuild → Measure → Layout → onSizeChange/onAreaChange → Draw → Display
↑ ↑
JS构建完成 native布局完成
关于几个回调的官方说明:
| 回调 | 触发时机 |
|---|---|
| onDidBuild | build() 执行完后,仅首次创建时触发 |
| onSizeChange | 组件宽高变化时触发,在 Measure+Layout 完成后 |
| onAreaChange | 组件区域(位置+尺寸)变化时触发,在 Measure+Layout 完成后 |
onSizeChange 和 onAreaChange 的区别:onSizeChange 只看尺寸变化,onAreaChange 还看位置变化。onSizeChange 更轻量。
🧠 我的理解
基于这些信息和我的实测,我梳理出几个认知:
onDidBuild ≠ 渲染完成:onDidBuild 在 JS 构建完成时触发,native Measure/Layout 还没开始。如果用 onDidBuild 计时,测到的只是 JS 组件树构建开销,不包含布局算法差异。
onAreaChange/onSizeChange 可以作为布局完成的代理标记:它们在 Measure+Layout 完成后触发。但需要满足条件------组件必须在屏幕可视区域内,且尺寸/位置必须实际发生了变化。首次创建一定算"变化"(从无到有),renderActive 销毁重建也一定触发。
根节点 onAreaChange 不等于子树布局完成:布局是自顶向下的,根最先完成,叶子最后完成。测根节点只能知道根自己完成了,不知道子树是否也完成了。
onAreaChange 在不变的 wrapper 上不触发 :我挂在 width='100%' 的 Column 上,它的区域不随模式变化,所以不触发。这是一个容易踩的坑。
JS 创建开销远大于布局算法差异:在 500 层递归组件的场景下,JS 创建开销约 330ms,而布局算法的差异可能在微秒级,信噪比太低。
4️⃣ 验证
Profiler 实测数据(depth=500,三种模式独立录制)
根 Column(depth=0,包含 500 层子树累积):
| 指标 | 自计算 | 百分比 | 固定值 |
|---|---|---|---|
| BuildDuration | 18μs | 35μs | 22μs |
| MeasureDuration | N/A | N/A | N/A |
| LayoutDuration | 480ms | 467ms | 516ms |
| DrawDuration | 21ms | 22ms | 22ms |
| 总Duration | 501ms | 489ms | 538ms |
Summary 汇总(Column 组件,503 个):
| 指标 | 自计算 | 百分比 | 固定值 |
|---|---|---|---|
| 总 MeasureDuration | 192ms | 181ms | 203ms |
| 总 LayoutDuration | 502ms | 490ms | 540ms |
| 总 DrawDuration | 200ms | 191ms | 213ms |
数据解读
三种模式没有显著差异。固定值总 Duration 538ms 反而最慢,百分比 489ms 反而最快,差异约 10%------我认为这在 Profiler 采样精度范围内,不能断言任何一种模式更快或更慢。
所有模式的 MeasureDuration 都是 N/A。我推测 Measure 开销可能被归入 LayoutDuration 统计了,但没有找到官方文档确认这一点。
LayoutDuration 包含的是累积时间------depth=0 的 Column LayoutDuration 约 480ms,这包含了 500 层子树的所有布局开销,而不是这一个 Column 自己的布局时间。
JS 组件创建开销(onDidBuild 测到约 330ms)远大于布局算法可能的微秒级差异,信噪比太低,布局算法差异被淹没在噪音中。
1000 层尝试
我试过把深度加到 1000 层,但应用启动不起来,500 层已经是我的设备(Mate 60 Pro)的极限。
5️⃣ 最终结论(我的观察)
| 维度 | 我的观察 |
|---|---|
| 500 层嵌套 | 三种模式无显著差异(Profiler 数据差异在采样精度内) |
| 正常应用(3-5 层) | 差异更不可能感知 |
| 列表场景 | 正常用 LazyForEach 懒加载,不存在同时渲染大量 item 的问题 |
| 预计算方案 | 在我测试的条件下,没有表现出优于百分比布局的性能 |
我不再为此专门做优化,直接用教材推荐的写法就好。
arkts
// 预计算方案的思路(虽然实测没优势,但记录下来作为经历)
aboutToAppear(): void {
try {
const disp: display.Display = display.getDefaultDisplaySync();
const w: number = disp.width / disp.densityPixels;
if (!isNaN(w) && w > 0) {
this.screenWidth = w;
}
} catch (e) {
// 获取失败时用默认值
}
}
getBaseWidth(): number {
if (this.currentMode === 'fixed') return 200;
return this.screenWidth * 0.9; // 预计算:JS层算好具体数字
}
arkts
// 精简后的实验代码(最纯净版,用于 Profiler 录制)
@ComponentV2
struct NestedItem {
@Param depth: number = 0;
@Param itemWidth: number = 0;
@Param mode: string = 'computed';
@Param maxDepth: number = MAX_DEPTH;
get currentWidth(): Length {
if (this.mode === 'fixed') return 200;
if (this.mode === 'percent') return '100%';
return this.itemWidth;
}
get nextWidth(): number {
if (this.mode === 'fixed') return 200;
if (this.mode === 'percent') return this.itemWidth;
return this.itemWidth * 0.9;
}
build() {
Column() {
Text(`深${this.depth}`)
.fontSize(8).height(15).fontColor('#FFF')
Row() {
ForEach([1, 2, 3], (item: number) => {
Text('子')
.width(this.mode === 'percent' ? '30%' : (this.itemWidth * 0.3))
.height(10).backgroundColor('#666').margin(2)
})
}
.justifyContent(FlexAlign.SpaceAround)
.width(this.currentWidth)
if (this.depth < this.maxDepth) {
NestedItem({
depth: this.depth + 1,
itemWidth: this.nextWidth,
mode: this.mode,
maxDepth: this.maxDepth
})
}
}
.width(this.currentWidth)
.backgroundColor(`rgba(0,0,255,${1 - this.depth / this.maxDepth})`)
.border({ width: 1, color: '#FFF' }).padding(2)
}
}
核心观察: 500 层嵌套下三种布局模式差异不显著,正常应用不需要为此优化。"过早优化是万恶之源"这句话在这个实验里得到了验证。
学习小结:从"百分比布局更慢"的疑问出发,设计了 500 层递归嵌套实验,经历了四种计时方案的迭代,最终用 Profiler 实测发现三种模式差异不显著。过程中学会了 DevEco Profiler 的使用,搞清了 ArkUI 渲染管线时序(onDidBuild 在 JS 构建完成时触发,onSizeChange/onAreaChange 在 native Layout 完成后触发),以及一堆 ArkTS 编码规则上的坑。两天的实验,最终结论是------不用为此优化。