ArkUI 声明式 UI 深度解析:从 build() 到 @Builder 复用
本文基于 HarmonyOS Next API 24+,深入「车辆保养手册」应用的渲染机制。我们将从
build()的执行模型入手,讲透声明式 UI 与命令式 UI 的本质差异、@Builder的复用边界与陷阱、ForEach的渲染调度、if控制流与条件渲染的区分,以及组件树递归生成的工程约束。


一、声明式 UI 与命令式 UI 的本质差异
1.1 命令式 UI 的操作模型
命令式 UI(如 Android View、iOS UIKit、传统 Win32)的核心操作是"创建 → 持有 → 改写":
// 命令式伪代码
TextView titleView = new TextView();
titleView.setText("车辆保养手册");
titleView.setTextSize(22);
titleView.setTextColor(Color.BLACK);
parentView.addView(titleView);
// 后来要改字
titleView.setText("配件年限");
开发者持有 titleView 引用,可以随时改它的属性。UI 的"当前状态"是一系列显式 setter 调用的累积结果。这种模型有三个工程特征:
- 引用持久:开发者持有 View 实例,可以随时改动
- 状态可变:UI 的属性是可变字段,setter 直接改字段
- 命令式更新:改 UI 必须显式调用 setter,不会自动跟随数据
1.2 声明式 UI 的表达模型
ArkUI 的声明式 UI 完全反过来——开发者不持有 UI 实例引用,只声明"UI 应该是什么样子":
Text('车辆保养手册')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
这段代码不是"创建一个 Text 实例并设置属性",而是"声明这里有一个 Text,它的字是 22、字重 Bold、字色 text_primary"。ArkUI 运行时负责根据声明生成实例、管理生命周期、在数据变化时重新执行声明并更新实例。
三个对应的工程特征:
- 无引用持久:开发者拿不到 UI 实例引用,所有改动靠"重新声明"
- 状态驱动:UI 的属性是"当前数据的状态投影",数据变 UI 自动变
- 声明式更新:改 UI 只要改数据,重新执行
build()自动同步
1.3 build() 是表达式不是构造器
这是声明式 UI 最关键的认知转换。命令式思维会把 build() 当成"一次性的构造器"——执行完就生成了 UI 实例,后续要改实例。
但 ArkUI 的 build() 是一个表达式——它每次执行都生成一份"UI 应该是什么样子"的描述,ArkUI 运行时拿这份描述去对已经存在的 UI 实例做差异更新。
本应用里 build() 会被反复执行:
currentTab改变 →build()重新执行 → Tab 高亮、内容列表、计数 chip 同步刷新activeCategory改变 →build()重新执行 → 分类筛选 chip、内容列表、计数 chip 同步刷新
每次重新执行,所有 Text(...)、Row(...) 这些声明都会重新求值。如果数据依赖了 @State,新的值会被自然带进 UI;如果不依赖,UI 就保持原状(因为表达式结果一样)。
二、build() 的执行模型
2.1 重新执行的粒度
ArkUI 的重渲染不是"整个 build() 从头跑一遍",而是"依赖变化的片段重新执行"。@State 装饰器在运行时收集依赖:
currentTab被读取的位置:Tab 栏条件表达式、内容列表 if 分支、计数 chip 三元表达式activeCategory被读取的位置:分类筛选 chip 条件表达式、getFilteredMaintenance/getFilteredParts调用、内容列表的 ForEach 数据源
当 currentTab 变化时,只有这三处重新执行;其他不依赖 currentTab 的部分(如顶部标题的固定文字)不重渲染。
这是 ArkUI 性能的红利——精确的依赖追踪让重渲染的粒度尽量小。但开发者不能假设"某段代码不会重执行"——只要它读取了 @State,就有可能在状态变化时重新跑。
2.2 build() 内的控制流
if (this.currentTab === 0) {
ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => {
this.MaintenanceCard(item)
})
} else {
ForEach(this.getFilteredParts(), (item: PartItem) => {
this.PartCard(item)
})
}
if 在 build() 里是控制流——每次重新执行都会重新判断 this.currentTab === 0,根据当前值选择哪个分支渲染。
这与 React 的条件渲染模型差异明显。React 里 if 在 render 函数里也是控制流,但 React 用虚拟 DOM diff 比较"上次渲染"和"这次渲染",决定更新哪些实例。ArkUI 不强调"diff"机制,而是直接重新执行声明——if 分支变了,对应分支的渲染重新跑,旧分支的 UI 实例被销毁,新分支的 UI 实例被创建。
对本应用来说,Tab 切换意味着 20 张 MaintenanceCard 销毁、20 张 PartCard 创建(如果分类是"全部")。这是个相对重的操作,但 Tab 切换是低频交互(用户切换 Tab 的频率远低于滚动列表),开销可以接受。
2.3 build() 内的不可变约束
build() 是渲染声明,不是逻辑执行场。它内部不应有任何副作用:
- 不应改
@State:会触发循环渲染 - 不应做 IO 操作:会阻塞渲染线程
- 不应改外部变量:破坏纯度
本应用的 build() 内只有三类操作:
- 读取
@State:this.currentTab、this.activeCategory - 调用派生方法:
this.getFilteredMaintenance()、this.getBarWidth(...) - 声明 UI:
Text(...)、Row(...)、ForEach(...)、this.MaintenanceCard(item)
派生方法是关键——它们是纯函数(不改状态、不做 IO、相同入参相同出参),可以在 build() 里安全调用。如果把过滤逻辑改成"读文件"或"发请求",就要把数据搬到状态层,在生命周期里异步加载。
三、@Builder 的复用机制
3.1 @Builder 不是组件
ArkUI 的 @Builder 与 React 的函数组件、Vue 的单文件组件有本质差异。@Builder 不是一个独立的渲染单元,而是宿主 struct 的"渲染片段"。
@Builder
MaintenanceCard(item: MaintenanceItem) {
Column() {
// ... 卡片内容
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
}
这段代码在 struct 内定义了一个"可复用的渲染片段",但它:
- 共享宿主的 @State:
MaintenanceCard内可以读this.currentTab、this.activeCategory,不需要 props 透传 - 没有独立生命周期:
aboutToAppear/aboutToDisappear等生命周期是 struct 级的,Builder 没有独立生命周期 - 参数是方法参数不是 props:
item: MaintenanceItem是方法调用的参数,不是 React 的 props 对象
3.2 @Builder 的调用语义
调用 @Builder 用 this.XXX(args):
ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => {
this.MaintenanceCard(item)
})
这个调用不是"创建一个子组件实例",而是"在当前位置插入这段渲染"。 ArkUI 运行时把 Builder 的内容内联到调用点,与直接写 Column() { ... } 等价。
这是 Builder 与 React 组件的根本差异。React 里 <Card item={item} /> 创建一个独立的 Card 实例,有自己的状态、自己的生命周期;ArkUI 里 this.MaintenanceCard(item) 内联到调用点,没有独立实例概念。
3.3 Builder 参数的传递约束
@Builder
LegendDot(color: Resource, label: string) {
Row({ space: 4 }) {
Column()
.width(8)
.height(8)
.backgroundColor(color)
.borderRadius(4)
Text(label)
.fontSize(11)
.fontColor($r('app.color.text_hint'))
}
}
调用处:
this.LegendDot($r('app.color.primary'), '里程指标')
this.LegendDot($r('app.color.accent'), '时间指标')
Builder 参数传递有几个 ArkUI 特有约束:
- 参数类型要具体:不能用泛型(
<T>),每个 Builder 都要具体类型参数 - 资源类型可以透传:
Resource是 ArkUI 内置类型,$r(...)返回Resource,可以直接当参数传 - 参数求值时机:参数在 Builder 调用时求值,不是在渲染时延迟求值。这意味着传
this.getBarWidth(item.intervalKm, 100000)时,方法会立即执行,结果字符串传给 Builder
3.4 @Builder 复用的边界
什么场景适合抽 @Builder?三个判据:
- 形状稳定:渲染的容器结构不随数据变化。本应用的
MaintenanceCard、PartCard、LegendDot都是固定形状,适合 Builder。 - 数据流入简单:参数少且类型明确。
LegendDot(color, label)两个参数,MaintenanceCard(item)一个对象参数,都简单。 - 复用次数足够:哪怕一次也抽 Builder 不浪费(内联),但多次复用更显价值。本应用
MaintenanceCard复用 20 次,LegendDot复用 2 次。
什么场景不适合 Builder?
- 形状随数据剧变:如果卡片有时有图、有时没图、有时有按钮、有时没按钮,Builder 内要一堆
if分支,反而不如直接写在调用点 - 需要独立状态:如果片段有自己的
@State,应该抽成子@Component struct,不是 Builder - 需要独立生命周期:如果片段要监听
aboutToAppear,抽子 struct
本应用的所有复用片段都满足"形状稳定 + 数据简单",因此 Builder 是最佳选择。
四、ForEach 的渲染调度
4.1 ForEach 不是 map
ForEach(this.tabs, (tab: string, index?: number) => {
Text(tab)
.fontSize(15)
.fontWeight(this.currentTab === index ? FontWeight.Bold : FontWeight.Normal)
...
})
ForEach 在表面上像 JavaScript 的 array.map,但它是 ArkUI 的渲染装饰器,不是数组方法。关键差异:
- 返回值不是数组:
ForEach不返回 JavaScript 数组,而是在渲染树里生成多个 UI 节点 - 键值是核心:
ForEach的完整签名是ForEach(arr, itemGenerator, keyGenerator?),键值决定如何匹配新旧节点 - 执行时机受调度控制:
ForEach的回调在渲染时执行,不是在数组变化时
4.2 ForEach 的键值策略
ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => {
this.MaintenanceCard(item)
})
本应用没有显式传 keyGenerator,意味着用默认的索引键。ArkUI 用"数组下标"做键。
这对静态数据集是安全的——数组顺序稳定,每个下标对应的项目恒定。但如果数据动态变化,索引键会失效:
| 场景 | 索引键的问题 |
|---|---|
| 数组插入新项到头部 | 后续所有下标对应的节点都错位,全部重渲染 |
| 数组删除某项 | 后续下标对应的节点都错位,全部重渲染 |
| 数组重排序 | 下标对应错位,全部重渲染 |
| 数组末尾追加 | 只新增最后一个,不影响前面,安全 |
本应用的所有 ForEach 都用于静态数据集(tabs、categories、getFilteredMaintenance()、getFilteredParts()),不存在插入/删除/重排序,索引键安全。
如果要工程化(例如未来扩展支持用户自定义保养项),就要显式传 keyGenerator:
ForEach(this.getFilteredMaintenance(),
(item: MaintenanceItem) => { this.MaintenanceCard(item) },
(item: MaintenanceItem) => item.name // 用项目名做唯一键
)
键值稳定是 ArkUI ForEach 性能与状态保持的关键。稳定键让 ForEach 只重渲染"真正变化的项",不稳定键会导致全量重渲染。
4.3 ForEach 与 if 的组合
if (this.currentTab === 0) {
ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => {
this.MaintenanceCard(item)
})
} else {
ForEach(this.getFilteredParts(), (item: PartItem) => {
this.PartCard(item)
})
}
if 包裹 ForEach 是 ArkUI 的标准模式——条件分支决定渲染哪个 ForEach。
这有两个含义:
- Tab 切换销毁重建:从 Tab 0 切到 Tab 1,Tab 0 的 ForEach 整体销毁(20 张 MaintenanceCard 销毁),Tab 1 的 ForEach 整体创建(20 张 PartCard 创建)
- 分类筛选只重渲染数据源:分类变化时,
if分支不变,只是ForEach的数据源变了,ArkUI 会根据键值比较新旧数组,重渲染差异项
第二种场景的开销远小于第一种。这也是为什么分类筛选 chip 设计成"快速切换"——每次切换只让数据源变,不让 if 分支变,开销最小。
五、if 控制流与条件渲染的区分
5.1 ArkUI 的 if 是控制流
if (index === 0) {
Text('·')
.fontColor($r('app.color.divider'))
.margin({ left: 6, right: 6 })
}
在 ArkUI 的 build() 内,if 是合法的控制流语法。它每次重新执行都会重新判断,根据当前值决定是否生成对应 UI 节点。
这与 React 的"条件渲染"概念有微妙差异。React 里 if 在 render 函数里也是控制流,但 React 强调"虚拟 DOM diff 比较"——if 变了,diff 算法决定哪些实例复用、哪些销毁。ArkUI 不强调 diff,而是直接重新执行——if 变了,对应分支的 UI 实例直接销毁/创建。
5.2 build() 内 if 的语义
ArkUI build() 内的 if 有两种典型用法:
条件生成节点
if (index === 0) {
Text('·')
.fontColor($r('app.color.divider'))
.margin({ left: 6, right: 6 })
}
只在某些条件下生成 UI 节点。本应用用这个模式只在第一个 Tab 后渲染分隔符 ·。
条件分支选择
if (this.currentTab === 0) {
// 渲染保养列表
} else {
// 渲染配件列表
}
根据状态选择不同的渲染分支。本应用用这个模式切换两个 Tab 的内容。
5.3 if 与 ternary 的取舍
ArkUI 里 if 和三元表达式都能做条件渲染,取舍看"是否生成节点":
- 要/不要生成节点:用
if。if (cond) { Text(...) }清晰表达"条件成立才有这个节点" - 节点的属性值切换:用三元。
fontSize(cond ? 15 : 13)清晰表达"这个节点永远存在,只是属性值变"
本应用的混合用法:
// if:条件生成分隔符
if (index === 0) {
Text('·')
}
// 三元:属性值切换
.fontWeight(this.currentTab === index ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.currentTab === index ? $r('app.color.primary') : $r('app.color.text_hint'))
if 控制节点存灭,三元控制属性值变。这是 ArkUI 声明式 UI 的惯用法。
六、组件树递归生成的工程约束
6.1 build() 的递归结构
build() {
Column() {
Row() {
Column() {
Text('车辆保养手册')
Text('保养周期 · 配件使用年限')
}
Blank()
Text(...)
}
// ... 更多嵌套
}
}
build() 是一个深度嵌套的递归结构——每个容器(Column、Row、Scroll)内部又包含子节点,子节点可能是另一个容器或叶子节点(Text、Blank)。
ArkUI 的渲染树就是这份嵌套结构的实例化。每个容器是一个布局节点,负责子节点的位置;叶子节点是渲染节点,负责实际绘制。
6.2 嵌套深度的工程约束
本应用的渲染树深度:
Column (根)
└─ Row (顶部标题)
├─ Column (标题文字组)
│ ├─ Text
│ └: ...
│ └: ...
│ ├─ Blank
│ └: ...
│ ├─ Text
│ └: ...
│ ├─ Row (Tab栏)
│ │ └: ...
│ │ └: ...
│ │ ├─ Row (分类筛选)
│ │ │ └: ...
│ │ │ └: ...
│ │ │ ├─ Scroll (内容列表)
│ │ │ │ └: ...
│ │ │ │ └: ...
│ │ │ │ ├─ Column (列表容器)
│ │ │ │ │ └: ...
│ │ │ │ │ └: ...
│ │ │ │ │ ├─ ForEach → MaintenanceCard
│ │ │ │ │ │ └: ...
│ │ │ │ │ │ └: ...
│ │ │ │ │ │ ├─ Column (卡片根)
│ │ │ │ │ │ │ ├─ Row (卡片顶部)
│ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ ├─ Column (色条)
│ │ │ │ │ │ │ │ ├─ Column (标题组)
│ │ │ │ │ │ │ │ ├─ Blank
│ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ ├─ Column (数值组)
│ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ ├─ Row (进度条)
│ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ ├─ Column (里程条)
│ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ │ ├─ Text
│ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ │ ├─ Stack
│ │ │ │ │ │ │ │ │ │ │ ├─ Column (背景条)
│ │ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ │ └: ...
│ │ │ │ │ │ │ │ │ └: ...
最深路径约 12 层(根到进度条内的 Stack 子节点)。20 张 MaintenanceCard 同时渲染,意味着渲染树有约 20 × 8 + 共享部分 = 200+ 节点。这对 ArkUI 来说是中等规模,完全在性能舒适区。
6.3 嵌套结构的可读性维护
渲染树深度一旦超过 5 层,build() 内的缩进就会让代码可读性下降。本应用的应对策略:
- 抽 @Builder 减少主 build() 深度:每张卡片的 8 层嵌套抽成
MaintenanceCardBuilder,主build()里只一行this.MaintenanceCard(item),主结构深度降到 4-5 层 - 用链式 API 平铺属性:每个节点的属性 API 链式调用,垂直排列,避免单行过长
- 注释标段:
// 顶部标题、// Tab栏、// 分类筛选、// 内容列表这些注释把主build()切成视觉段
build() {
Column() {
// 顶部标题
Row() { ... }
// Tab栏
Row() { ... }
// 分类筛选
Scroll() { ... }
// 内容列表
Scroll() {
Column({ space: 10 }) {
if (this.currentTab === 0) {
ForEach(...) { this.MaintenanceCard(item) }
} else {
ForEach(...) { this.PartCard(item) }
}
// 底部图例
Row({ space: 16 }) { ... }
}
}.layoutWeight(1)
}
.height('100%')
}
主 build() 4 个视觉段,每段都对应一个功能区域,阅读时按段定位。这是大型 build() 的可读性惯用法——用注释切视觉段、用 Builder 平深度、用链式 API 平属性。
七、ArkUI 与 React 的渲染模型对比
7.1 状态更新触发机制
| 维度 | ArkUI | React |
|---|---|---|
| 状态声明 | @State x: T = init |
const [x, setX] = useState(init) |
| 状态更新 | this.x = newValue 直接赋值 |
setX(newValue) 调用 setter |
| 重渲染触发 | 装饰器自动追踪赋值 | setter 调用触发 |
| 依赖收集 | 运行时自动追踪 build() 内读取 |
Hooks 顺序推断 |
ArkUI 的"直接赋值即触发"更接近直觉,但阅读时要留意"任何对 @State 的赋值都会触发重渲染"。React 的 setter 模式有显式提示但样板代码多。
7.2 渲染树对比机制
| 维度 | ArkUI | React |
|---|---|---|
| diff 算法 | 不强调 diff,重新执行声明 | 虚拟 DOM diff |
| 节点复用 | 同位置同类型节点复用实例 | 同位置同类型节点复用实例 |
| 键值作用 | ForEach 的 keyGenerator 决定匹配 | 列表 key 决定匹配 |
本应用 Tab 切换时,两个分支的 ForEach 类型不同(MaintenanceItem vs PartItem),ArkUI 直接销毁重建。React 同样会销毁重建(因为组件类型不同),但 React 强调 diff 算法的优化空间;ArkUI 则更直接地"重新执行声明"。
7.3 副作用处理
| 维度 | ArkUI | React |
|---|---|---|
| 副作用入口 | 生命周期 aboutToAppear / aboutToDisappear |
useEffect |
| 副作用依赖 | 手动管理 | 依赖数组自动追踪 |
| build() 内副作用 | 严禁 | 严禁 |
本应用没有任何副作用——所有数据是静态 const,所有交互是同步 onClick。如果改成远端拉取,就要在 aboutToAppear 里加 async fetchData(),把结果赋给 @State。
八、本篇小结
本章从 build() 的执行模型入手,完整覆盖了 ArkUI 声明式 UI 的渲染机制。关键点归纳:
- build() 是表达式不是构造器:每次重新执行都生成一份"UI 应该是什么样子"的描述,运行时拿描述去同步实例
- @Builder 是渲染片段不是组件:共享宿主 @State,没有独立生命周期,调用即内联
- ForEach 的键值是核心:静态数据用索引键安全,动态数据必须显式 keyGenerator
- if 是控制流不是节点切换:ArkUI 不强调 diff,if 变了对应分支直接销毁/创建
- 嵌套深度靠 Builder 平:每张卡片的 8 层嵌套抽成 Builder,主 build() 只剩 4-5 层视觉段
九、附录:本篇涉及的渲染相关装饰器与 API
| API | 用途 | 本篇体现 |
|---|---|---|
@Entry |
标记根组件 | struct Index |
@Component |
标记组件 | struct Index |
@State |
响应式状态 | currentTab, activeCategory |
@Builder |
渲染片段复用 | MaintenanceCard, PartCard, LegendDot |
ForEach |
数组列表渲染 | Tab 栏、分类筛选、内容列表 |
if |
控制流 | Tab 内容切换、分隔符渲染 |
十、附录:本篇涉及的渲染模型对比
| 模型 | ArkUI | React | 命令式 |
|---|---|---|---|
| 状态表达 | @State 装饰器 |
useState Hook |
字段 + setter |
| 重渲染触发 | 直接赋值 | setter 调用 | 手动调用 invalidate |
| 渲染描述 | build() 声明 |
render() / 函数组件 |
显式创建 View |
| 渲染对比 | 重新执行声明 | 虚拟 DOM diff | 不对比 |
| 副作用 | 生命周期 | useEffect |
直接操作 View |
至此完整覆盖了"ArkUI 声明式 UI 渲染机制"的全貌。下一篇将进入 HarmonyOS Next API 24 的资源体系,从 $r 资源引用到 module.json5 配置的工程实战。
更多推荐


所有评论(0)