先说问题子组件越通用越不能把 UI 写死我在拆 ArkUI 组件时最容易踩的坑不是语法而是边界。一个列表组件刚开始只服务一个页面把搜索栏、筛选按钮、空状态按钮都写在里面看起来很省事。等到第二个页面也想复用它时问题就来了头部区域不一样空状态按钮不一样点击动作不一样但列表主体又确实是同一套。这时如果继续给子组件加一堆布尔值比如showSearch、showFilter、showRetryButton组件很快就会变成“什么都知道、什么都管”的大组件。后面哪怕只是改一个按钮文案也可能影响另一个页面。BuilderParam适合解决的就是这种问题子组件负责布局位置和公共结构具体那块 UI 长什么样由父组件传进来。它不是为了炫语法而是为了把“公共结构”和“页面差异”拆开。环境和目标项说明系统方向HarmonyOS / ArkUI / ArkTS核心知识点Builder、BuilderParam、State、ObjectLink示例难度两个可复用组件案例筛选列表头部、空状态操作区要解决的问题父组件传 UI 片段后子组件不乱改外部状态状态刷新也不串先把版本和验证口径说清楚这篇不是只讲概念。我按一个可以在 Stage 模型里复现的小页面来拆父组件负责搜索条件和弹窗开关子组件只负责摆放公共列表结构再通过BuilderParam接住父组件传进来的头部、行内容和空状态操作区。项本文验证口径工程模型HarmonyOS Stage 模型ArkTS 页面组件写法ArkUI 语法Builder、BuilderParam、State、Observed、ObjectLink适配范围DevEco Studio 5.x/6.x 创建的 ArkUI 工程都可以按这个思路检查如果升级到 HarmonyOS 7.0 新 SDK先看编译器对装饰器和函数签名的提示再套同一套边界判断验证目标同一个公共列表组件被两个页面传入不同头部和不同空状态动作后不能出现状态串行、按钮动作串页、返回后旧状态残留判断结果筛选条件只在父页面变公共组件只摆结构行状态用可观察对象承接UI 和数据能对上最小复现先做一个会出问题的页面可以先故意写一个坏例子问题会更明显列表容器自己保存搜索词、分类、排序和空状态按钮。第一次写很快第二个页面复用时就开始分叉。Component struct BadReusablePanel { State keyword: string State category: string all State sortType: string score build() { Column({ space: 12 }) { Search({ value: this.keyword, placeholder: 搜菜谱 }) .onChange((value: string) { this.keyword value }) Row({ space: 8 }) { Button(this.category all ? 全部 : this.category) Button(this.sortType score ? 推荐优先 : 最近浏览) } if (this.keyword.length 0) { Button(清空关键词) .onClick(() { this.keyword }) } } } }这个坏例子的复现方法很简单把它同时放到搜索页和收藏页。搜索页想清空关键词收藏页想跳回推荐列表两个页面的按钮动作不同但公共组件已经把动作写死了。后面只能继续加参数组件会越来越难维护。验证步骤改完以后要看哪几个现象在页面 A 传入搜索头部输入关键词再切换分类确认只有页面 A 的keyword、category被修改。在页面 B 传入另一个头部比如只保留排序按钮确认页面 B 不需要知道页面 A 的搜索状态。让两个页面都使用同一个空状态组件但分别传入不同按钮动作A 清空关键词B 打开添加面板。快速切换筛选条件、返回页面、再次进入页面确认没有旧状态残留。如果行对象里有checked用ObservedObjectLink验证勾选后行 UI 和底部统计能同步。这几步能跑通才说明BuilderParam不是只写了语法而是真的把公共布局、页面状态和行数据边界拆开了。Builder、BuilderParam 和普通参数到底怎么分我现在的判断方式比较简单写法适合放什么不要拿它做什么Builder当前组件内部的一段 UI 模板不要拿它当跨组件插槽传来传去BuilderParam父组件传给子组件的一段 UI 片段不要让子组件偷偷管理父组件业务状态普通参数字符串、数字、布尔值、枚举、轻量配置不要用几十个参数硬拼一块复杂 UIState当前组件自己拥有、自己修改的状态不要拿它保存父组件已经管理的数据副本ObjectLink父组件传来的可观察对象字段不要传普通对象后期待深层字段自动刷新普通参数适合传“值”BuilderParam适合传“一块 UI”。如果这块 UI 里还会改状态状态最好仍然留在真正负责它的那一层不要因为用了插槽就把状态边界打乱。案例一筛选列表头部搜索栏和筛选条件由父组件决定先看一个比较常见的场景。一个列表页面有公共的列表容器但不同页面的头部不一样有的页面要搜索框有的页面要分类筛选有的页面还要排序切换。列表容器只需要知道“头部放在这里”不应该知道每个页面具体有哪些筛选项。一个容易失控的写法是这样Component struct RecipeListPanelBad { State keyword: string ; State category: string all; State sortType: string score; build() { Column() { Search({ value: this.keyword }) .onChange((value: string) { this.keyword value; }) Row() { Button(全部) Button(家常菜) Button(收藏优先) } // 下面才是真正通用的列表区域 List() { // render rows } } } }这个写法短期能跑但复用时会很麻烦。列表容器开始知道搜索、分类、排序后面另一个页面只想用列表主体也会被这些状态拖住。我更倾向于把头部变成一个插槽。列表容器只保留公共结构标题区、头部区、列表区、底部区。至于头部里放搜索框还是筛选按钮由父组件自己决定。Builder function EmptyHeader() {} Component struct RecipeListPanel { title: string ; total: number 0; BuilderParam headerBuilder: () void EmptyHeader; BuilderParam rowBuilder: (item: RecipeItem) void; private items: RecipeItem[] []; build() { Column({ space: 12 }) { Row() { Text(this.title).fontSize(20).fontWeight(FontWeight.Bold) Blank() Text(共 this.total 条).fontSize(13).fontColor(#666) } this.headerBuilder() List() { ForEach(this.items, (item: RecipeItem) { ListItem() { this.rowBuilder(item) } }, (item: RecipeItem) item.id) } } .padding(16) } }父组件里再决定头部到底长什么样Entry Component struct SearchRecipePage { State keyword: string ; State selectedCategory: string all; State sortType: string score; Builder buildFilterHeader() { Column({ space: 8 }) { Search({ value: this.keyword, placeholder: 搜菜名、食材或做法 }) .onChange((value: string) { this.keyword value; this.reloadByFilter(); }) Row({ space: 8 }) { Button(this.selectedCategory all ? 全部 : this.selectedCategory) .onClick(() this.openCategorySheet()) Button(this.sortType score ? 推荐优先 : 最近浏览) .onClick(() this.toggleSort()) } } } Builder buildRecipeRow(item: RecipeItem) { RecipeRow({ item: item }) } build() { RecipeListPanel({ title: 菜谱列表, total: this.filteredCount, headerBuilder: this.buildFilterHeader, rowBuilder: this.buildRecipeRow }) } private reloadByFilter() { // 按 keyword、selectedCategory、sortType 重新计算列表 } private openCategorySheet() {} private toggleSort() {} }这个拆法的好处很直接筛选状态还在页面层列表组件不需要知道筛选规则。以后另一个页面只想换一套头部传另一个Builder就行。案例二空状态操作区按钮动作不要写死在公共组件里第二个例子更容易被忽略。很多页面都有空状态但空状态后面的动作不一样搜索页可能要“清空关键词”收藏页可能要“去首页看看”购物清单页可能要“添加食材”。如果空状态组件把按钮写死后面肯定会越改越乱。Builder function EmptyAction() {} Component struct SmartEmptyView { icon: ResourceStr $r(app.media.empty_box); title: string ; message: string ; BuilderParam actionBuilder: () void EmptyAction; build() { Column({ space: 12 }) { Image(this.icon).width(96).height(96) Text(this.title).fontSize(18).fontWeight(FontWeight.Medium) Text(this.message).fontSize(14).fontColor(#777).textAlign(TextAlign.Center) this.actionBuilder() } .width(100%) .padding({ top: 40, left: 24, right: 24 }) } }搜索页传自己的动作Component struct RecipeSearchEmptyCase { State keyword: string 番茄牛腩; Builder buildSearchEmptyAction() { Button(清空关键词重新搜) .onClick(() { this.keyword ; this.searchAgain(); }) } build() { SmartEmptyView({ title: 没有找到相关菜谱, message: 当前关键词没有匹配结果可以换个食材试试。, actionBuilder: this.buildSearchEmptyAction }) } private searchAgain() {} }购物清单页也用同一个空状态但动作完全不同Component struct ShoppingListEmptyCase { State showAddPanel: boolean false; Builder buildShoppingEmptyAction() { Button(添加第一种食材) .onClick(() { this.showAddPanel true; }) } build() { SmartEmptyView({ title: 购物清单还是空的, message: 做菜前可以先把食材列出来买菜时不容易漏。, actionBuilder: this.buildShoppingEmptyAction }) } }这里的关键不是“能不能写出来”而是公共组件没有替父页面做决定。它只负责摆放图标、标题、说明和操作区。按钮文案、点击行为、状态修改都留给外层页面。状态边界怎么定别让插槽顺手改乱数据BuilderParam本身只是传 UI 片段不等于状态可以随便传。我的经验是先问三个问题这块状态是谁创建的如果是页面创建就尽量由页面修改。子组件只是展示还是要真正拥有这份状态只是展示就不要复制一份。这块 UI 会不会被多个页面复用会复用就不要把业务动作写死在公共组件里。如果传的是普通对象还要注意可观察能力。比如行数据里有checked页面勾选后希望行组件立刻刷新就不能一边传普通对象一边期待深层字段变化自动触发 UI。Observed class ShoppingIngredient { id: string ; name: string ; checked: boolean false; } Component struct ShoppingIngredientRow { ObjectLink item: ShoppingIngredient; build() { Row() { Checkbox({ name: this.item.id, group: shopping }) .select(this.item.checked) .onChange((checked: boolean) { this.item.checked checked; }) Text(this.item.name) } } }这种写法比“子组件拿到一个普通对象后自己改字段”更稳因为数据是可观察的行状态变化能和 UI 刷新对上。和BuilderParam搭配时原则一样UI 可以传进来状态归属要想清楚。怎么复现边界没拆清楚的问题我一般用下面几步检查先让两个页面复用同一个列表或空状态组件。两个页面传不同头部或不同按钮动作。快速切换筛选条件、空状态动作和列表刷新。观察是否出现按钮文案错乱、旧状态残留、当前行不刷新、底部统计不同步。如果这些问题出现了多半不是BuilderParam本身有问题而是组件职责没拆清楚。公共组件管太多页面状态又被复制了一份最后就会出现“看起来改了实际没跟上”的错觉。几种写法对比方案优点问题我会怎么选全部写死在子组件最省事第一次开发快复用后条件分支越来越多只适合一次性页面用普通参数控制显示适合简单开关参数多了可读性很差3 个以内还可以更多就要警惕用BuilderParam传 UI公共结构和页面差异能拆开状态归属要提前想清楚适合头部、底部、空状态、行操作区直接拆成多个组件边界最清晰公共布局可能重复页面差异非常大时使用能不能封装成固定写法可以。我会把这类组件按“壳组件 插槽 页面状态”的方式封装壳组件负责稳定结构比如标题、间距、列表容器、空状态外观。插槽负责页面差异比如筛选栏、按钮组、行操作区。页面负责业务状态比如关键词、分类、排序、是否显示弹窗。数据对象需要跨组件响应时再配合Observed和ObjectLink。这样以后新增页面时不需要复制一套列表组件也不需要在公共组件里继续堆判断。要改哪个页面就改哪个页面传进去的 builder。补一个最小可跑的验证页面为了避免只讲概念我会单独写一个最小页面来验证。这个页面不依赖后端也不依赖真实图片资源只看BuilderParam、父组件状态和子组件插槽的关系。验证项内容工程模型Stage 模型 ArkTS 页面UI 框架ArkUI 声明式组件状态能力State、Builder、BuilderParam、Observed、ObjectLink验证目标父组件更新关键词、分类、勾选状态后插槽 UI 和列表行能同步刷新排查重点组件复用后是否出现旧状态残留、行状态串行、空状态按钮动作写死下面这段可以直接放到一个 ArkTS 页面里做验证。真实项目里可以把DemoRecipe换成自己的数据模型。Observed class DemoRecipe { id: string; name: string; category: string; checked: boolean; constructor(id: string, name: string, category: string, checked: boolean false) { this.id id; this.name name; this.category category; this.checked checked; } } Builder function DefaultHeader() {} Component struct DemoListShell { title: string ; items: DemoRecipe[] []; BuilderParam header: () void DefaultHeader; BuilderParam row: (item: DemoRecipe) void; build() { Column({ space: 12 }) { Row() { Text(this.title).fontSize(20).fontWeight(FontWeight.Bold) Blank() Text(当前 this.items.length 条).fontSize(13).fontColor(#666) } this.header() if (this.items.length 0) { Text(没有匹配结果).fontSize(14).fontColor(#999).padding(20) } else { List({ space: 8 }) { ForEach(this.items, (item: DemoRecipe) { ListItem() { this.row(item) } }, (item: DemoRecipe) item.id) } } } .padding(16) } }页面层负责关键词、分类和列表过滤。注意这里没有把搜索逻辑塞进DemoListShell公共组件只负责结构。Entry Component struct BuilderParamVerifyPage { State keyword: string ; State category: string 全部; State source: DemoRecipe[] [ new DemoRecipe(1, 番茄炒蛋, 家常菜), new DemoRecipe(2, 红烧牛腩, 硬菜), new DemoRecipe(3, 凉拌黄瓜, 凉菜) ]; private get filtered(): DemoRecipe[] { return this.source.filter((item: DemoRecipe) { const hitKeyword this.keyword.length 0 || item.name.includes(this.keyword); const hitCategory this.category 全部 || item.category this.category; return hitKeyword hitCategory; }); } Builder buildHeader() { Column({ space: 8 }) { Search({ value: this.keyword, placeholder: 输入菜名试试 }) .onChange((value: string) { this.keyword value; }) Row({ space: 8 }) { ForEach([全部, 家常菜, 硬菜, 凉菜], (name: string) { Button(name) .backgroundColor(this.category name ? #D65A31 : #F2F2F2) .fontColor(this.category name ? Color.White : #333) .onClick(() { this.category name; }) }, (name: string) name) } } } Builder buildRow(item: DemoRecipe) { Row({ space: 10 }) { Checkbox({ name: item.id, group: recipe }) .select(item.checked) .onChange((checked: boolean) { item.checked checked; }) Text(item.name).fontSize(16) Blank() Text(item.category).fontSize(12).fontColor(#777) } .padding(12) .borderRadius(8) .backgroundColor(#FFFFFF) } build() { DemoListShell({ title: 菜谱筛选验证, items: this.filtered, header: this.buildHeader, row: this.buildRow }) } }验证时重点看四个现象输入“番茄”后列表只剩番茄炒蛋头部搜索框仍然在父页面状态里。切换“硬菜”后列表变成红烧牛腩公共列表组件没有新增任何分类判断。勾选某一行后再切换分类回来行状态不应该串到另一行。把DemoListShell换到另一个页面只需要换header和row不用改壳组件。如果第 3 步出问题优先检查ForEach的 key 是否稳定再检查对象是不是可观察对象。如果 key 用了数组下标筛选后行复用就很容易把状态带错。这类问题以后怎么避免我会在写公共组件前先定三个边界公共组件只管稳定结构不管页面业务判断。页面状态留在页面层除非子组件确实拥有这份状态。插槽只传 UI数据刷新靠稳定 key 和可观察对象兜住。这样写出来的组件虽然一开始多几行代码但后面复用时会轻很多。尤其是列表页、详情页、空状态、筛选栏这类反复出现的地方边界一旦拆清楚后面加页面不会一直改老组件。版本边界不要把状态管理 V1 和 V2 混在一起这篇示例讨论的是 ArkUI 组件化里的BuilderParam插槽传递以及State、Observed、ObjectLink这组状态能力的配合。这里有一个很容易忽略的点项目如果已经迁到状态管理 V2写法和装饰器选择会变不能直接把 V1 示例原样搬过去。场景建议检查原因老页面维护确认是否仍在使用State、Observed、ObjectLink这些写法属于常见 ArkUI 状态管理写法适合解释父子组件刷新边界新页面开发先看项目是否统一采用状态管理 V2如果混用两套状态模型排查刷新问题会更麻烦组件封装先定插槽职责再定状态归属BuilderParam只解决 UI 片段传递不替你解决数据同步列表复用同时检查ForEach稳定 keykey 不稳定时行组件复用会把旧状态带到新数据上我会把这类文章里的代码当成“排查方法示例”不是官方完整模板。真正落到项目时要先确认自己的 DevEco Studio、HarmonyOS SDK API、ArkUI 状态管理版本再决定是否继续用这组装饰器。我会怎么把它放进项目检查实际排查时我不会只看页面能不能打开而是按下面顺序验打开页面后先切换一次筛选条件确认headerBuilder里的State能触发列表刷新。再勾选列表行确认ObjectLink指向的对象字段变化能更新当前行。接着切换分类让列表数据数量发生变化确认ForEach的 key 没有用数组下标。最后把同一个壳组件放到另一个页面只替换header和row确认公共组件没有业务判断。这四步都过了才说明这次封装是真的把边界拆开了。只要其中一步出问题就回到三件事上查插槽是否承担了业务状态、状态对象是否可观察、列表 key 是否稳定。常见错误和修正方式现象常见原因修正方式筛选条件变了列表没刷新筛选状态被复制进子组件父组件改了子组件不知道筛选状态留在父组件子组件只接收过滤后的数据勾选 A 行后筛选回来 B 行也像被勾了ForEachkey 不稳定行组件复用了旧状态使用业务 id 做 key不要用 index空状态按钮每个页面都要改公共组件按钮动作写死在公共空状态里用BuilderParam把操作区交给页面传入传对象后字段改了 UI 不动对象不是可观察对象子组件也没有正确接收按场景使用Observed和ObjectLink最后总结一下BuilderParam最值得用的地方不是简单把一段 UI 从 A 文件搬到 B 文件而是把组件边界拆清楚。公共组件负责“这块区域放在哪里”父组件负责“这块区域具体放什么”。如果只是一个字符串、数字、布尔值用普通参数就够了。如果是一整块可变 UI比如筛选头部、空状态按钮、列表行右侧操作区再考虑BuilderParam。如果这块 UI 还牵涉状态刷新就顺手把状态归属也梳理清楚不要让子组件复制父组件的数据。这套判断方式落到项目里会少很多后期返工组件能复用状态不会乱串问题也更容易定位到具体页面或具体公共组件。