本文基于 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 调用的累积结果。这种模型有三个工程特征:

  1. 引用持久:开发者持有 View 实例,可以随时改动
  2. 状态可变:UI 的属性是可变字段,setter 直接改字段
  3. 命令式更新:改 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 运行时负责根据声明生成实例、管理生命周期、在数据变化时重新执行声明并更新实例。

三个对应的工程特征:

  1. 无引用持久:开发者拿不到 UI 实例引用,所有改动靠"重新声明"
  2. 状态驱动:UI 的属性是"当前数据的状态投影",数据变 UI 自动变
  3. 声明式更新:改 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)
  })
}

ifbuild() 里是控制流——每次重新执行都会重新判断 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() 内只有三类操作:

  1. 读取 @Statethis.currentTabthis.activeCategory
  2. 调用派生方法this.getFilteredMaintenance()this.getBarWidth(...)
  3. 声明 UIText(...)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 内定义了一个"可复用的渲染片段",但它:

  • 共享宿主的 @StateMaintenanceCard 内可以读 this.currentTabthis.activeCategory,不需要 props 透传
  • 没有独立生命周期aboutToAppear / aboutToDisappear 等生命周期是 struct 级的,Builder 没有独立生命周期
  • 参数是方法参数不是 propsitem: MaintenanceItem 是方法调用的参数,不是 React 的 props 对象

3.2 @Builder 的调用语义

调用 @Builderthis.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 特有约束:

  1. 参数类型要具体:不能用泛型(<T>),每个 Builder 都要具体类型参数
  2. 资源类型可以透传Resource 是 ArkUI 内置类型,$r(...) 返回 Resource,可以直接当参数传
  3. 参数求值时机:参数在 Builder 调用时求值,不是在渲染时延迟求值。这意味着传 this.getBarWidth(item.intervalKm, 100000) 时,方法会立即执行,结果字符串传给 Builder

3.4 @Builder 复用的边界

什么场景适合抽 @Builder?三个判据:

  1. 形状稳定:渲染的容器结构不随数据变化。本应用的 MaintenanceCardPartCardLegendDot 都是固定形状,适合 Builder。
  2. 数据流入简单:参数少且类型明确。LegendDot(color, label) 两个参数,MaintenanceCard(item) 一个对象参数,都简单。
  3. 复用次数足够:哪怕一次也抽 Builder 不浪费(内联),但多次复用更显价值。本应用 MaintenanceCard 复用 20 次,LegendDot 复用 2 次。

什么场景不适合 Builder?

  1. 形状随数据剧变:如果卡片有时有图、有时没图、有时有按钮、有时没按钮,Builder 内要一堆 if 分支,反而不如直接写在调用点
  2. 需要独立状态:如果片段有自己的 @State,应该抽成子 @Component struct,不是 Builder
  3. 需要独立生命周期:如果片段要监听 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 的渲染装饰器,不是数组方法。关键差异:

  1. 返回值不是数组ForEach 不返回 JavaScript 数组,而是在渲染树里生成多个 UI 节点
  2. 键值是核心ForEach 的完整签名是 ForEach(arr, itemGenerator, keyGenerator?),键值决定如何匹配新旧节点
  3. 执行时机受调度控制ForEach 的回调在渲染时执行,不是在数组变化时

4.2 ForEach 的键值策略

ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => {
  this.MaintenanceCard(item)
})

本应用没有显式传 keyGenerator,意味着用默认的索引键。ArkUI 用"数组下标"做键。

这对静态数据集是安全的——数组顺序稳定,每个下标对应的项目恒定。但如果数据动态变化,索引键会失效:

场景 索引键的问题
数组插入新项到头部 后续所有下标对应的节点都错位,全部重渲染
数组删除某项 后续下标对应的节点都错位,全部重渲染
数组重排序 下标对应错位,全部重渲染
数组末尾追加 只新增最后一个,不影响前面,安全

本应用的所有 ForEach 都用于静态数据集(tabscategoriesgetFilteredMaintenance()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

这有两个含义:

  1. Tab 切换销毁重建:从 Tab 0 切到 Tab 1,Tab 0 的 ForEach 整体销毁(20 张 MaintenanceCard 销毁),Tab 1 的 ForEach 整体创建(20 张 PartCard 创建)
  2. 分类筛选只重渲染数据源:分类变化时,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 和三元表达式都能做条件渲染,取舍看"是否生成节点":

  • 要/不要生成节点:用 ifif (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() 是一个深度嵌套的递归结构——每个容器(ColumnRowScroll)内部又包含子节点,子节点可能是另一个容器或叶子节点(TextBlank)。

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() 内的缩进就会让代码可读性下降。本应用的应对策略:

  1. 抽 @Builder 减少主 build() 深度:每张卡片的 8 层嵌套抽成 MaintenanceCard Builder,主 build() 里只一行 this.MaintenanceCard(item),主结构深度降到 4-5 层
  2. 用链式 API 平铺属性:每个节点的属性 API 链式调用,垂直排列,避免单行过长
  3. 注释标段// 顶部标题// 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 的渲染机制。关键点归纳:

  1. build() 是表达式不是构造器:每次重新执行都生成一份"UI 应该是什么样子"的描述,运行时拿描述去同步实例
  2. @Builder 是渲染片段不是组件:共享宿主 @State,没有独立生命周期,调用即内联
  3. ForEach 的键值是核心:静态数据用索引键安全,动态数据必须显式 keyGenerator
  4. if 是控制流不是节点切换:ArkUI 不强调 diff,if 变了对应分支直接销毁/创建
  5. 嵌套深度靠 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 配置的工程实战。

Logo

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

更多推荐