尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

鸿蒙ArkUI @Builder深度解析:组件内外差异与参数传值陷阱

鸿蒙ArkUI @Builder深度解析:组件内外差异与参数传值陷阱 先说一个我自己的经历。去年做鸿蒙应用改版项目里有个带进度条的项目卡片要在首页、分类页和搜索页三个地方出现样式一样只有旁边的按钮文案和点击回调不同。第一反应肯定是抽个自定义组件但写了一半发现不对劲为了一个卡片去定义Component要给每个字段都写Prop、给回调写Event光样板代码就快一百行了而这个卡片本身只是个静态展示加一个按钮。后来用Builder重构代码量直接砍掉一半。但真正让我卡壳的是另一个问题——同一个Builder写在不同位置到底有什么区别组件内用和组件外用是不是只是放哪儿的区别如果你也有同样的疑问这篇内容应该能帮到你。我会从机制层面拆解Builder把组件内和组件外的差异、参数传值的坑、实际项目的选型习惯全部展开最后附上我踩过的几个真实问题。这不是官方文档复读是照着代码一行行调出来的经验。1. Builder到底是什么先解决什么时候用它的疑问1.1 从一段重复的卡片代码说起先还原一个典型的重复场景。假设列表页里每个项目项都要渲染这样一个卡片左侧封面图中间标题和进度右侧一个操作按钮。Entry Component struct ProjectListPage { State projects: ProjectModel[] []; build() { List({ space: 12 }) { ForEach(this.projects, (item: ProjectModel) { ListItem() { Row({ space: 12 }) { Image(item.cover) .width(72) .height(72) .borderRadius(8) Column({ space: 4 }) { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Progress({ value: item.progress, total: 100 }) .width(100%) .color(Color.Blue) } .layoutWeight(1) Button(item.btnText) .onClick(() { // 处理点击 }) } .width(100%) .padding(12) .backgroundColor(Color.White) .borderRadius(12) } }, (item: ProjectModel) item.id) } } }这段代码本身没什么问题但同样的结构在首页、搜索页各复制了一份。等产品把标题换成了两行、按钮改为图标按钮时我意识到重复代码的维护成本已经高于抽组件了。两个方向一个是抽自定义组件另一个就是用Builder。两者的取舍是理解本文的关键。1.2 Builder和自定义组件、普通函数的边界先把概念说清楚。Builder在鸿蒙ArkUI里叫自定义构建函数它本质上是一个专门用来生成UI片段的方法。你写在Builder里的不是渲染逻辑而是UI结构描述这些描述会被框架编译成对应的组件树。它和自定义组件的本质区别在于自定义组件是一个类有生命周期aboutToAppear、aboutToDisappear等、有状态管理和自己的构建逻辑它是组件树上的一个节点。Builder只是一个方法它没有实例、没有生命周期它生成的UI会被拼到调用它的那个组件里不会形成独立节点。所以Builder更像是一个UI模板片段适合用来组织组件内部的重复结构或者在多个组件之间共享一段不复杂的UI。和普通函数的区别就更好理解了普通函数返回的是一个值Builder返回的或者说产出的是UI结构。你不能在普通函数里写Text()然后希望它渲染出来——那只是创建了一个对象而已。但在Builder里这些UI组件会被真正加入到渲染树中。理解这个边界之后什么时候用Builder就清楚了一段UI在单个组件里重复多次或者要在几个组件间复用但结构简单、不需要独立状态管理时优先用Builder。一旦这段UI需要有自己维护的状态、需要生命周期联动、需要被多个页面以不同逻辑复用那就老老实实抽自定义组件。2. 组件内Builder的完整写法和它吃的免费午餐2.1 成员函数的实现细节与可见性先看组件内Builder的标准写法。它实际上是Component结构体下的一个成员方法可以加private也可以不加。默认就是私有只能在当前组件内部使用。Component struct ProjectListPage { State projects: ProjectModel[] []; State currentTab: number 1; Builder projectCard(item: ProjectModel) { Row({ space: 12 }) { Image(item.cover) .width(72) .height(72) .borderRadius(8) Column({ space: 4 }) { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Progress({ value: item.progress, total: 100 }) .width(100%) .color(Color.Blue) } .layoutWeight(1) Button(item.btnText) .onClick(() this.handleCardClick(item)) } .width(100%) .padding(12) .backgroundColor(Color.White) .borderRadius(12) } build() { List({ space: 12 }) { ForEach(this.projects, (item: ProjectModel) { ListItem() { this.projectCard(item) } }, (item: ProjectModel) item.id) } } }这里有个细节值得注意在onClick回调里我写了this.handleCardClick(item)。这个this指向的是当前组件实例因为Builder方法运行在组件的上下文环境中所以可以直接访问组件里的所有属性和方法。这就是组件内Builder的最大优势——它可以隐式访问当前组件的this环境包括State、Prop、Link、普通成员变量、甚至是路由对象。这带来的好处很明显你不需要把组件里的各种状态作为参数传进去Builder方法体里直接看得到组件的所有数据。很多情况下你甚至可以不用给Builder传任何参数它直接读this上的值就行了Builder projectCardTitle() { Text(${this.currentTab} / ${this.projects.length}) }2.2 为什么它能直接访问this和State背后的原理其实不复杂。Builder在组件内定义时编译器会把方法绑定到这个组件实例上因此方法体内出现的this就是组件实例本身。State装饰的变量之所以能在Builder里访问并且后续状态变化能触发UI刷新是因为State的getter/setter机制和Builder的渲染追踪是在同一个框架链路里的。用生活化的方式理解组件是一个房间State是房间里的一块白板Builder是房间里的一台电视。电视播放的内容可以直接读取白板上写的东西。白板内容变了电视画面跟着刷新。整套联动都在房间内部完成不需要从外面递纸条进来。这一点在对比全局Builder时会显得尤为关键因为到了组件外面白板就不存在了。2.3 组件内Builder的适用场景从我的实践看组件内Builder最适合以下几种场景同一个组件里多个地方使用相同的UI结构比如列表的header和footer都有相同的标签样式。这个UI片段依赖于组件内部大量状态如果抽到全局参数会传递得又臭又长。你并不打算让别的组件复用这段UI抽出去反而增加理解成本。有个常见的错误倾向是觉得Builder很好用于是把所有相似的UI全抽成全局Builder。等到后期需求迭代时才发现这个UI片段和某个页面的状态耦合得太深传参已经传哭了。所以我的习惯是先在组件内写等确认要跨组件复用了再挪出去。3. 把构建函数搬到组件外面全局复用的代价与回报3.1 抽成全局Builder的第一步当首页、分类页、搜索页都要用同一张项目卡片时再让每个页面里各写一份组件内Builder就没有意义了。这时需要把它定义在组件外面变成全局构建函数。Builder export function projectCard(item: ProjectModel, btnText: string, onClick: () void) { Row({ space: 12 }) { Image(item.cover) .width(72) .height(72) .borderRadius(8) Column({ space: 4 }) { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Progress({ value: item.progress, total: 100 }) .width(100%) .color(Color.Blue) } .layoutWeight(1) Button(btnText) .onClick(onClick) } .width(100%) .padding(12) .backgroundColor(Color.White) .borderRadius(12) }组件里调用Entry Component struct HomePage { State projects: ProjectModel[] []; build() { List({ space: 12 }) { ForEach(this.projects, (item: ProjectModel) { ListItem() { projectCard(item, 查看详情, () this.handleClick(item)) } }, (item: ProjectModel) item.id) } } }和组件内版本对比关键变化在于全局Builder函数访问不到任何this。它不在任何组件的上下文里所以组件里所有的状态变量在全局函数内都是不可见的。想用数据就必须通过参数传进来想处理事件就必须通过回调函数传进来。这既是限制也是约束的回报。全局Builder是纯函数式的——输入参数输出UI。没有隐式依赖不读取全局上下文这意味着它的可复用性和可预测性比组件内版本更强。3.2 失去this之后参数成了唯一的桥梁刚才的例子已经体现了参数传递的核心思路数据通过形参传入交互通过回调形参传入。这里有一个非常重要的注意点——回调函数的this绑定。看这一行projectCard(item, 查看详情, () this.handleClick(item))回调写成了箭头函数this指向HomePage组件实例。如果写成projectCard(item, 查看详情, this.handleClick)在鸿蒙上大概率会出问题。因为this.handleClick是一个普通成员方法当你把它作为参数传出去再被调用时它的this已经丢失了运行时就会报Cannot read properties of undefined之类的错误。全局Builder还有一个容易被忽视的细节它不能直接使用BuilderParam那是组件内和自定义组件之间做插槽用的。全局Builder的复用方式就是纯参数没有插槽的概念。想实现卡片的尾巴可以自定义这种需求要么传给Builder一个自定义组件的构造器要么就放弃Builder改用自定义组件。3.3 全局Builder的适用场景与边界适合用全局Builder的场景其实比很多人想得更窄多个组件共享一段完全一样的UI片段。该UI片段的输入和输出可以全部用参数表达没有隐式状态依赖。该UI片段结构简单不涉及复杂动画、手势、状态管理。一旦你的需求超出了这个边界比如卡片内部自己维护了选中状态、比如点击卡片后要做复杂的入场动画、比如多个页面需要以完全不同的方式扩展卡片内容Builder就开始变形了。参数会越加越多回调会越传越深代码的可读性不升反降。这时候抽自定义组件才是正解。具体什么时候选哪个我在第5章会给出一个比较完整的决策清单。4. 状态刷新的分水岭参数按值传递和传引用($$)的区别4.1 默认按值传递的坑改状态不刷新这是Builder使用中最容易踩的坑也是组件内、组件外行为差异最明显的地方。先看一个反例。假设我有一个全局Builder渲染一段文本Builder export function tipsView(msg: string) { Text(msg) .fontSize(14) .fontColor(Color.Gray) }然后在一个页面的Button点击事件里修改了State messageEntry Component struct DemoPage { State message: string 初始文案; build() { Column({ space: 12 }) { tipsView(this.message) Button(修改文案) .onClick(() { this.message 修改后的文案 }) } } }运行一下会发现什么点击按钮UI没有变化。这就是按值传递的机制tipsView(this.message)在调用时把this.message的值复制了一份传给了tipsView的形参msg。之后this.message变了但msg还是旧值。组件内Builder遇到同样的写法也会这样吗不一定。如果组件内Builder里使用的是this.message而不是形参那状态变化会触发渲染更新因为this.message是一个State它的getter被Builder的渲染追踪机制标记了。但如果你在组件内Builder里也是用形参来接收this.message同样会被按值传递卡住。所以这个坑和组件内外无关和参数传递方式有关。但实际项目中全局Builder几乎百分百会用到参数传递而组件内Builder往往直接读this这就是为什么全局Builder遇到这个坑的概率远高于组件内的。4.2 用$$实现引用式传参华为官方给了解决方案按引用传递参数。写法是在Builder函数的形参定义里用$$包裹一个对象这个对象里再声明你需要的参数Builder export function tipsView($$: { msg: string }) { Text($$.msg) .fontSize(14) .fontColor(Color.Gray) }调用方式也要对应调整不能直接传字符串而是要传一个对象tipsView({ msg: this.message })这样传参时this.message和$$.msg之间就建立了数据联动关系。当this.message变化时Builder里的$$.msg也会拿到新值UI会随之刷新。这个机制的原理官方文档的说法是传递的是引用。在鸿蒙的渲染框架层面$$包装符让编译器不再简单复制值而是追踪这个属性与源状态变量之间的依赖关系。当源变量变更时框架会把新的值同步给使用了$$的Builder并触发重新渲染。对比一下两种方式的差异对比维度按值传递按引用传递$$传参写法tipsView(this.message)tipsView({ msg: this.message })形参写法msg: string$$: { msg: string }状态变化刷新不刷新刷新使用复杂度低中适用场景静态展示、一次性渲染需要跟随状态实时更新的UI4.3 组件内和组件外在这种场景下的真实差异踩过上面的坑之后我重新梳理了组件内和组件外Builder在处理状态刷新时的差异最后总结成一句话组件内Builder有两张牌可以打全局Builder只有一张。组件内Builder的第一张牌是直接访问this.xxx状态这样天然能做到状态变了UI就变第二张牌才是$$传参。全局Builder没有this可用只能靠参数传值所以全局版本只能靠$$这一张牌来建立数据联动。来个对比场景// 组件内版本直接使用this状态变了自动刷新 Component struct DemoPage { State count: number 0; Builder countView() { Text(数量${this.count}) } build() { Column({ space: 12 }) { this.countView() Button(加一).onClick(() this.count) } } }// 全局版本必须传$$才能联动 Builder export function countView($$: { count: number }) { Text(数量${$$.count}) } Component struct DemoPage { State count: number 0; build() { Column({ space: 12 }) { countView({ count: this.count }) Button(加一).onClick(() this.count) } } }这两种写法都能实现点击按钮后文本更新。但如果你在全局版本里粗心写成了countView(this.count)那就是按值传递UI永远不更新而且编译期不一定报错运行时在真机上往往要盯半天才发现问题。这里补充一个排查经验如果某个用Builder渲染的文本或组件在状态变化后不刷新第一步就要去检查它的参数传递方式。先看Builder的形参是不是用了$$再看调用处是不是传了对象而不是裸值。两个条件缺一个就不会刷新。5. 实际项目中我的选型习惯与踩坑记录5.1 什么时候坚持抽成自定义组件Builder确实轻量但轻量意味着功能边界也小。我给自己定了一个选型标准项目里遇到UI复用问题时按照下面这个顺序来判断这段UI只是结构重复、数据展示没有自己独立的状态也没有复杂的交互那就用Builder。优先组件内确认要跨组件了再挪到全局。这段UI内部有状态需要维护比如选中态、展开态那就直接用自定义组件。不要试图通过Builder加一堆参数和回调来模拟状态管理后期会很难受。这段UI要在不同页面展示不同内容但骨架相同属于同一个模板不同数据用Builder传数据是非常合适的。这段UI要做精细的动画和手势联动比如拖动、缩放、自定义转场用自定义组件。Builder没有生命周期监听很多时候你连触发动画的时机都拿不准。需要把一段UI作为插槽放到子组件的指定位置用BuilderParam。这是在自定义组件内部定义插槽标准做法不要试图用全局Builder去模拟插槽。5.2 几个容易误导人的细节除了参数传递方式的坑还有几个细节我经常在代码评审里看到在这里一并列出。第一个是不要在Builder方法体里定义State。我看到过有人这么写Builder card(item: ProjectModel) { State isLiked: boolean false; // 错误写法 // ... }编译直接报错。State只能用在Component结构体的顶层Builder方法体内只能写UI描述语句不能声明状态变量。如果你发现一段UI必须要一个局部状态那说明它应该抽成自定义组件。第二个是**Builder方法体内可以有条件判断和循环**。你可以正常使用if/else、ForEach这并不冲突。这非常适合做多态展示Builder statusBadge(status: string) { if (status success) { Text(已完成).fontColor(Color.Green) } else if (status error) { Text(已失败).fontColor(Color.Red) } else { Text(进行中).fontColor(Color.Orange) } }第三个是**Builder和BuilderParam的配合**。这是在自定义组件里定义插槽的机制。父组件可以传一个Builder方法给子组件子组件通过BuilderParam接收并渲染。这个功能很长一段时间我都没用上直到做详情页需要向弹窗组件里注入自定义按钮时才体会到它的便利。但它和组件内/外Builder的区别是两个维度的问题不要混淆。5.3 当UI没更新时我的完整排查链路最后记录一个排查链路。如果你在使用Builder时遇到UI不更新的情况按下面的顺序检查基本覆盖80%的原因确认状态变量是不是State或Prop、Link等装饰的。普通成员变量在状态变化时本来就不会触发UI刷新和Builder无关。确认数据是否从Builder的形参传入了。如果Builder内部是读this.xxx要看这个Builder是不是组件内的。如果它是全局的读到的this根本不存在运行时会直接报错。确认传递方式。形参如果是$$: { xxx: type }调用处必须传对象。形参如果是裸类型那状态变化不触发刷新是预期行为你需要把它改成$$形式。排除闭包捕获问题。在ForEach的循环里调用Builder要小心循环变量被闭包捕获的问题。ForEach的第三个参数keyGenerator一定要正确否则复用了旧组件实例可能导致状态明明变了但渲染层复用老节点。确认是否在if/else分支中。如果Builder渲染的组件不在当前分支中当然不会显示。看着像没更新其实是被隐藏了。有一次我排查一个列表删除后总数不刷新的问题步骤1、2、3全查完了也没发现问题。最后发现是ForEach的keyGenerator写错了每项的key里有重复值导致框架复用了错误的子组件节点UI重用了旧状态。那是另一个维度的坑了但排查的时候很容易和Builder的参数问题混淆。5.4 我最终的项目结构建议经过了几个项目的实践我现在更倾向于**组件内多用、全局慎用、复杂就抽组件**的策略。组件内的Builder我几乎每个页面都会用。比如列表页里的header、footer、空态、Loading态这些结构简单、和本页状态强依赖的UI片段用组件内Builder组织起来代码会非常清爽。它们不需要跨页面复用抽出去反而降低内聚性。全局Builder我只用在确认有两处以上需要复用的静态结构上。数量不会太多因为抽成全局意味着必须把所有的状态依赖都通过参数暴露出来如果参数超过三四个我就开始重新考虑是不是用自定义组件了。自定义组件则用来承载有状态、有生命周期、多页面以不同逻辑复用的UI。虽然样板代码多一点但它提供了状态隔离和生命周期管理这是Builder给不了的。很多时候重一点反而是稳一点的保证。结束语回到开头那个项目卡片的问题。我最后是怎么处理的卡片本身用全局Builder抽了出来因为三个页面的卡片高度一致数据全部可以通过参数传进去。但卡片上的进度条动画后来变成了一个需要内部定时器的状态组件那部分被单独抽成了一个ProgressCard自定义组件由Builder负责整体布局ProgressCard负责动画逻辑。两者配合代码结构清晰后续需求迭代也没有再大改。鸿蒙的Builder是个很灵活的工具难点不在于会不会写而在于能不能想清楚这段UI该放在哪和它和状态之间的联动关系是什么。把我上面的排查链路和选型标准记下来遇到实际问题时会少走不少弯路。
返回列表