1. 先分清 Builder 的两种身份,不然你后面会越看越乱
前阵子在重构一个鸿蒙项目的首页时,我发现团队里关于 Builder 的讨论特别多。有人说“用 @Builder 封装一个头部组件”,有人说“我的 Builder 实现了一个复杂对象”,还有人拿着一张 SketchUp 的报错截图来问是不是 HarmonyOS 的问题。后来我意识到,大家口中的 Builder 不只是同一个东西:在鸿蒙开发里,它既是 ArkUI 声明式框架里的@Builder 装饰器,又是设计模式里的建造者模式,甚至在一些第三方工具里也出现过同名概念。
对于做 HarmonyOS 原生应用的人来说,最常打交道的其实是 ArkUI 提供的@Builder 自定义构建函数。它的核心作用是把一段 UI 描述封装成一个函数,在多个页面或组件内复用,避免重复写同样的布局代码。而设计模式里的 Builder,更多是用在 ArkTS 的数据建模、复杂参数构建上,跟 UI 不直接相关,但工程上也很实用。
所以这篇文章我打算把两件事串起来讲:前面五个部分全部围绕 ArkUI 的 @Builder 展开,包括全局 Builder、局部 Builder、参数传递、@BuilderParam 和尾随闭包;最后一部分再重点聊一下 Builder 设计模式在 ArkTS 工程中的落地场景。这样不管你是刚入门的新手,还是已经写了几个月鸿蒙页面但一直没把 Builder 理清的老手,都能找到自己需要的答案。
2. 全局自定义构建函数:最常用也最容易被忽略的细节
2.1 全局 Builder 的声明方式
全局 @Builder 是定义在组件外部的函数,它的作用域是整个模块。你可以把它当作一段“UI 模板”,在任何组件里直接调用。
// GlobalBuilder.ets @Builder export function GlobalHeaderBuilder($$: { title: string }) { Row() { Text($$.title) .fontSize(20) .fontWeight(FontWeight.Bold) Blank() Text('更多') .fontSize(14) .fontColor('#999') } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor('#F5F5F5') }调用时不需要new,也不需要实例化,直接在 build 方法里写:
@Component struct IndexPage { build() { Column() { GlobalHeaderBuilder({ title: '首页' }) // 其他内容 } } }这里的参数传递用了$$语法,后面会单独讲,但先记住一个结论:如果希望 Builder 内部对参数的修改能同步到外层状态,或者希望依赖的状态变量可以双向联动,建议使用$$。
2.2 全局 Builder 的优势和坑
全局 Builder 最大的优势是跨组件复用。多个页面里只要有相同的页头、空状态、错误提示,都可以抽出来做成全局 Builder。我习惯把项目中通用的 UI 片段全部放到一个common/builders目录下,按功能命名,比如EmptyStateBuilder、ErrorStateBuilder、PageHeaderBuilder。
但全局 Builder 有一个非常容易踩的坑:它不能访问组件内部的@State变量。因为全局函数没有绑定到任何一个组件实例上,所以想从组件里把状态传给全局 Builder,只能通过参数传进去。如果你在全局 Builder 里直接使用this,编译阶段就直接报错。
所以我一般只在以下场景使用全局 Builder:
- 页面级通用头部、底部。
- 下拉刷新、加载失败、空数据等与业务状态解耦的占位 UI。
- 需要在多个
Entry页面里复用的纯展示型组件片段。
如果某段 UI 强依赖当前组件的状态、需要调用当前组件的方法,或者需要和组件的生命周期联动,我的建议是不要用全局 Builder,直接用局部 Builder,也就是下面第三部分要说的内容。
3. 组件内自定义构建函数:状态访问更自由的局部 Builder
3.1 在组件内部声明 Builder
局部 Builder 就是把@Builder函数写在组件struct的内部。由于它定义在当前组件实例中,所以可以直接访问this,包括@State、@Prop、@Link以及普通成员变量和方法。
@Component struct ProductCard { @State productName: string = 'HarmonyOS 实战指南' @Builder ProductCardContent() { Column() { Text(this.productName) .fontSize(16) .fontColor('#333') Text('点击查看详情') .fontSize(12) .fontColor('#666') } .padding(12) .backgroundColor('#FFFFFF') .borderRadius(8) } build() { Column() { // 直接调用 this.ProductCardContent() } .padding(16) } }这个例子虽然简单,但它体现了局部 Builder 最核心的价值:Builder 内部和组件共享同一个上下文。当你修改productName时,Builder 内的Text会自动更新,不需要你手动同步任何参数。
3.2 局部 Builder 为什么能做到状态同步
很多初学者会问:this.ProductCardContent()不就是一个函数调用吗?为什么状态变了它会自动刷新?
这里要注意,ArkUI 的 @Builder 并不是普通的函数。它在编译阶段会被框架特殊处理,成为渲染树的一部分。当组件状态变化时,框架会依据状态依赖关系定位到具体的 UI 组件,而不是把整个 build 方法重新执行一遍。换句话说,@Builder 内部的Text和build()里的组件一样,都建立在相同的状态跟踪基础之上。
这就带来一个很实用的技巧:如果某个区域的 UI 逻辑特别复杂,或者被if/else、ForEach包裹得很乱,可以单独拆成一个局部 Builder,让代码可读性提升不少。但注意,局部 Builder 不适合在多个不同组件之间复用,因为一旦 A 组件写的 Builder 想拿到 B 组件里用,就要改成全局 Builder,或者提取成公共组件。
4. 参数传递:默认值、值传递和 $$ 引用传递,一次搞明白
4.1 基础参数传递与默认值
@Builder 函数也支持普通参数和默认值。比如:
@Builder function InfoBuilder(content: string = '默认文案', showIcon: boolean = true) { Row({ space: 8 }) { if (showIcon) { Text('●') } Text(content) } }调用时可以不传参:
InfoBuilder()也可以传部分参数:
InfoBuilder('自定义文案', false)但这里有两个规则要记牢:
- @Builder 函数参数不能同时使用按引用传递和按值传递的混写方式。要么全部用普通参数(值传递),要么使用
$$对象形式(引用传递)。 - 参数默认值只支持普通参数类型,比如字符串、数字、布尔值,不支持数组、对象类型。如果默认值是一个动态状态变量,那就不能用默认值机制,必须显式传入。
4.2 用 $$ 实现引用传递
官方推荐的传参方式之一是把参数包装成一个对象,并在参数名称前加$$。看这个例子:
@Builder function ClickableTextBuilder($$: { count: number }) { Button(`点击次数:${$$.count}`) .onClick(() => { $$.count++ }) }父组件里这样用:
@Component struct CounterPage { @State total: number = 0 build() { Column() { ClickableTextBuilder({ count: this.total }) Text(`父组件计数:${this.total}`) } } }当 Builder 内部修改了$$.count,父组件的total也会跟着变,因为这里的$$表示引用传递,this.total和$$.count指向同一个状态源。
如果你不用$$,直接写成:
ClickableTextBuilder({ count: this.total }) // 但函数定义是普通参数 function ClickableTextBuilder(count: number) { ... }那么 Builder 内部拿到的是.total的一个快照,不管怎么修改都不会影响父组件。这对某些只读展示的场景是合理的,但如果期望在 Builder 内触发状态更新,就必须用$$。我在实际开发里几乎都统一用$$对象传参,避免踩“改半天没反应”的坑。
4.3 值传递和引用传递应该如何选择
选择规则其实很短:
- 如果 Builder 只是展示,不修改入参,用普通参数足够。
- 如果 Builder 内部需要修改入参并同步父组件,用
$$。 - 如果参数是
@State、@Link等状态变量的引用,建议用$$确保联动。
如果一个 Builder 有七八个参数,强烈建议全部放进一个$$对象里管理。这样调用时的代码看着像一个配置对象,语义清晰,也不容易把参数顺序搞错。
5. @BuilderParam:把 UI 片段当参数传进门
5.1 理解 BuilderParam 的插槽思想
@BuilderParam是 @Builder 的进阶版,它解决的场景非常直接:父组件想往子组件里塞一段自定义 UI,而不是塞一个值。熟悉前端的开发者一眼就能认出来,这就是“插槽”思路。
举个例子,有一个通用卡片组件,卡片上半部分是固定的标题栏,下半部分需要父组件自由填充内容。我可以这样设计:
@Component export struct CardContainer { @BuilderParam contentBuilder: () => void build() { Column() { Text('卡片标题') .fontSize(18) .fontWeight(FontWeight.Bold) Divider() // 这里渲染父组件传入的 UI 片段 this.contentBuilder() } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(12) } }父组件通过@BuilderParam把自定义内容传进来:
@Builder function CustomContentBuilder() { Column() { Text('自定义区域') Button('按钮') } } @Component struct ParentPage { build() { Column() { CardContainer({ contentBuilder: CustomContentBuilder }) } } }这里的contentBuilder类型是() => void,意思是“一个没有参数的构建函数”。父组件传一个 @Builder 函数进去,子组件在合适的位置调用它。
5.2 初始化方式一:通过普通参数传入
第一种是上面这种,直接把一个已有 Builder 作为参数传入。这种方式在父组件内部已经定义好一段 UI 时最自然。
5.3 初始化方式二:尾随闭包
第二种在 API 10 之后非常常用,就是“尾随闭包”写法。如果子组件的最后一个参数是@BuilderParam,可以直接在子组件后面跟一个花括号,里面写 UI:
CardContainer() { // 这里的内容会传给 contentBuilder Row({ space: 8 }) { Text('自定义标题') Image($r('app.media.icon')) .width(24) .height(24) } }这种方式阅读起来特别像普通布局代码,父组件不需要单独定义一个 Builder 函数,代码更紧凑。我个人在写通用列表项、弹窗内容、页面骨架时非常喜欢这种写法,因为它把“子组件的固定部分”和“父组件的自定义部分”分得很开。
5.4 几个容易踩的初始化细节
@BuilderParam有一些隐藏规则,新手经常在这里翻车:
- 子组件的 @BuilderParam 数量不能太多。虽说不限制数量,但超过两个后调用代码的可读性会急剧下降。如果确实需要多个插槽,建议用普通 Builder 传参给子组件,再在子组件内部用 if 处理不同区域。
- @BuilderParam 参数名如果在初始化时没有传,也不会有默认值。如果父组件没传
contentBuilder,子组件调用this.contentBuilder()时会报错。因此对于某些可选插槽,我会在子组件内部提供一个默认 Builder 兜底。 - 尾随闭包方式和普通参数方式不能同时使用。一个 @BuilderParam 只能选择一种传入方式初始化。
// 错误的写法:既有参数传入,又写了尾随闭包 CardContainer({ contentBuilder: CustomContentBuilder }) { Text('这段闭包不生效') }这种代码编译不会直接报错,但闭包内容会被忽略,排查起来很浪费时间。
5.5 实际案例:做一个可定制弹窗外壳
我项目中有一个通用弹窗组件,外层有遮罩、圆角容器、动画,内部内容完全由调用方决定。用 @BuilderParam 做起来非常清爽:
@Component export struct CommonDialog { @BuilderParam dialogContent: () => void build() { Stack() { // 遮罩 Column() .width('100%') .height('100%') .backgroundColor('rgba(0, 0, 0, 0.4)') .onClick(() => { /* 关闭逻辑 */ }) // 弹窗内容 Column() { this.dialogContent() } .padding(20) .backgroundColor('#FFF') .borderRadius(16) .margin({ left: 24, right: 24 }) } } }使用方只需要传具体内容:
CommonDialog() { Column({ space: 12 }) { Text('确定要删除这条数据吗?') Row({ space: 20 }) { Button('取消') Button('删除') } } }这样弹窗的交互框架和业务内容完全解耦,新增任何弹窗都不需要再改弹窗容器代码。
6. 实战避坑:Builder 状态不刷新、循环构建和参数失效问题
6.1 状态更新不生效的经典场景
有网友和我反馈过一个问题:在 @Builder 里用setInterval修改一个普通变量,界面不刷新。看到代码后发现他用的是普通成员变量,没有用@State装饰。这是理解上的误区:@Builder 负责复用 UI 描述,但不负责“魔法化”所有变量。想要 Builder 里的 UI 感知数据变化,数据源必须是状态变量(@State、@Prop、@Link、@Provide等)或者能被框架观察到的对象属性。
排查 Builder 不刷新问题,我一般按这个顺序查:
- 数据变量是否加了
@State或@Observed? - 传递参数是否用了
$$引用传递?如果用了值传递,Builder 内部再改也不会触发父组件更新。 - Builder 内部是否使用了
this?全局 Builder 里访问不到组件状态,所以只能靠传参。 - 如果是对象属性更新,对象是否实现了
@Observed并且属性在类内部声明?
6.2 在 LazyForEach 和列表项中使用 Builder
列表页是 Builder 的高频场景。很多人把列表项写成普通@Component,然后通过ForEach循环渲染。这没问题,但如果列表项只是一段静态 UI,且不需要独立状态,用 @Builder 会更轻量。
@Builder function ProductItemBuilder(item: ProductModel) { Row({ space: 12 }) { Image(item.cover) .width(80) .height(80) .borderRadius(8) Column() { Text(item.name) .fontSize(16) Text(item.price) .fontSize(14) .fontColor('#E84026') } .alignItems(HorizontalAlign.Start) } .width('100%') .padding(10) }在LazyForEach中使用时,我会把 Builder 直接放在LazyForEach的子项生成区域里:
LazyForEach(this.productDataSource, (item: ProductModel, index: number) => { ProductItemBuilder(item) })这里有一个经验:如果列表项内部还需要点击跳转、需要访问当前组件的路由方法,建议写成组件而不是 Builder。因为 Builder 里很难方便地处理生命周期和事件上下文,强行用 Builder 反而会增加复杂度。
6.3 常见问题速查表
| 现象 | 可能原因 | 推荐解法 |
|---|---|---|
| Builder 内部修改数据不刷新 | 变量不是状态变量 | 改用 @State 或 @Observed |
| 父组件传入参数后 Builder 里改不动 | 没有用 $$ 引用传递 | 修改参数为 $$ 对象形式 |
| 全局 Builder 访问 this 报错 | 全局作用域没有组件实例 | 改为组件内局部 Builder 或通过参数传入 |
| @BuilderParam 没有渲染内容 | 初始化方式冲突或未传值 | 检查是否用普通参数和尾随闭包混用 |
| Builder 内 ForEach 多次渲染后卡顿 | 每次调用都创建了新数组 | 使用 LazyForEach 并确认数据源 ID 稳定 |
| Builder 内使用路由方法报错 | 缺少组件上下文 | 改为 @Component 并在 build 中调用 |
6.4 一个容易被忽略的编译约束
@Builder 函数内不能使用@Builder装饰的变量?其实不是。但有一个规则值得注意:在 @Builder 内定义局部状态变量是不允许的。比如:
@Builder function WrongBuilder() { @State value: number = 0 // 编译报错 Text('错误示例') }状态变量必须要放在组件结构体的顶层,Builder 只是一个构建函数,不能拥有自己的状态存储。如果需要局部状态,可以新建一个独立的@Component子组件,把 Builder 区域替换成组件调用。这也是为什么很多时候组件和 Builder 要按场景取舍,而不是一味追求“全部用 Builder 封装”。
6.5 代码组织上的建议
项目里 Builder 多起来以后,命名和文件组织特别重要。我的习惯是:
- 全局 Builder 文件名用
*Builder.ets命名,如ListEmptyBuilder.ets。 - Builder 函数名用“用途 + Builder”后缀,如
EmptyStateBuilder、ErrorStateBuilder。 - 局部 Builder 放在组件结构体底部,和
build()方法分开,用注释块分隔。 - 只在同一个
.ets文件里使用的 Builder,优先做成局部 Builder,不导出到全局。
7. 顺手聊聊 Builder 设计模式在 ArkTS 工程里的应用
7.1 数据对象构造场景
ArkUI 开发中经常要构造复杂的请求参数、表单提交对象、或者一个包含多个配置项的数据模型。直接用构造函数传参,参数一多代码就变得难读,而且容易传错顺序。
这时候就可以用 Builder 设计模式。比如有一个UserProfile对象:
class UserProfile { name: string = '' age: number = 0 email: string = '' phone: string = '' address: string = '' }用 Builder 模式封装后:
class UserProfileBuilder { private profile: UserProfile = new UserProfile() setName(name: string): UserProfileBuilder { this.profile.name = name return this } setAge(age: number): UserProfileBuilder { this.profile.age = age return this } setEmail(email: string): UserProfileBuilder { this.profile.email = email return this } setPhone(phone: string): UserProfileBuilder { this.profile.phone = phone return this } build(): UserProfile { return this.profile } }调用时就很舒服:
const user = new UserProfileBuilder() .setName('张三') .setAge(28) .setEmail('zhangsan@example.com') .setPhone('13800138000') .build()这个写法在构建复杂对象、DTO、或者测试数据时能明显提升代码可读性。
7.2 什么时候不要用 Builder 模式
如果对象本身只有两三个字段,直接构造函数传入反而更清晰。Builder 模式最大的代价是代码量增大、每次构建多创建一次临时对象,而且不支持在 Build 方法里再继续复用已有状态。所以我的建议是:
- 参数数量在 5 个以上时优先考虑 Builder。
- 需要链式调用、且每个参数具备默认值时适合 Builder。
- 如果对象是可变对象、需要频繁修改一部分字段,不要用 Builder,直接类属性赋值更简单。
7.3 Builder 设计模式和 @Builder 装饰器能混用吗
可以。在同一个项目里,既有 UI 层面的 @Builder,也有数据层/业务层的 Builder 设计模式。我通常会把数据对象构建放在model/builders文件里,UI 片段构建放在view/builders文件里。两者互不干扰,但要注意命名空间,不能在同一个文件中定义同名的xxxBuilder类和一个 @Builder 函数,否则会冲突。
8. 写在最后的实操提醒
上面这些内容,前五部分是在项目里总结出来的 ArkUI Builder 核心玩法,最后一部分是设计模式工程化的补充。很多人看着官方文档觉得 @Builder 就是“装饰器 + 函数”,但实际写起来之后,关于参数传递、@BuilderParam 的初始化方式、以及全局局部选型这些问题,才是真正拉开开发效率差距的地方。
我在实际开发中的一个体会是:Builder 的本质是“复用”,但复用的粒度要控制好。UI 结构完全固定、且不需要交互上下文的,优先用全局 Builder;需要共享组件状态、又不想拆组件的,用局部 Builder;需要让外部决定某一块区域内容的,用 @BuilderParam;需要链式构造复杂数据对象的,用设计模式 Builder。按这个思路去决策,基本能覆盖日常 90% 的场景。
最后再分享一个小技巧:如果遇到“@Builder 内部刷新不生效”这种问题,先别急着查数据,先看看你传进来的是对象还是对象属性的引用。ArkUI 的状态观察是按照属性级别追踪的,如果你把一个对象传入 Builder,但对象的属性不是@Observed装饰的,那大概率会出现改了值但界面没反映的情况。这个坑我至少踩过三次,每次排查半天最后都发现是对象观察层级的问题。记住这句话,能帮你省下不少时间。