☰
HarmonyOS开发必会:详解ArkUI @Builder与Builder设计模式
2026/10/10 21:01:04 网站建设 项目流程

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)

但这里有两个规则要记牢:

  1. @Builder 函数参数不能同时使用按引用传递和按值传递的混写方式。要么全部用普通参数(值传递),要么使用$$对象形式(引用传递)。
  2. 参数默认值只支持普通参数类型,比如字符串、数字、布尔值,不支持数组、对象类型。如果默认值是一个动态状态变量,那就不能用默认值机制,必须显式传入。

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有一些隐藏规则,新手经常在这里翻车:

  1. 子组件的 @BuilderParam 数量不能太多。虽说不限制数量,但超过两个后调用代码的可读性会急剧下降。如果确实需要多个插槽,建议用普通 Builder 传参给子组件,再在子组件内部用 if 处理不同区域。
  2. @BuilderParam 参数名如果在初始化时没有传,也不会有默认值。如果父组件没传contentBuilder,子组件调用this.contentBuilder()时会报错。因此对于某些可选插槽,我会在子组件内部提供一个默认 Builder 兜底。
  3. 尾随闭包方式和普通参数方式不能同时使用。一个 @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 不刷新问题,我一般按这个顺序查:

  1. 数据变量是否加了@State或@Observed?
  2. 传递参数是否用了$$引用传递?如果用了值传递,Builder 内部再改也不会触发父组件更新。
  3. Builder 内部是否使用了this?全局 Builder 里访问不到组件状态,所以只能靠传参。
  4. 如果是对象属性更新,对象是否实现了@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装饰的,那大概率会出现改了值但界面没反映的情况。这个坑我至少踩过三次,每次排查半天最后都发现是对象观察层级的问题。记住这句话,能帮你省下不少时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询