ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

HarmonyOS开发必会:详解ArkUI @Builder与Builder设计模式

HarmonyOS开发必会:详解ArkUI @Builder与Builder设计模式 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. 组件内自定义构建函数状态访问更自由的局部 Builder3.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可以直接在子组件后面跟一个花括号里面写 UICardContainer() { // 这里的内容会传给 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(zhangsanexample.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装饰的那大概率会出现改了值但界面没反映的情况。这个坑我至少踩过三次每次排查半天最后都发现是对象观察层级的问题。记住这句话能帮你省下不少时间。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表