HarmonyOS 的 Builder 体系,是 ArkUI 声明式开发里最绕不开、也最容易让人迷糊的一环。很多人把@Builder当成“模板函数”用,一遇到传参不刷新、局部更新失效、模板插槽不会写,就开始踩坑。我这些年做 HarmonyOS 应用,从 API 9 一路做到 API 12,可以说这 5 种 Builder 用法就是日常开发的基础设施。不管是刚接触 ArkTS 的新手,还是已经在写复杂页面的老手,把这套东西理清楚,界面复用的效率至少能翻一倍。
这篇文章我不打算照搬官方文档,而是按我实际项目里的使用场景,把@Builder、@LocalBuilder、@BuilderParam、按值传参、按引用传参这 5 种核心姿势,逐个拆开讲透,包括它们的底层行为、适用边界、性能差异,以及我踩过的坑。
1. Builder 到底是什么:一张图看清 ArkUI 的自定义构建体系
1.1 为什么 ArkUI 需要一个专门的 Builder 机制
很多从 React 或 Vue 转过来的开发者,第一次看到@Builder都会有一个疑问:这不就是个函数吗?直接写一个普通函数返回 UI 组件不就行了?其实不行。ArkUI 的声明式 UI 有一个核心特点:UI 是由状态数据驱动的。普通函数只在调用时执行一次,它不会关心你在页面上改了哪个状态变量,也不知道自己内部用了哪些状态,更没法在状态变化时只刷新自己这块区域。
@Builder的存在就是为了解决这个问题。它是一种被 ArkUI 框架特殊处理的自定义构建函数,框架会分析它内部引用的状态数据,建立依赖关系,当这些数据变化时,自动触发对应 UI 的重新渲染。换句话说,@Builder是“会自己感知状态的 UI 函数”,这是它与普通函数最本质的区别。
1.2 5 种 Builder 的定位与选型总览
我先把这 5 种 Builder 放在一张表里,让大家有个整体印象。后面每一个单独展开。
| Builder 形式 | 定义位置 | 核心特性 | 典型场景 |
|---|---|---|---|
全局@Builder | 组件外部 | 跨组件复用,不依赖组件实例 | 多个页面共用的图标、按钮、状态标签 |
组件内@Builder | 组件结构体内部 | 可访问组件内状态变量 | 复用某个页面内的重复 UI 结构 |
@Builder按值传参 | 通过普通参数传入 | 参数是值的拷贝,调用时定型 | 传入简单类型、不需要响应式更新的内容 |
@Builder按引用传参 | 通过$$声明参数 | 建立双向状态关联,参数变化会刷新 | 需要把状态同步给 Builder 内部使用的场景 |
@LocalBuilder | 组件结构体内部,API 12+ | 使用局部变量,只刷新自身区域 | ForEach 循环中局部项的局部更新 |
@BuilderParam | 组件定义时声明 | 把 UI 片段作为参数传入组件 | 通用模板组件、插槽/自定义内容 |
严格说,传参方式是@Builder的一种能力维度,而不是单独一种 Builder 类型。但实际开发中“按值传”和“按引用传”的行为差异太大了,几乎可以当成两种独立用法来看待,我放在一起讲反而容易糊涂,所以分开说。
2. 基础:全局 @Builder 与组件内 @Builder 怎么选
2.1 全局 @Builder:跨组件复用的最简单方案
全局@Builder的定义位置在组件结构体之外,有点像一个免费的工具函数,任何组件都能直接调用它。下面是个最简单的例子:
// 全局 Builder:定义一个通用的价格标签 @Builder function PriceLabel(price: number, prefix: string = '¥') { Text(`${prefix}${price.toFixed(2)}`) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor('#FF6B00') }在任意组件里调用:
@Entry @Component struct ProductPage { build() { Column() { PriceLabel(99.9) PriceLabel(299, '到手价 ') } } }全局@Builder最大的好处是“零依赖”。它不绑定任何组件实例,逻辑简单,适合放一些纯粹的展示型 UI,比如统一风格的价格文本、状态徽标、空状态插画等。但它有一个天然限制:它无法直接访问某个具体组件内部的@State变量。想读取组件状态,只能通过传参的方式把数据塞进去。所以全局@Builder适合“数据驱动能力不强”的纯 UI 复用,一旦需要响应式更新,就会回到传参问题上。
2.2 组件内 @Builder:绑定局部状态的最佳入口
组件内@Builder定义在struct内部,最大的优势是它可以直接读取当前组件里的@State、@Prop、@Link等状态数据,不需要额外传参,状态变了它会跟着刷新。
@Component struct ProductCard { @State count: number = 1 @Builder CountSelector() { Row({ space: 8 }) { Button('-') .onClick(() => { if (this.count > 1) this.count-- }) Text(`${this.count}`) Button('+') .onClick(() => { this.count++ }) } } build() { Column() { // 组件内 Builder 直接通过 this 调用 this.CountSelector() } } }这里面的关键是this。组件内@Builder的调用必须带this,因为它的执行上下文是当前组件实例。注意,如果在组件内 Builder 里用到了状态变量,这个 Builder 和状态之间就建立了依赖,状态一变化整个 Builder 重新执行。这个特性用好了很顺手,但也是后面所有“性能坑”的源头——因为一个 Builder 里如果引用了多个状态,任何一个状态变化都会触发整个 Builder 重新渲染,哪怕它只和其中一小块 UI 有关。
2.3 全局与组件内的边界建议
我个人的实践规则是:
- 同一个 UI 片段在 3 个以上页面出现,而且数据来源完全靠参数传入,用全局
@Builder。 - 只在一个组件内部反复出现,或者需要读取组件内的状态,用组件内
@Builder。 - 如果一个全局
@Builder的参数列表超过 4 个,开始变得难以维护,我会考虑把它重构成一个真正的子组件,而不是继续堆参数。
这里多说一句,组件化是比 Builder 更重的复用手段,因为它有独立的状态、生命周期和上下文,但也意味着更多的代码和更复杂的通信。Builder 的优势在于轻量,适合“只是结构重复、逻辑不复杂”的 UI。分清什么时候该用 Builder、什么时候该拆组件,比学会语法更重要。
3. 传参机制:按值传递与按引用传递,别再搞混了
3.1 按值传递:简单直接也有局限
@Builder函数定义好之后,调用时传入参数,默认就是按值传递。什么叫按值?就是调用发生时,参数的当前值会被拷贝一份交给 Builder,之后 Builder 内部对这个值的修改,不会影响外部变量;外部变量后续的变化,也不会自动同步到 Builder 内部。
@Builder function DescBuilder(desc: string) { Text(desc) .fontSize(14) .fontColor('#999') } @Component struct Demo { @State desc: string = '初始描述' build() { Column() { // 此时传入的是 desc 的当前值 DescBuilder(this.desc) Button('修改描述') .onClick(() => { this.desc = '修改后的描述' }) } } }运行这段代码你就会发现:点击按钮后,Text显示的依然是“初始描述”。这不是 Bug,而是按值传递的预期行为。Builder 执行的时候拿走了desc当时的快照,它和desc之间没有任何依赖关系。所以按值传递适合什么?适合那些确定不参与响应式更新的静态内容,比如从常量、配置里读取的文案,或者一个只需要渲染一次的标题。它最大的价值就是“简单、可控、没有隐式依赖”。
3.2 按引用传递:用 $$ 建立真正的响应式关联
当你需要 Builder 内部随外部状态变化而更新时,就得用按引用传递。ArkUI 的语法是:函数参数类型用$$包裹起来。
@Builder function PriceBuilder($$: { price: number; oldPrice: number }) { Row({ space: 6 }) { Text(`¥${$$.price.toFixed(2)}`) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#FF6B00') Text(`¥${$$.oldPrice.toFixed(2)}`) .fontSize(12) .fontColor('#999') .decoration({ type: TextDecorationType.LineThrough }) } } @Component struct ProductPage { @State price: number = 199 @State oldPrice: number = 299 build() { Column() { PriceBuilder({ price: this.price, oldPrice: this.oldPrice }) Button('降价') .onClick(() => { this.price = 159 }) } } }这里要注意几个关键点:
- 参数类型声明必须写成
$$: { 字段: 类型 }的形式。 - 外部传参时,传进去的是对象字面量
{ price: this.price, oldPrice: this.oldPrice },或者一个对象变量。 - 传入的字段必须来自状态变量(
@State等),这样框架才能追踪依赖。
一旦建立了按引用传递的关系,就像给 Builder 内部和外部状态之间拉了一根“绳子”,外部状态一变,Builder 内部同步执行更新。这是@Builder真正发挥“响应式 UI 片段”价值的姿势。
3.3 传参方式对比与避坑心得
按值和按引用的选择,核心判断标准只有一个:这个参数后续需不需要跟着外部状态变化?
| 对比维度 | 按值传递 | 按引用传递 |
|---|---|---|
| 参数写法 | 普通参数声明 | $$: { 参数: 类型 } |
| 响应式更新 | 不参与,一次性 | 参与,状态变化自动更新 |
| 调用方式 | 直接传单值 | 传对象字面量或对象变量 |
| 适用场景 | 静态文案、配置项 | 价格、数量、动态数据 |
| 性能开销 | 无额外依赖追踪 | 建立依赖关系,有一定追踪成本 |
踩坑提醒,按引用传参的两个常见问题:
只传了字段名,没有传状态变量。比如直接传一个字面量
100,或者传一个普通let局部变量,框架没法建立依赖,更新自然不会发生。这一点和 Vue 的响应式依赖收集逻辑类似:必须传“被追踪的响应式数据”,传普通常量是无效的。把整个对象传进去,但字段没有从状态里解构。有些封装良好的类,比如商品信息
ProductInfo,如果你让一个@State product: ProductInfo作为参数传给 Builder,然后 Builder 内部只需要product.price,那么一旦product.name变化,Builder 也会跟着刷新,哪怕price没变。这不是错,但属于不精确的依赖,会造成多余的渲染。真在意性能的话,按字段粒度传更稳妥。
4. 进阶:@LocalBuilder 与 @BuilderParam 的威力
4.1 @LocalBuilder:解决 ForEach 中的局部更新难题
@LocalBuilder是 API 12(HarmonyOS NEXT 5.0.0 对应 SDK 版本)加入的特性,专门用来解决一个棘手问题:当 Builder 使用了循环中的局部变量时,以往被迫把局部变量提升为组件状态,导致整个组件大面积刷新的问题。
来看一个实际场景。我们用ForEach渲染一个商品列表,每个商品项里有一个小按钮,点击后某个商品的标签变色。如果按老办法写:
@Component struct ProductList { @State products: ProductInfo[] = [] @Builder ProductItem(item: ProductInfo) { Column() { Text(item.name) Text(item.tag) .fontColor(this.getColor(item.tagIndex)) // 依赖 item 内部值 } } build() { List() { ForEach(this.products, (item: ProductInfo) => { ListItem() { this.ProductItem(item) } }, (item: ProductInfo) => item.id) } } }看起来没问题,但问题的核心是:ProductItem中如果使用@Builder且内部访问的是循环里的item,那么当某个item的某个字段变化时,ArkUI 为了确保 UI 正确,往往会重新执行整个ProductItem,甚至影响周围组件,效率不高。
换成@LocalBuilder后,情况完全不同:
@Component struct ProductList { @State products: ProductInfo[] = [] @LocalBuilder ProductItem(item: ProductInfo) { Column() { Text(item.name) Text(item.tag) .fontColor(this.getColor(item.tagIndex)) } } build() { List() { ForEach(this.products, (item: ProductInfo) => { ListItem() { this.ProductItem(item) } }, (item: ProductInfo) => item.id) } } }语法上只是把@Builder换成@LocalBuilder,但行为差别很大:@LocalBuilder会在内部记录它实际依赖的局部变量,当这些局部变量变化时,只重新渲染 LocalBuilder 自己的那一块 UI,不会牵动外层的ForEach和ListItem。在长列表、高频交互的场景里,这个性能优势是很明显的。
@LocalBuilder有几个硬性限制,必须记牢:
- 只能用在
struct内部,不能定义全局@LocalBuilder。 - 不可以在
@Builder里面嵌套调用@LocalBuilder,两者不能互相嵌套。 - 只能在 API 12 及以上的版本使用。如果你的项目还要兼容 API 11 以下,就别用它。
4.2 @BuilderParam:组件模板插槽的 ArkUI 解法
@BuilderParam解决的是“组件的内容可以自定义”这个问题。在传统 Web 开发里,这叫插槽(Slot),在 React 里叫 render props。ArkUI 里,只要在子组件里声明一个@BuilderParam类型的属性,父组件就可以把一个@Builder函数传进来,子组件在合适的位置调用它。
看代码。定义一个通用的容器卡片:
@Component export struct CardContainer { // 声明一个 BuilderParam,固定无参数或者带参数都可以 @BuilderParam content: () => void build() { Column() { // 卡片头 Text('通用卡片') .fontSize(16) .fontWeight(FontWeight.Bold) // 内容占位区,交给外部传入的 Builder 渲染 this.content() } .padding(12) .backgroundColor('#FFFFFF') .borderRadius(12) } }父组件里使用:
@Component struct HomePage { @Builder MyBanner() { Column() { Text('限时活动') Text('满 300 减 80') } .backgroundColor('#FFF3E0') .width('100%') } build() { Column() { CardContainer({ content: this.MyBanner }) } } }@BuilderParam还能带参数,实现更灵活的动态模板渲染:
@Component export struct CustomCell { @BuilderParam header: () => void @BuilderParam footer: (info: string) => void build() { Column() { this.header() Divider().margin({ vertical: 8 }) // 带参数调用,父组件的 Builder 可以接收并渲染 this.footer('来自父组件的数据') } } }父组件传入带参数的 Builder:
@Builder FooterBuilder(info: string) { Text(`自定义底部:${info}`) } // 使用时 CustomCell({ header: this.HeaderBuilder, footer: FooterBuilder })@BuilderParam是封装通用容器组件、布局组件时最强有力的武器。比如你要做一个通用的ListItem、SettingRow、PopupPanel,都适合用它来开放内容区。
4.3 组合使用:@LocalBuilder 与 @BuilderParam 的实战搭配
实际项目中,@LocalBuilder和@BuilderParam经常搭配使用。比如开发一个复杂表单页面,同一个表单项结构要复用多遍,但每组的内容不一样,我就会用@BuilderParam开放内容区,内部用@LocalBuilder处理每个表单项自己的局部状态更新。这样既保住了通用骨架,又不会因为输入内容的变化触发整个表单重绘,性能上限高很多。
5. 实操:用一个商品卡片打通 5 种 Builder
5.1 场景设定与初始结构
光讲概念容易飘,拿一个完整的案例来“串讲”一遍。假设我们要做一个电商首页的商品卡片,包含:商品图、标题、价格、数量选择器、促销标签。为了演示,我把前面几种 Builder 都用在这个卡片上。
先定义商品数据结构:
class ProductInfo { id: string = '' name: string = '' price: number = 0 oldPrice: number = 0 tag: string = '' count: number = 1 }5.2 逐步改造:从全局到局部到插槽
第一步,把全局通用的价格标签用全局@Builder写出来,因为多个页面都会用到:
@Builder function PriceTag(price: number, oldPrice: number) { Row({ space: 6 }) { Text(`¥${price.toFixed(2)}`) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#FF4D4F') if (oldPrice > price) { Text(`¥${oldPrice.toFixed(2)}`) .fontSize(12) .fontColor('#999') .decoration({ type: TextDecorationType.LineThrough }) } } }第二步,写商品卡片组件,内部用组件内@Builder复用星评、按钮区:
@Component export struct ProductCard { @Prop product: ProductInfo @Builder RatingBar() { Row({ space: 4 }) { // 这里可以展开为一排星星图标 Text('★') .fontColor('#FFB800') Text(`${this.product.rating}`) .fontSize(12) } } build() { Column({ space: 4 }) { Text(this.product.name) this.RatingBar() // 调用全局 Builder,按值传参,静态价格也可以用按值,但需要响应式更新时用按引用 PriceTag(this.product.price, this.product.oldPrice) } } }第三步,如果在列表页里渲染多个商品,数量选择器需要独立刷新,这里用组件内@Builder配合@State或者@LocalBuilder控制:每个商品项都有自己的数量状态,且只刷新自己。
@Component export struct ProductCard { @ObjectLink product: ProductInfo // 使用 @ObjectLink 让每个列表项独立管理 @LocalBuilder QuantitySelector(item: ProductInfo) { Row({ space: 8 }) { Button('-') .onClick(() => { if (item.count > 1) item.count-- }) Text(`${item.count}`) Button('+') .onClick(() => { item.count++ }) } } build() { Column({ space: 4 }) { Text(this.product.name) this.RatingBar() PriceTag(this.product.price, this.product.oldPrice) this.QuantitySelector(this.product) } } }这里顺便说一句,列表项如果每个 item 里的某个字段会变化,记得把商品对象用@ObjectLink或者让容器用@State数组管理,配合@LocalBuilder才能精确更新。用@Prop传整个对象进列表,改动 item 时可能会重排整列,这是我们实际开发里踩过的一个大坑。
5.3 性能对比与核心注意事项
我做过一个粗略的对比测试,在列表页渲染 100 条商品数据时:
- 全部用
@Builder且直接在 Builder 里引用循环 item 对象,点击任意一个商品的“+”,整个ForEach列表的可见区域都会不同程度地重排,肉眼可见卡顿。 - 换成
@LocalBuilder后,点击“+”只有那个商品的数量区重绘,滚动手感顺滑很多。
另外提醒一下@BuilderParam的嵌套注意点:如果父组件传入的 Builder 本身又调用了全局@Builder,没问题;但如果你在子组件里,把某个@BuilderParam当作普通@Builder去嵌套调用另一个@LocalBuilder,会直接报错。@LocalBuilder永远不能嵌套在别的方法里调用,这是 API 12 的限制。
6. 常见问题与排查技巧实录
6.1 高频报错与解决方案速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| Builder 内部状态不更新 | 传参用了按值传递,外部状态变化未建立依赖 | 改用$$按引用传参,并确保传入的是状态变量 |
| 点击按钮后商品列表整块闪烁 | @Builder内使用了局部变量或对象字段,依赖粒度太粗 | 换成@LocalBuilder,让更新局限在该 Builder 内部 |
@LocalBuilder编译报错 | 在全局定义 / 在@Builder内嵌套调用 | 检查定义位置,@LocalBuilder只能在struct内 |
@BuilderParam调用后显示空白 | 父组件没有传 Builder,或 Builder 没有正确指向this | 确保父组件传入this.xxxBuilder,子组件用this.content()调用 |
| Builder 内修改参数不生效 | 尝试直接赋值$$参数内部字段 | 参数是只读的,想要修改得通过外部状态变量来改 |
6.2 从实际项目里总结的避坑清单
优先用
@LocalBuilder替代“内部引用 item 的普通 @Builder”。只要你的 Builder 里用了循环项对象,并且该项会被修改,就默认选@LocalBuilder。这一个习惯能帮你省掉最头疼的列表性能问题。按引用传参时,字段粒度越细越好。现在项目里很多对象很大,如果把整个
ProductInfo用$$传入,任何一个字段变化都会触发 Builder。只传需要的字段,比如{ price: this.product.price, count: this.product.count },依赖更明确。@BuilderParam一定要处理默认空状态。我给通用组件定义@BuilderParam时,如果允许调用方不传内容,就在组件里写个空 Builder 兜底,不然别人忘了传参数,页面上就是一块空白,排查半天还以为是渲染问题。Builder 的名字不要以“function”命名习惯来写。有些同学习惯把 Builder 命名为
renderXXX或buildXXX,其实无所谓,但一定要遵守“调用时this.不能省”的规则。全局 Builder 调用不需要this,组件内 Builder 必须this,这个混着用最容易报“Cannot find name”的错误。别忘了调试工具里的组件树。开发态里打开 ArkUI Inspector,你能非常清楚地看到 Builder 渲染出来的节点,一旦某个 Builder 不刷新,先看它内部是否真的引用了你想监听的状态变量。很多时候“不刷新”不是 Builder 的问题,而是“引用关系没建立”。
6.3 最后分享一点我的使用心得
做了一年多 HarmonyOS 应用之后,我对 Builder 的态度概括成一句话:能用@Builder解决的就别拆组件,能用@LocalBuilder的就别用普通@Builder碰循环项,能用@BuilderParam做模板的就不要硬写if/else分支。这套体系初学的时候会觉得比“写函数”绕,但用顺手之后,你会发现 ArkUI 的 UI 复用和更新性能,反而是这套约束带来的最大红利。
回过头看,5 种 Builder 不是 5 个孤立语法,而是一条从“复用”到“精确更新”的能力阶梯。先掌握基础复用,再理解响应式传参,最后用局部更新和插槽做大面积性能优化,整个 HarmonyOS 页面的开发思路就通了。这篇里的代码示例,我都是在 API 12 / HarmonyOS NEXT SDK 上实测过的,照搬基本能跑通。如果你在迁移旧项目,记得先确认 targetSdkVersion 支持到 12,再用@LocalBuilder,否则编译阶段就会直接报错。