列表项不刷新,这个话题在很多鸿蒙开发群里都能炸出几个人来。尤其是用了@Builder装饰器之后,数据源明明已经更新,界面却像被按了暂停键一样纹丝不动。其实这不怪ArkUI,也不怪你写的业务代码,真正要背锅的往往是@Builder的参数传递机制在起作用。今天这篇文章,我就把值传递和引用传递这件事一次性讲透,从原理到实战,把你在开发中可能踩过的坑都摊开来说。
先用一句话交代清楚:@Builder是ArkUI提供的自定义构建函数装饰器,它可以把一段UI结构封装成函数,在多个地方复用。它的参数传递分为按值传递和按引用传递两种方式,理解它们之间的差别,是你写出界面联动正确、逻辑不绕弯的鸿蒙应用必须跨过的一道门槛。这篇内容适合已经掌握ArkTS基础语法、正在进阶组件封装和状态管理的开发者,读完你能直接拿来排查自己项目里的类似问题。
1. 先搞懂 @Builder 是干嘛的:它替你省掉了什么
1.1 为什么需要 @Builder:一段 UI 写三遍之后的觉悟
在没有@Builder之前,如果你要在三个页面里展示同一种商品卡片的布局,你最直接的做法就是Ctrl+C、Ctrl+V,把那段Row、Column、Image、Text的组合从A页面复制到B页面再复制到C页面。写的时候确实痛快,但等到产品经理说要调整卡片的圆角、字号和间距时,你就得满项目地找这些重复代码,一处漏改就是一次线上事故。这种痛,经历过的人都懂。
@Builder解决的就是这个问题。它让你把一段UI描述包装成一个构建函数,需要时像一个普通函数一样调用,函数放在组件内部就是局部@Builder,放在组件外部就是全局@Builder。它做的本质上是“UI结构的代码级复用”,不是组件级复用。这意味着它不会创建一个新的组件实例,也不会有额外的组件通信开销,只是把这段UI描述“原地展开”。这个定位决定了它的使用方式,也决定了它在参数处理上的特殊性。
我打个比方:@Builder就像你做菜时写好的“配菜清单”,每次炒同一道菜时照着清单把材料摆好,而不是重新雇一个厨师(自定义组件)来开火。厨师的工资更高,但如果你只是想复用摆盘的方式,那清单就够用了。理解了这一点,你就明白为什么很多列表项、卡片标题、标签按钮都适合用@Builder封装,而不是非得拆成一个@Component子组件。
1.2 局部 @Builder 与全局 @Builder:访问 this 的边界
@Builder有两种定义位置,使用场景和约束完全不同。局部@Builder定义在组件内部,通过this调用,最大的好处是函数体内部可以直接访问组件的状态变量和方法。比如你可以在组件内写一个@Builder Card(),里面直接用this.title、this.desc来渲染,非常自然。
全局@Builder定义在组件外部,和普通顶层函数一样,它没有this,也拿不到任何组件的成员。它只能依赖通过参数传入的数据。这个约束很多人会忽略,结果在全局@Builder里尝试访问某个状态变量时直接编译报错,或者拿到的值是undefined,一头雾水。全局@Builder适合封装那种完全由数据驱动、不依赖页面上下文的UI片段,比如通用空状态、统一格式的标签、通用列表行。
typescript复制// 组件外定义全局 @Builder
@Builder function GlobalTag(text: string) {
Text(text)
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#E84026')
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
局部@Builder的写法只是在组件结构体内部,注意调用时用this前缀,否则编译器找不到:
typescript复制@Component
struct DemoPage {
@State count: number = 0
@Builder LocalCard() {
Column() {
Text(`当前数量:${this.count}`)
}
}
build() {
Column() {
this.LocalCard()
}
}
}
从长期的开发体验来看,我建议遵循一个原则:如果这个UI片段只在当前组件里用,就用局部@Builder;如果多个页面、多个组件都要用,而且靠参数就能画出来,就抽成全局@Builder。不要在局部@Builder里写太多和组件状态强耦合的逻辑,否则调用方想复用时会发现搬不动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制拆解:@Builder 的值传递和引用传递到底差在哪
2.1 值传递:靠参数快照渲染,不跟外部联动
所谓按值传递,就是你直接把一个变量、一个对象或一个数组作为参数传给@Builder函数。这个阶段的函数会拿你传入的值去做一次UI渲染,但不会和外部数据建立长期的联动依赖。翻译成大白话说就是:它只记住了你第一次给它的值,后面外部怎么变,它一概不管。
很多人的第一反应是:我传的不是一个对象吗?对象在JavaScript里不是引用类型吗?传引用应该会跟随变化啊。这个说法在普通编程里是对的,但在ArkUI的UI渲染体系里,“值传递”和“引用传递”的定义并不仅仅依赖数据类型,而是依赖ArkUI是否为目标参数建立了状态依赖。按值传递时,@Builder内部虽然拿到了对象的引用地址,但它没有登记“我要监听这个对象的哪些属性”,所以当属性变化时,UI刷新机制根本不会通知它。
我们来看一段典型的踩坑代码:
typescript复制@Builder function ValueCounter(count: number) {
Text(`当前数量:${count}`)
}
@Entry
@Component
struct ValueDemo {
@State count: number = 0
build() {
Column({ space: 12 }) {
ValueCounter(this.count)
Button('点击增加')
.onClick(() => {
this.count++
})
}
}
}
运行起来你会发现,每次点击按钮,this.count确实在增加,但ValueCounter里显示的文本永远都是0。这不是Bug,而是@Builder按值传递的预期表现。外层状态变化时,@Builder不会重新渲染。你也可以在@Builder里尝试修改count的值,同样影响不了外层的this.count,因为是拷贝的“快照”。
那么问题来了:什么时候你需要按值传递?答案是当UI内容是静态的、或者完全由调用时传入的一次性数据决定时。比如一个只显示用户名的头像组件,用户名不可能在运行中改变,你传个字符串进去就够了。如果你需要UI跟随数据变化,继续看下一节。
2.2 引用传递:用 $$ 接收对象字面量,建立状态依赖
按引用传递的写法和普通传参有明显区别,它要求你把参数包装成一个对象字面量,同时在@Builder函数的参数列表里用$$语法来接收这个对象。这里的$$并不是什么玄学,它是一个约定标记,告诉ArkUI:函数体中对这个对象属性的读写,都要和外部对应状态变量建立关联。
typescript复制@Builder function RefCounter($$: { count: number }) {
Text(`当前数量:${$$.count}`)
}
@Entry
@Component
struct RefDemo {
@State count: number = 0
build() {
Column({ space: 12 }) {
RefCounter({ count: this.count })
Button('点击增加')
.onClick(() => {
this.count++
})
}
}
}
这段代码跑起来后,效果和前面的值传递版本截然不同。每次点击按钮,RefCounter里的文本都会同步更新,而且这个更新不依赖你手动刷新组件。原因在于,当你传入{ count: this.count }这个对象字面量时,ArkUI会解析出count属性来自this.count这条状态链路,并在@Builder内部使用$$.count的位置建立一个依赖关系。之后this.count一旦变化,ArkUI就能顺着依赖关系定位到具体的Text组件,完成精准刷新。
这个机制在官方文档里通常叫“按引用传递参数”,它比按值传递多了一层状态管理开销,但并不复杂。实际开发中,凡是需要动态展示和联动的场景,都应该优先使用引用传递。操作起来也很简单:调用处写成someBuilder({ key: this.stateVar }),@Builder定义处写成@Builder someBuilder($$: { key: Type }),函数体里用$$.key访问属性。需要注意,对象字面量里可以有多个属性,$$: { item: ProductItem, showPrice: boolean }这种组合是合法的,ArkUI会分别为每个属性建立依赖。
2.3 两种传递方式的适用场景对比
| 场景特征 | 按值传递 | 按引用传递 |
|---|---|---|
| UI内容是否跟随外部数据变化 | 不跟随 | 跟随 |
| 传入格式 | 直接传变量、对象 | 必须传对象字面量 |
| 函数参数写法 | param: Type |
$$: { key: Type } |
| 函数体内访问方式 | param |
$$.key |
| 适合场景 | 静态标签、图标、一次性配置 | 列表行、购物车、动态详情 |
| 性能开销 | 更低 | 略高,但通常可忽略 |
需要澄清的是,值传递并不是错误用法,引用传递也不是银弹。有些UI片段本身就不需要动态更新,比如页面标题、分组标签、静态装饰元素,你强行上引用传递反而会让代码更啰嗦。反过来,如果你封装的@Builder内部展示的是会频繁变化的数据,比如商品数量、消息红点、进度百分比,那就毫不犹豫地用引用传递。选择的标准只有一个:这段UI需不需要跟外部状态一起刷新。需要就引用传递,不需要就值传递。
3. 实战:购物车列表从“不刷新”到“联动刷新”的完整改造
3.1 需求场景:做一个可增减数量的商品列表
理论知识说再多,不如直接上项目里最常见的场景。假设我们现在要做这样一个购物车页面:一个商品列表,每一项显示商品名称、单价、购买数量和小计金额,旁边有加号和减号按钮可以调整数量。列表底部有一个总价,要求随时跟随数量的变化更新。
这个需求在鸿蒙应用里非常典型,也很适合用来演示@Builder参数传递。我先把商品的数据结构定义出来:
typescript复制interface ProductItem {
id: string
name: string
price: number
count: number
}
接着在页面中维护一个商品数组:
typescript复制@State productList: ProductItem[] = [
{ id: 'p001', name: '鸿蒙定制保温杯', price: 99, count: 1 },
{ id: 'p002', name: 'ArkTS 实战手册', price: 69, count: 1 },
{ id: 'p003', name: '开发者机械键盘', price: 399, count: 1 }
]
这里用@State修饰数组,保证数组本身的变化能触发页面级别的刷新。接下来我分两个版本改造,先展示最容易出问题的写法,再给出修正方案。
3.2 第一版值传递踩坑:数据变了,UI 纹丝不动
很多人写@Builder时会很自然地把整个商品对象传进去,定义一个productRow(item: ProductItem)。我把这个版本完整写出来:
typescript复制@Builder
productRow(item: ProductItem) {
Row() {
Column() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(`单价:¥${item.price}`)
.fontSize(13)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(`小计:¥${item.price * item.count}`)
.fontSize(14)
.fontColor('#E84026')
Button('-')
.width(28)
.height(28)
.onClick(() => {
this.decreaseCount(item.id)
})
Text(`${item.count}`)
.fontSize(16)
.width(30)
.textAlign(TextAlign.Center)
Button('+')
.width(28)
.height(28)
.onClick(() => {
this.increaseCount(item.id)
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
}
increaseCount(id: string) {
this.productList = this.productList.map(item => {
return item.id === id ? { ...item, count: item.count + 1 } : item
})
}
decreaseCount(id: string) {
this.productList = this.productList.map(item => {
return item.id === id ? { ...item, count: Math.max(1, item.count - 1) } : item
})
}
列表渲染时,用ForEach遍历商品数组,每一项调用this.productRow(item):
typescript复制ForEach(this.productList, (item: ProductItem) => {
this.productRow(item)
}, (item: ProductItem) => item.id)
这个版本跑起来会让人非常困惑:你点“+”号,数据源productList的count确实变了,列表底部的总价(单独写的一个Text)也能正常更新,但每一行里的数量Text和小计Text死活不变,永远是初始值。哪怕你用日志把item.count打印出来,你也能看到它是最新的值,但界面就是不动。
为什么会出现这种“一半刷新一半不刷新”的现象?因为底部的总价直接读取了this.productList,属于组件级别的状态依赖,数组更新自然会刷新;而productRow(item)内的UI,由于是按值传递,没有注册到任何状态变量上,ArkUI不知道这段UI和this.productList有关系,自然不会去更新它。这就是值传递最坑的地方:它不会报错,看起来逻辑也对,但UI就是不响应。
3.3 第二版引用传递:数量、小计和总价全部联动
找到问题根源后,改法其实很简单:把参数从直接传对象改成传对象字面量,并用$$接收。下面是改造后的完整写法:
typescript复制@Builder
productRow($$: { item: ProductItem }) {
Row() {
Column() {
Text($$.item.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(`单价:¥${$$.item.price}`)
.fontSize(13)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(`小计:¥${$$.item.price * $$.item.count}`)
.fontSize(14)
.fontColor('#E84026')
Button('-')
.width(28)
.height(28)
.onClick(() => {
this.decreaseCount($$.item.id)
})
Text(`${$$.item.count}`)
.fontSize(16)
.width(30)
.textAlign(TextAlign.Center)
Button('+')
.width(28)
.height(28)
.onClick(() => {
this.increaseCount($$.item.id)
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
}
列表渲染处也要同步修改:
typescript复制ForEach(this.productList, (item: ProductItem) => {
this.productRow({ item: item })
}, (item: ProductItem) => item.id)
注意观察两个改动点:定义处的参数变成了$$: { item: ProductItem },函数体内的引用全部变成了$$.item;调用处则用{ item: item }把对象包了一层。就这么两处调整,再运行一次,点“+”和“-”时,每行的数量、小计、以及总价都会一起联动刷新,效果完全符合预期。
这个改造里还有一个细节值得强调:increaseCount和decreaseCount里我用的是整体替换数组元素的方式,也就是this.productList = this.productList.map(...)。这一步不能省,因为只有通过@State修饰的数组整体更新,ArkUI才能检测到变化并触发依赖它的UI刷新。如果你图省事,在方法里直接修改item.count,比如item.count++,即使@Builder用了引用传递,也不一定能让UI刷新,因为对象属性本身没有被状态管理器观察。这也是很多人从值传递改成引用传递之后依然不生效的最常见原因。
3.4 复盘:解决这个问题的关键在依赖关系
回过头看这个购物车案例,真正解决“不刷新”问题的核心并不是某个神秘API,而是理解了一个关键点:@Builder引用传递的本质是建立UI与状态变量之间的属性级依赖。
第一版失败,是因为按值传递根本没有建立依赖,ArkUI自然不会管你;第二版成功,是因为{ item: item }让ArkUI可以把$$.item解析到productList数组里的对应元素,进而注册依赖关系。当increaseCount通过整体赋值方式更新数组时,状态管理器检测到数组变化,沿着依赖链路去找哪些UI用了这个数组的元素,最终刷新了对应的行。
由此还能延伸出一个结论:如果你的@Builder内部只是展示静态数据,不参与任何状态联动,值传递就行;如果它内部有任何可能变化的数据,就统一用引用传递。不要混着用,混着用会让后来维护你代码的人疯掉,因为他们要先猜这个@Builder到底跟哪些状态有关联。
4. 与 @Prop/@Link/@BuilderParam 的边界:别把机制混着用
4.1 @Builder 参数传递与 @Prop/@Link 的本质区别
很多初学者会把@Builder的引用传递和@Prop、@Link混为一谈,觉得它们都是父组件往子组件传数据、然后跟着刷新。实际上这三者的层级完全不同。@Prop和@Link是自定义组件之间的状态同步机制,它们的载体一定是组件实例;@Builder则是构建函数内的UI片段复用,它不会被编译成独立的组件实例。
@Prop的特点是在子组件内部创建一份数据的副本,父组件数据变化时会把新值同步到这份副本,但子组件内部修改副本不会反向影响父组件,属于单向同步。@Link则是双向同步,父组件和子组件共享同一个状态,任意一端修改都会同步到另一端。这两者在处理复杂对象属性变化时有更完善的观察能力,代价是组件数量变多,状态链路变复杂。
@Builder的引用传递则轻量得多。它不创建组件,也没有自己独立的状态存储,只是让@Builder函数内部的UI与外部某些状态属性建立依赖。它的适用范围被局限在“同一组件上下文中的UI复用”,如果某个UI片段需要被一个独立的子组件使用,并且需要在子组件内部维护交互状态,那还是老老实实用@Prop或@Link拆组件更合适。
4.2 @BuilderParam:当插槽用的参数化构建函数
除了直接调用@Builder,ArkUI还提供了@BuilderParam装饰器,它可以把一个@Builder函数作为参数传给子组件,让子组件在指定位置渲染父组件定义的UI结构。官方文档里常把@BuilderParam类比成Vue插槽或Android的自定义View内容区域,这个类比很贴切。
典型用法是:
typescript复制@Component
struct CardContainer {
@BuilderParam customContent: () => void
build() {
Column() {
Text('卡片标题')
.fontSize(16)
.fontWeight(FontWeight.Bold)
this.customContent()
}
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}
}
@Entry
@Component
struct Page {
@Builder contentArea() {
Row() {
Text('这是外部传入的自定义内容')
Image($r('app.media.icon'))
}
}
build() {
Column() {
CardContainer({ customContent: this.contentArea })
}
}
}
这里@BuilderParam起到了一个占位作用。父组件把contentArea这个构建函数传给CardContainer,子组件在build中调用this.customContent(),就把父组件定义的那段UI渲染到了卡片内部。这种写法非常适合做通用容器组件,比如带标题、带内容区、带底部按钮的卡片,内容区完全由调用方自定义。
@BuilderParam本身也可以配合参数传递使用,比如@BuilderParam customContent: (item: ProductItem) => void,父组件传入的构建函数接受一个商品对象作为参数,子组件在遍历列表时调用this.customContent(currentItem),从而做到列表行UI完全由调用方定制。这种模式在写列表基类组件时相当实用。
4.3 选型建议:什么场景用哪种状态管理
根据我自己的项目经验,可以给出一个比较实用的选型思路,供你参考:
- 只是在一段UI里复用布局结构,不依赖动态数据,用@Builder值传递。
- UI结构复用,但数据会变化且需要和外部状态联动,用@Builder引用传递。
- UI片段需要被独立成组件,并且子组件内部有自己交互逻辑,用自定义组件加@Prop或@Link。
- 需要做通用容器,把内容区开放给调用方决定,用@BuilderParam。
- 父子组件需要双向同步一个值,首选@Link。
- 父子组件只需要单向展示,不希望子组件反向修改,用@Prop。
这套选型逻辑的核心原则是:能轻量解决就不要上重组件,能用组件解决就不要把大量逻辑塞进@Builder。@Builder虽然灵活,但如果一个构建函数里塞满了业务逻辑、嵌套调用、复杂判断,它的可读性和可维护性会迅速下降,到时候你宁愿拆成一个正经的组件。
5. 避坑合集:参数传递相关的常见问题与排查技巧
5.1 @Builder 内部的 this 和事件绑定问题
局部@Builder内部使用this是很方便的,但有一个细节容易被忽略:在build方法里调用@Builder函数时,如果函数没有通过this来调用,或者在非组件实例的上下文里传递,this就可能会指向错误。更常见的一个坑是,在全局@Builder里使用事件绑定时,如果你在onClick里引用了一个外部变量,一定要注意变量在闭包里捕获的值是哪个时间点的快照。
我处理这类问题有一个固定套路:尽可能让@Builder保持“受控”,也就是说它不主动修改外部数据,而是通过参数传入回调函数来通知外部执行修改。比如购物车案例里,加减按钮就可以传入一个回调参数:
typescript复制@Builder
productRow($$: { item: ProductItem, onChange: (id: string, delta: number) => void }) {
Button('+')
.width(28)
.height(28)
.onClick(() => {
$$.onChange($$.item.id, 1)
})
}
这种方式下,@Builder内部不依赖this,也不关心外部具体如何修改数据,只要调用方把回调传对就行。对于全局@Builder来说,这是最安全和可测试的写法。
5.2 多参数和复杂对象:怎么传才不容易翻车
有些人的@Builder参数一多就开始放飞,直接写成@Builder foo(a: string, b: number, c: boolean, d: SomeClass[])。这样写不是不行,但有几个隐患:参数顺序容易记错;值传递时每个参数都要单独建立传参逻辑;后期增加一个参数就要改所有调用处。
我建议的参数设计原则是:
- 三个以上参数就用对象字面量装起来,必要时结合引用传递。
- 复杂对象不要拆成多个基础类型参数,直接把对象传进去,内部按需取属性。
- 如果参数需要联动刷新,统一使用引用传递,不要让一部分参数是值传递、另一部分是引用传递。
举个例子,一个展示用户信息的@Builder可以这样设计:
typescript复制interface UserInfo {
name: string
avatar: string
level: number
isVip: boolean
}
@Builder
userInfoCard($$: { user: UserInfo, showLevel: boolean }) {
Row() {
Text($$.user.name)
if ($$.showLevel) {
Text(`Lv.${$$.user.level}`)
}
}
}
调用处传this.userInfoCard({ user: this.currentUser, showLevel: true })。这样的参数结构一目了然,后续加字段也不用大面积改动。
5.3 数组作为参数时的特殊表现与解决办法
@Builder参数如果是数组,情况会稍微复杂一点。数组本身是引用类型,但按值传递时,@Builder内部不会与数组建立依赖,所以即使你往数组里push了新元素,@Builder内的ForEach也不会自动增加一行。这一点和对象属性不刷新是同一个道理。
使用引用传递传入数组时,格式是arrayBuilder({ list: this.list })。这样@Builder内部对$$.list的读取会与this.list建立依赖。但这里还有一个坑:如果@Builder内部用ForEach遍历$$.list,当this.list通过push、splice等方法修改时,ArkUI的数组代理机制能否感知,取决于你的API版本和数据状态管理方式。最稳妥的写法是保持“整体替换”的习惯,也就是:
typescript复制this.list = [...this.list, newItem] // 追加
this.list = this.list.filter(...) // 删除
this.list = this.list.map(...) // 更新
整体替换虽然看起来多写了一点代码,但能让状态变化路径非常清晰,不用去猜某个数组方法到底有没有被代理。这个习惯也建议大家带到日常开发里,能避开大量隐形Bug。
5.4 实操速查表:5 分钟定位参数不传值的根因
如果你正在排查一个“@Builder里数据显示不出来/不更新”的问题,可以按下面的顺序逐项检查:
| 排查步骤 | 检查内容 | 解决方法 |
|---|---|---|
| 1 | 确认@Builder是被调用而不是被覆盖 | 局部函数用this调用,全局函数直接调用 |
| 2 | 确认参数传递方式 | 需要联动刷新的,必须传对象字面量并用$$接收 |
| 3 | 确认函数体内是否用了$$前缀 | 引用传递时,所有属性都要写成$$.xxx |
| 4 | 确认数据更新是否整体赋值 | 对象数组用map/filter整体替换,不要直接改属性 |
| 5 | 确认@Builder内部是否写了组件无法感知的逻辑 | 复杂计算建议提取成方法,保证依赖收集正常 |
| 6 | 确认key生成器是否稳定 | ForEach的key不要用index,用唯一id |
这套速查表帮我解决过不少团队里的问题,大多数人卡在第二步和第四步。
5.5 关于性能和维护:@Builder 也不是越多越好
@Builder能复用UI结构,但不代表应该把整个页面塞进几个巨大无比的@Builder函数里。一个常见的坏味道是:一个@Builder函数有七八个参数,函数体超过一百行,内部还嵌套了两层ForEach。这种代码跑起来可能正常,但维护起来极其痛苦,因为你很难一眼看出这个构建函数依赖了哪些状态,改了一个数据结构可能要翻好几个文件。
我通常建议一个@Builder函数做到“单屏可见”,也就是函数体尽量控制在一屏以内,职责单一。复杂的UI区域应该拆成多个@Builder组合,或者适度引入自定义组件。另外,@Builder内部的渲染节点越少越好,因为每次状态更新,依赖于这些状态的@Builder片段都可能被重新执行来计算UI差异。如果一个@Builder里塞了大量无关节点,状态稍微一变就会带来无谓的渲染开销。在列表这种高频渲染场景下,尤其要注意控制@Builder内部的复杂度。
这里再分享一个实测有效的性能技巧:如果ForEach的列表项是一个@Builder,而列表数据量很大,尽量让每一项@Builder接收最小的数据对象,而不是把整个页面状态都传进去。这样可以减少依赖范围,状态更新时ArkUI只需要重新计算实际变化的那几行。
我在实际开发中最深的体会是:@Builder这个装饰器并不难写,难的是理解它的依赖收集和刷新边界。很多人写来写去,最终效果不对,不是语法不会,而是不知道ArkUI到底在什么情况下会更新这段UI。把值传递和引用传递的差异吃透,等于拿到了诊断这类问题的钥匙。你以后再看别人的代码,一眼就能判断出一个@Builder是“静态摆设”还是“动态视图”,排查问题至少快一倍。
最后再给一个小技巧:全局@Builder如果想复用某些公共工具函数,直接调用即可,它本质上就是普通函数加了一层UI解析能力。但局部@Builder里引用组件方法时,注意别在异步回调里依赖this的指向,必要的时候用箭头函数绑定,或者像前面说的那样把修改操作设计成参数回调,让调用方处理状态更新。这个习惯在团队协作时尤其重要,因为不同人对this的理解程度不一样,接口设计越显式,出问题的概率越低。
