ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

鸿蒙ArkUI @Builder用法详解:组件内与组件外到底有什么区别?

鸿蒙ArkUI @Builder用法详解:组件内与组件外到底有什么区别? 做鸿蒙ArkUI开发只要你开始写稍微复杂一点的页面就一定逃不过“重复UI代码”这个问题。Builder自定义构建函数就是官方给开发者的解药但很多刚上手的人会被同一件事绕晕——同一个Builder写在组件里面和写在组件外面到底是不是一回事我一开始也以为只是摆放位置不同直到连续踩了“this访问不到”“状态改了UI不刷新”几个坑才把这里面的门道彻底摸清楚。这篇文章就专门拆这一件事把组件内和组件外使用Builder的区别讲透顺便把那些文档里写了但没人划重点的细节一并整理出来。1. Builder的本质一段可复用的UI构建逻辑1.1 从复制粘贴UI到声明式复用写过命令式UI的老开发都知道以前做一个列表、一个卡片就是把相同的布局逻辑重复写N遍要么抽成自定义View要么用include。到了鸿蒙ArkUI这种声明式框架里页面的结构就是build方法里面的那棵组件树本质上是一段又一段描述UI的代码。代码一旦多起来重复的节点描述就会出现在好几个地方改一个样式可能要翻遍整个文件去同步。Builder的价值就在这个阶段体现出来。它允许你抽出一段UI描述像函数一样命名然后在你需要的地方调用。调用Builder构建函数时它会按照你写好的布局逻辑去创建对应的组件树相当于把你的界面碎片做成模具。与自定义组件相比Builder的定位更轻它不需要创建新类、不需要约束输入状态只需要你把它当成“带UI返回值效果的函数”来理解。用Builder最重要的收益不是减少几个字母的输入而是让UI的局部逻辑有了唯一的修改入口。比如某个页面的卡片样式需要调整你只需要修改定义Builder的那一个位置所有引用了它的地方都会同步生效。这比全局搜索替换要可靠得多。1.2 组件内和组件外的分界线是什么同样叫Builder为什么还要分组件内和组件外根源在于ArkUI的状态管理机制。组件内的Builder本质是当前组件struct上的一个方法。它天生拥有这个组件的实例上下文所以你在builder内部可以直接用this访问State、Prop、Link这些状态变量而且这些状态一旦变化builder对应的UI区域会跟着联动刷新。这是一种“依赖注入式”的复用builder依赖谁ArkUI就帮它跟踪谁。组件外的Builder则是脱离具体组件实例的全局函数。它的作用域里没有this因为你连“是哪个组件”都不知道。它必须靠参数把数据、回调事件全部传进去才能工作这更像是“纯函数式”的复用。所以组件内和组件外不是谁更高级的问题而是使用边界不同。前者更贴近某个组件内部布局的局部复用后者更适合跨组件、跨页面的通用片段复用。很多人上来就在全局写一个Builder然后为了访问组件状态折腾半天其实很多时候这个builder本来就该放在组件里面。2. 组件内Builder绑定组件实例的构建函数2.1 定义与调用方式this就是答案组件内Builder的定义方式是在Component修饰的struct内部用Builder修饰一个方法。你可以把它理解成build方法之外的另一块UI构建入口。Component struct TaskCard { State isDone: boolean false; title: string ; Builder taskStatusLabel() { Row({ space: 8 }) { if (this.isDone) { Text(已完成) .fontColor(#1890ff) .fontSize(14) } else { Text(进行中) .fontColor(#fa8c16) .fontSize(14) } Text(this.title) .fontSize(16) .fontWeight(FontWeight.Medium) } .padding(12) .backgroundColor(this.isDone ? #e6f7ff : #fffbe6) .borderRadius(8) } build() { Column({ space: 16 }) { this.taskStatusLabel() Button(this.isDone ? 重置任务 : 完成任务) .onClick(() { this.isDone !this.isDone; }) } .width(100%) .padding(16) } }在build方法里调用时我习惯写this.taskStatusLabel()显示地标明这是当前实例的方法。实际开发中ArkUI也允许直接调用taskStatusLabel()但写上this之后阅读代码的人一眼就能判断出它的作用域避免后续迁移时产生误会。定义组件内Builder时有一个隐含约束它放在哪个struct里就只能由哪个struct的实例使用。你想在另一个组件里调用这个builder是不可能的除非你把这个builder提升为全局函数或者通过组件暴露的方式去复用。2.2 状态感知State变化后UI如何自动更新组件内Builder最容易让新手兴奋的点在于“不用传参”。它能这样做的原因不在builder本身而在于它是组件方法。当你在builder内部写了this.isDone时ArkUI会把这段builder注册到当前组件的状态依赖图里。之后你点击按钮让this.isDone发生变化组件会重新执行所有相关的UI构建逻辑包括build方法和使用了isDone的Builder函数界面上对应的分支、颜色、文字就会自动更新。这其实和组件常规的状态刷新用的是同一套机制并不是Builder额外赠送了什么黑科技。拿上面的例子来说Button的点击事件修改了isDonebuild方法重新执行然后调用了this.taskStatusLabel()那这个builder内部就会重新计算读取最新的isDone值渲染出完成或进行中的状态。有个细节容易被忽略组件内Builder能不能感知状态变化取决于builder内部是不是真的依赖了那个状态。如果builder里只读取了一个外部传入的普通成员变量比如this.title在初始化之后不会变那么ArkUI没有义务去跟踪它。这也提醒我们凡是在builder里需要动态响应的数据都应该尽可能声明成响应式状态否则界面看起来就会“卡住不动”。2.3 组件内传参值和引用的边界组件内Builder虽然能直接用this但有些场景你仍然需要给它传参数比如循环渲染时把每一项的数据传给builder。这时候就绕不开参数传递方式的问题。ArkUI的Builder函数参数默认按值传递。如果你是这样写的Builder function taskItem(title: string) { Text(title) }那么调用时传入的title会拷贝一份builder内部展示的是这份拷贝。后续你修改数据源里的titlebuilder不会重新渲染。这样的设计在展示静态数据时没问题但如果你期望数据源变化后UI跟着变化就必须用按引用传递。按引用传递的写法是给参数对象加上$$标识注意这里的参数必须是一个对象对象里的属性才是你真正要用的值Builder function taskItem($$: { title: string, done: boolean }) { Row() { Text($$.done ? ✔ : •) Text($$.title) } }调用时传入一个对象字面量ForEach(this.taskList, (task: TaskModel) { this.taskItem({ title: task.title, done: task.done }) })当task对象里的title或done属性变化时使用$$引用传递的builder能感知到并刷新UI。如果不加$$直接传{ title: task.title }ArkUI按值传递处理属性变化后刷新链路就断了。这条规则在组件内和组件外都适用不过组件内Builder因为能直接依赖this状态所以很多场景并不需要依赖引用传参这种方式。3. 组件外Builder脱离组件实例的全局复用3.1 定义与调用方式函数式形态组件外Builder的定义位置是在所有struct之外通常放在某个公共模块或独立文件里。它的形态接近一个普通函数但没有返回值函数体内只能是UI描述。Builder export function globalInfoCard(title: string, desc: string) { Column({ space: 6 }) { Text(title) .fontSize(18) .fontWeight(FontWeight.Bold) Text(desc) .fontSize(14) .fontColor(#666666) .textAlign(TextAlign.Start) } .width(100%) .padding(16) .backgroundColor(#ffffff) .borderRadius(12) }这样的全局函数可以在任意组件的build方法里直接调用不需要任何实例前缀。我们可以把它添加到公共的UI组件文件里通过export导出在其他页面import后使用这就能做到跨文件复用一份UI片段。使用全局Builder的收益在于统一性。如果公司内部有一套统一的卡片规范那完全可以定义成一个全局builder所有页面都走这一个入口后续调整间距、圆角、字体只改这个函数就行范围是整个应用。3.2 没有this时的数据传递方案全局Builder最大的限制就是没有this它不属于任何组件实例自然也不认识任何组件的状态。如果你在一个全局builder里写上this.count编译器会直接报错。那怎么让全局builder拿到数据三种常见方案第一种普通参数传递。适合静态数据或者数据变化不敏感的场景函数签名里列出所有要用到的值。这种方式简单直接但不具备响应式能力。第二种按引用传递。也就是前面提到的$$对象参数。这能让数据源变化驱动builder内部UI更新。第三种回调函数传递。适合处理点击、滑动这类事件。全局builder本身没有事件处理逻辑需要外部通过函数参数把事件行为注入进来。例如Builder function globalInfoCard(title: string, desc: string, onAction: () void) { Column() { Text(title) Text(desc) Button(查看详情) .onClick(() { onAction(); }) } }调用的时候外部组件可以从this出发提供一个闭包来操作自己的状态globalInfoCard(系统通知, 你有3条未读消息, () { this.unread 0; })这样一来全局builder虽然不认识this但它通过参数把“动作”接进来了事件依然能作用到对应的组件上。在很多实际场景里这种“UI复用事件注”的组合比把数据都硬编码在builder里要灵活得多。3.3 引用传参$$的使用让外部数据变化驱动UI组件外Builder能不能响应数据变化关键在于是否使用引用传参。ArkUI的$$参数让builder接收到的是一份引用而不是拷贝值因此当源数据发生变化时builder可以重新执行。看这样一个例子Builder function globalTitleBlock($$: { title: string }) { Text($$.title) .fontSize(20) .fontWeight(FontWeight.Bold) } Entry Component struct HomePage { State pageTitle: string 首页; build() { Column() { globalTitleBlock({ title: this.pageTitle }) Button(修改标题) .onClick(() { this.pageTitle 首页标题已更新; }) } } }这里的globalTitleBlock虽然是全局函数但因为参数用$$标注了引用传递所以当this.pageTitle变化时builder内部的Text组件会跟着更新。如果去掉$$改成Builder function globalTitleBlock(title: string)点击按钮后页面标题就不会有任何变化因为此时传入的只是当时那一刻的字符串拷贝。使用$$引用传参时有一个约束参数必须是对象类型属性名就是你在builder内部使用的变量名。这种设计不是ArkUI故意为难人而是要保证ArkUI的依赖收集能精准定位到某个属性。如果你传一个对象进去但不通过$$声明ArkUI没法判断你是想整体替换对象还是修改某个属性为了安全起见它宁可丢弃响应性。4. 实操对比同一需求在组件内、组件外各写一遍4.1 需求场景带状态标签的任务卡片为了把差异讲清楚我准备了一个具体的场景。假设我们要做一个任务列表每行是一个卡片卡片左侧是任务标题右侧是任务状态标签标签会根据完成状态在“进行中”和“已完成”之间切换并且需要支持点击整卡完成/恢复任务。我先用组件内Builder实现一遍再用组件外Builder实现一遍两个版本放在一起对比作用域和状态访问的差异就很直观了。4.2 组件内Builder实现组件内的方案最自然因为builder和任务卡片本身就在同一个组件里状态访问直接通过this完成。Component struct TaskListPage { State taskTitle: string 完成需求评审; State isDone: boolean false; Builder TaskCardUI() { Row({ space: 12 }) { Text(this.taskTitle) .fontSize(16) .fontWeight(FontWeight.Medium) .layoutWeight(1) if (this.isDone) { Text(已完成) .fontSize(12) .fontColor(#ffffff) .backgroundColor(#1890ff) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .borderRadius(10) } else { Text(进行中) .fontSize(12) .fontColor(#ffffff) .backgroundColor(#fa8c16) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .borderRadius(10) } } .width(100%) .padding(16) .backgroundColor(#f9f9f9) .borderRadius(10) .onClick(() { this.isDone !this.isDone; }) } build() { Column({ space: 12 }) { this.TaskCardUI() } .padding(16) } }这个版本里builder内部出现的this.taskTitle和this.isDone都是当前组件的成员状态。点击卡片后isDone变化不仅状态标签分支会切换卡片整体的背景色和文字样式也会同步调整。因为ArkUI把builder内部的依赖和组件本身的状态串在同一条链路上刷新链路非常直接。4.3 组件外Builder实现再改用全局Builder实现同一个需求。这里我需要把界面需要的状态都显式传入同时把点击行为通过回调传进来。Builder export function TaskCardUI($$: { title: string, done: boolean }) { Row({ space: 12 }) { Text($$.title) .fontSize(16) .fontWeight(FontWeight.Medium) .layoutWeight(1) if ($$.done) { Text(已完成) .fontSize(12) .fontColor(#ffffff) .backgroundColor(#1890ff) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .borderRadius(10) } else { Text(进行中) .fontSize(12) .fontColor(#ffffff) .backgroundColor(#fa8c16) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .borderRadius(10) } } .width(100%) .padding(16) .backgroundColor(#f9f9f9) .borderRadius(10) }调用时组件负责维护状态和事件Entry Component struct TaskListPage { State taskTitle: string 完成需求评审; State isDone: boolean false; build() { Column({ space: 12 }) { TaskCardUI({ title: this.taskTitle, done: this.isDone }) .onClick(() { this.isDone !this.isDone; }) } .padding(16) } }这里有个改动值得注意全局Builder只能负责UI结构它没有当前组件的实例上下文所以点击事件只能通过链式调用的方式挂在builder返回的结构上。实际运行效果和组件内版本一样点击卡片会切换状态因为$$引用传递保证了done属性变化后builder能重新渲染。4.4 两个版本的差异拆解两个版本运行起来效果几乎一样但从工程角度看差别不小。组件内版本把builder和状态访问耦合在一起好处是写起来快、刷新链路短坏处是换一个组件想复用时这段builder就没法带走。如果你想在两个页面里展示不同来源的任务卡片只能把builder复制一遍或者在两个组件里各定义一个同名方法。全局版本把UI结构抽离了数据和事件通过参数注入灵活度更高。同一个TaskCardUI可以放在十几个页面里用只要传入的title、done不同显示就不同。需要的代价是状态和事件都必须设计成参数代码里会多出不少传参的语句。从数据流看组件内版本是“面向实例编程”builder自动能拿到组件状态全局版本是“面向协议编程”builder只认参数。这两种思路没有绝对的好坏只是为了应对不同的复用范围。范围限定在一个组件内部用组件内Builder范围跨组件甚至跨工程用全局Builder。5. 踩坑记录与方案选型建议5.1 组件外Builder里“访问不到this”的几种处理思路我在刚开始写全局Builder时理所当然地在里面写了一个this.xxx结果编译直接报错提示找不到this。这个问题本质上不是Bug而是作用域规则决定的。遇到这种需求我的处理步骤是先把builder内要用到的所有动态值列出来全部作为参数。然后区分这些值里哪些需要响应式更新哪些只是初始快照。需要响应式的用$$:{...}包装成对象按引用传不需要响应式的直接作为普通参数传。最后把点击、输入这类事件抽象成函数参数由外部组件注入。有时候全局builder确实需要访问一些跨组件的配置信息比如换肤主题、字体缩放比例。这种全局共享数据可以放在AppStorage里builder内用StorageProp或AppStorage.get读取不一定非得通过this。这点在涉及全局配置的场景里非常有用。5.2 参数传了但UI不刷新值传递埋的雷最容易让人懵的一个现象是数据源明明变了builder里的UI就是不更新。排查半天十有八九是参数用的是值传递。比如你写了一个全局builder接收一个对象作为参数Builder function wrongBuilder(item: TaskModel) { Text(item.title) }然后外部传入this.taskList[0]你期望修改taskList[0].title时UI跟着变结果纹丝不动。这是因为wrongBuilder接收的是TaskModel对象的一份引用但ArkUI基于$$引用传参才能建立依赖关系。你直接用item作为参数而不是$$: { item }ArkUI就不会去追踪传入数据的属性变化。正确的写法是Builder function rightBuilder($$: { item: TaskModel }) { Text($$.item.title) }调用时也保持对象结构rightBuilder({ item: this.taskList[0] })这样当taskList[0]里title字段变化时UI会更新。还有一种隐蔽情况你确实用了$$但builder内把一个对象属性又作为子参数传给了另一个函数那个函数如果不用$$接收同样会失去响应性。所以引用传递要一层层贯穿到底中途任何一层退化成普通参数刷新链路就会断掉。5.3 循环渲染和条件渲染中Builder的注意事项在ForEach循环里使用Builder非常常见。组件内Builder在ForEach里调用时一般写法是ForEach(this.taskList, (task: TaskModel) { this.TaskCardUI({ title: task.title, done: task.done }) }, (task: TaskModel) task.id)这里的TaskCardUI如果是组件内Builder它仍然能获取当前组件实例的this所以如果builder内部访问了组件其他状态刷新逻辑照常工作。但要注意ForEach的第三个参数也就是键值生成器一定要写稳定且唯一的标识。否则列表重排时builder可能复用了旧实例的快照出现数据错乱的问题。条件渲染方面Builder内部可以使用if/else。但在组件外Builder中条件判断依赖的数据如果是从参数拿到的请确保这些数据是通过$$引用传递的否则条件判断的变化不会触发UI分支切换。我之前在全局builder里写了一个if (count 0)的角标逻辑因为count用的是普通参数传进去数据变化后角标死活不消失改成$$引用传递之后立刻正常。另外不要在Builder里做太重的计算或网络请求它本质上是UI描述逻辑应当保持纯净。数据转换放在组件代码里完成然后把结果传给builder。5.4 最终怎么选看状态边界而不是代码量现在回到标题本身组件内和组件外使用Builder到底怎么选我的建议是看状态边界不要只看重复代码的行数。我给自己定了三条选择依据如果这段UI只服务于当前组件内部的局部功能比如某个列表项的标签组合、某个弹窗内部的头部区域那优先用组件内Builder。它少传参、刷新自动、阅读成本低。如果这段UI被两个以上的组件使用并且数据来源不同事件行为也不同那就应该抽成全局Builder。全局builder是“无状态UI模板”数据从参数进来事件通过回调出去。如果界面里有很多业务逻辑与状态操作比如编辑表单、轮播图这种就得考虑是不是该用自定义组件而不是Builder。Builder擅长的是“展示性复用”一旦要维护内部状态、生命周期、复杂的初始化逻辑自定义组件才是更稳的选择。用组件内Builder处理组件内部的局部结构用全局Builder处理跨页面的通用结构用自定义组件处理有状态和生命周期的复杂模块——这个分层思路在实际项目中非常顺。5.5 一个容易被忽略的细节Builder和BuilderParam的组合聊到组件复用很多人会把Builder和BuilderParam搞混这里简单提一下。BuilderParam是用于自定义组件内部定义插槽的装饰器它允许使用者在创建组件实例时传入一段UI结构由组件内部决定在什么位置展示。你可以把BuilderParam理解为“组件的自定义插槽”它接收的不是数据而是另一段Builder构建函数。如果你在设计一个通用容器组件比如列表卡片、对话框外壳将来希望外部往里塞不同的内容那BuilderParam会比全局Builder更合适。因为BuilderParam让组件内部有了一个预留的UI口子外部传什么它就显示什么和组件内外的状态隔离问题相反它是在组件边界做隔离。这里不展开细讲但知道它和Builder的关系能帮你建立更完整的ArkUI复用知识结构。我个人的实际体会是Builder最理想的用法是“小步快跑”越是边界清晰、数据少、行为简单的UI片段用Builder越顺手。别为了未来可能存在的复用而强行把builder提到全局先用组件内方式跑通需求等第二个页面真的出现同样的结构再抽全局也不迟。嵌套函数里层层传参的全局builder维护起来并不比复制一遍UI轻松多少。最后再分享一个小技巧不管是组件内还是组件外的Builder命名时最好统一带上“UI”后缀或前缀比如itemCardUI、globalEmptyStateUI。这样别人拿到代码一眼就明白这段函数是给用户界面用的从命名层面就降低了理解成本。这个习惯坚持下来你的ArkUI项目会好维护很多。
返回列表