在这里插入图片描述

HarmonyKit | 鸿蒙新特性:@Builder 构建器的实战技巧与避坑指南

引言:一段让编译器崩溃的代码

HarmonyKit 的第一个版本里,主页 Index.ets 有 5 个 TabContent,每个里面都写了一段几乎完全相同的 Grid 布局代码——Scroll 包裹 Grid,Grid 里用 ForEach 渲染工具卡片。5 份代码的区别只有一个参数:getToolsByCategory() 传入的分类名不同。

这不是代码重复的问题,是代码重复导致的质量问题。当我给工具卡片加了一个 shadow 属性后,我只改了第一个 Tab 的 Grid,忘记了后面 4 个。于是首页出现了诡异的效果——"全部"标签下的卡片有阴影,"格式化"标签下的卡片没有。更糟糕的是,这个 bug 在视觉上非常隐蔽,我发现它纯粹是因为偶然左滑切换了 Tab。

这就是 @Builder 存在的意义。它不是语法糖,而是 UI 一致性的保证机制。这篇文章将基于 HarmonyKit 项目中真实的 Builder 使用场景,讲清楚 @Builder 的使用模式、@BuilderParam 的通道能力、以及 Builder 与 @Component 的选择边界。

项目仓库:https://atomgit.com/VON-/harmony-kit

@Builder 的本质:函数化的 UI 片段

ArkTS 的 @Builder 装饰器将一个方法标记为"UI 构建方法"。被 @Builder 标记的方法可以在 build() 方法中被调用,就像调用一个无状态的子组件一样。但它的本质是一个编译期展开的函数——不会产生独立的组件实例,不参与组件树,不拥有独立的状态。

HarmonyKit 中第一个被抽离的 Builder 是主页的 ToolGrid
在这里插入图片描述

@Builder
ToolGrid(category: string) {
  Scroll() {
    Grid() {
      ForEach(getToolsByCategory(category), (tool: ToolItem) => {
        GridItem() {
          ToolCard({ tool: tool })
        }
      })
    }
    .columnsTemplate('1fr 1fr')
    .columnsGap(12)
    .rowsGap(12)
    .padding({ left: 16, right: 16, top: 8, bottom: 80 })
    .width('100%')
  }
  .scrollBar(BarState.Off)
}

这 16 行 Builder 在 HarmonyKit 中被 5 个 TabContent 共享调用:

HdsTabs({ controller: this.controller }) {
  ForEach(CATEGORIES, (cat: string) => {
    TabContent() {
      this.ToolGrid(cat)  // 同一份代码,5 个 Tab
    }
    .tabBar(cat)
  })
}

getToolsByCategory(cat) 是一个数据过滤函数,它接收分类名,返回 ToolItem[] 数组。5 个 Tab 分别传入"全部"“格式化”“编解码”“计算”“文本”。每次切换 Tab 时,ToolGrid 使用新的分类参数重新渲染网格。但 Builder 本身只定义了一次。

如果以后需要修改网格布局——比如把 2 列改成 3 列——只需要改 columnsTemplate('1fr 1fr') 这行代码,5 个 Tab 同时生效。这不仅是节省代码,更是消除了一类最常见的"改了这里忘了那里"的 bug。

注意 padding 中的 bottom: 80。这个数值被精确设定为 80vp,而不是常见的 16vp 或 24vp。原因是主页使用了 HdsTabsbarOverlap(true) 模式——底部导航栏悬浮在内容之上。如果不留出足够的底部 padding,最后一行的工具卡片会被底部导航栏遮挡。80vp 等于底部悬浮导航栏的高度(约 48vp)加上 barBottomMargin(36vp)再减去一些重叠。这是经过反复试调得到的经验值——少了挡内容,多了浪费空间。

@Builder 的三种使用场景

场景一:组件内复用——StatCard 的 6 次调用

TextCounter(文本统计工具页)需要在页面中展示 6 个统计指标:总字符数、不含空格数、字节数、行数、单词数、中文字符数。每个指标的展示模式完全相同——一个醒目的数字 + 一个灰色的小标签。我提取了 StatCard Builder:
在这里插入图片描述

@Builder
StatCard(label: string, value: string, color: string) {
  Column() {
    Text(value)
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor(color)
      .fontFamily('monospace');

    Text(label)
      .fontSize(10)
      .fontColor('#999')
      .margin({ top: 4 });
  }
  .width('100%')
  .padding({ top: 14, bottom: 14 })
  .backgroundColor('#ffffff')
  .borderRadius(10);
}

调用方非常简单:

Grid() {
  GridItem() { this.StatCard('总字符数', String(this.charCount), '#007aff'); }
  GridItem() { this.StatCard('不含空格', String(this.charNoSpace), '#5856d6'); }
  GridItem() { this.StatCard('字节数', String(this.byteCount), '#34c759'); }
  GridItem() { this.StatCard('行数', String(this.lineCount), '#ff9500'); }
  GridItem() { this.StatCard('单词数', String(this.wordCount), '#af52de'); }
  GridItem() { this.StatCard('中文字符', String(this.cnCharCount), '#ff3b30'); }
}
.columnsTemplate('1fr 1fr 1fr')

三个参数(label、value、color)决定了卡片展示什么数据和用什么颜色强调。6 次调用,6 组不同的参数,但渲染逻辑只有一份。

用 @Builder 而不是再写一个 @Component 的原因很简单:StatCard 不需要状态管理。它不持有 @State,不需要生命周期回调(aboutToAppear/aboutToDisappear),不需要属性装饰器。它仅仅是三个入参驱动一段 UI 布局。Builder 是这种场景下最轻量的选择。

如果用 @Component 来实现,意味着每个 StatCard 都会产生独立的组件实例,占用额外的组件管理开销。6 个 StatCard 就是 6 个组件实例——虽然对于现代手机来说这点开销可以忽略不计,但"无状态时用 Builder,有状态时用 Component"是一种值得养成的代码习惯。它让你的代码意图更明确:其他人看到 @Builder 就知道"这是一个纯展示的 UI 片段",看到 @Component 就知道"这个组件有自己的状态或生命周期逻辑"。

场景二:跨组件传递——@BuilderParam 的通道能力

这个场景 HarmonyKit 目前没有使用,但在更大的项目中非常重要,值得一提。@BuilderParam 允许父组件将自己的 @Builder 方法传递给子组件,子组件在特定位置渲染这个 Builder:

// 子组件:预留一个 Builder 插槽
@Component
struct CustomContainer {
  @BuilderParam header: () => void;

  build() {
    Column() {
      this.header()  // 渲染父组件传入的 header Builder
      Text('Content...')
    }
  }
}

// 父组件:定义并传入 Builder
@Component
struct Parent {
  @Builder
  myHeader() {
    Text('Custom Header').fontSize(20).fontWeight(FontWeight.Bold)
  }

  build() {
    CustomContainer({ header: this.myHeader })
  }
}

这种模式类似于 React 的 render props 或 Vue 的 slot。鸿蒙的设计称之为"尾随闭包"。子组件暴露一个"插槽",父组件注入具体内容。对于需要高度自定义布局的容器组件(如弹出面板、抽屉、对话框),@BuilderParam 是实现"框架写壳,业务写肉"的最佳工具。

需要注意的是,@BuilderParam 只能接受 () => void 类型的 Builder——无参数的 Builder。如果需要传入参数,需要采用别的方式,比如用 @Provide/@Consume 传递数据,或者将 Builder 包装在一个带有状态的 @Component 中。

场景三:NavPathStack 路由映射

这是 HarmonyKit 早期开发时考虑过但最终未采用的模式。在复杂应用中,NavPathStack 需要一个路由映射函数——根据路由名称返回对应的页面组件。用 @Builder 是实现路由映射的最简洁方式:

@Builder
NavMap(name: string, _param: Object) {
  if (name === 'json') {
    JsonFormatterPage()
  } else if (name === 'base64') {
    Base64ToolPage()
  } else if (name === 'hash') {
    HashCalculatorPage()
  }
  // ...
}

这种 if-else 链路由映射器是 ArkUI 导航体系的标准写法。它有两个特点:第一,每个分支返回一个以 () 调用的组件构建函数——这是 Builder 中嵌套组件的标准语法;第二,_param 参数以下划线开头——表示"我收到了这个参数但暂时不用它"——这是 ArkTS 处理未使用参数的约定。

HarmonyKit 没有使用 NavPathStack 导航而是用了传统的页面路由 router.pushUrl()。原因是 HarmonyKit 是单窗口应用,页面栈深度不超过 2 层(主页 -> 工具页),传统的 pushUrl/back 模式足够简单可靠。NavPathStack 更适合多窗口、多级嵌套导航的复杂场景。

@Builder 的语法约束与注意事项

1. Builder 内的变量作用域

Builder 方法内部遵循特殊的作用域规则。Builder 可以访问 this 上的 @State@Prop 属性,也可以访问传入的参数,但不能访问 this 上的普通类成员变量(非响应式变量)。

以下代码在 Builder 中会编译报错:

// 错误示例
private prefix: string = '工具: ';

@Builder
myBuilder(name: string) {
  Text(this.prefix + name)  // 错误!Builder 不能访问非响应式类成员
}

解决方案是将普通变量声明为 @State:

// 正确示例
@State prefix: string = '工具: ';

@Builder
myBuilder(name: string) {
  Text(this.prefix + name)  // 正确,@State 变量可以在 Builder 中访问
}

或者将变量作为 Builder 参数传入:

// 正确示例
@Builder
myBuilder(name: string, prefix: string) {
  Text(prefix + name)
}

这个限制是合理的。Builder 不创建独立的组件实例,它运行在父组件的上下文中。如果 Builder 可以随意访问父组件的所有成员,那么父组件的细微改动(改变一个变量的值、删除一个方法)都可能影响 Builder 的渲染——类型安全性荡然无存。限制为只能访问响应式变量,意味着 Builder 的依赖关系是显式的、可追踪的。

2. build() 方法的严格限制

ArkUI 的 build() 方法有严格的语法限制:只能在 build() 中写 UI 组件语法。不能声明变量(let x = ...),不能用 if (condition) { ... } 包裹非 UI 逻辑,不能用 switch 语句。所有逻辑必须放在 Builder 的表达式层面或委托给外部方法。

下面的代码看起来合理但编译不通过:

build() {
  Column() {
    if (this.isLoading) {
      Text('加载中...')  // 这行 OK——它是 UI 表达式
    } else {
      let result = this.processData();  // 错误!build() 中不能用 let
      Text(result)
    }
  }
}

正确的写法是将逻辑抽到外部方法:

build() {
  Column() {
    if (this.isLoading) {
      Text('加载中...')
    } else {
      Text(this.getProcessedResult())  // 调用外部方法
    }
  }
}

getProcessedResult(): string {
  let result = this.processData();  // 逻辑在 build() 外部,合法
  return result;
}

3. @Builder 不支持属性装饰器

不能给 Builder 方法使用 @State、@Prop、@Link、@Provide、@Consume 等装饰器。Builder 方法的唯一数据来源是参数和父组件的响应式变量。如果需要状态管理,必须将逻辑提升到 @Component 中。

// 错误!Builder 不支持 @Prop
@Builder
MyBuilder(@Prop title: string) { ... }

// 正确做法:参数不加装饰器
@Builder
MyBuilder(title: string) { ... }

这个限制意味着 Builder 本质上是一个"渲染函数"——给定输入参数,返回 UI 输出。这就是为什么它叫 Builder 而不是 Component。它构建 UI,但不管理状态。

@Builder 与 @Component 的选择决策树

在 HarmonyKit 的开发过程中,对于"什么时候用 Builder 什么时候用 Component",我总结了一条简单的决策树:

第一步:是否需要 @State 或 @Prop 装饰器?
如果需要 → @Component。Builder 不支持这些装饰器。

第二步:是否需要生命周期回调?
如果需要 aboutToAppear、aboutToDisappear、onPageShow 等 → @Component。Builder 不能注册生命周期。

第三步:是否需要 @Provide/@Consume 等数据共享机制?
如果需要 → @Component。Builder 的依赖注入能力有限。

第四步:以上都不需要?
→ @Builder。更轻量,更高效,代码意图更明确。

用 HarmonyKit 的实际例子来看:

场景 选择了 原因
工具卡片 ToolCard @Component 需要 @Prop 接收 ToolItem,需要 @State 管理按下效果
网格布局 ToolGrid @Builder 无状态,纯布局模板,被 5 个 Tab 复用
统计卡片 StatCard @Builder 无状态,3 个参数驱动,6 次调用
复制按钮 CopyButton @Component 需要 @Prop 接收文本,内部调用系统 API
页面 Index @Component + @Entry 顶层路由页面,持有多个 @State

ToolCard 必须是 @Component 的核心原因是它需要 @State isPressed: boolean = false; 来管理按下时的缩放和透明度动画。Builder 不能持有状态,也就无法实现"按下时卡片缩小到 0.96 倍"的交互反馈:

@Component
export struct ToolCard {
  @Prop tool: ToolItem;
  @State isPressed: boolean = false;

  build() {
    Column() {
      // ... 图标、名称、描述
    }
    .scale({ x: this.isPressed ? 0.96 : 1.0, y: this.isPressed ? 0.96 : 1.0 })
    .opacity(this.isPressed ? 0.8 : 1.0)
    .animation({ duration: 150, curve: Curve.EaseOut })
    .onTouch((event: TouchEvent) => {
      if (event.type === TouchType.Down) {
        this.isPressed = true;
      } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
        this.isPressed = false;
      }
    })
  }
}

这个 @State 驱动的按下效果是 ToolCard 必须用 @Component 的根本原因。如果去掉 isPressedonTouch,ToolCard 本质上也可以是一个 Builder——但那就失去了交互反馈,卡片变成了"硬邦邦"的静态块。

CopyButton 必须是 @Component 的原因是它接收 @Prop 并调用系统 API(粘贴板服务)。Builder 虽然可以调用 this.getUIContext(),但无法通过 @Prop 标记参数——这使得它的输入值不参与响应式更新。

一个实战中的坑:Builder 中的空白区域

在 HarmonyKit 开发初期,TextCounter 页面的底部有一个奇怪的 24vp 空白行:

// 错误的写法
@Builder
StatCard(label: string, value: string, color: string) {
  Column() {
    Text(value)...;
    Text(label)...;
  }
  .padding({ top: 14, bottom: 14 })
}

Grid() {
  GridItem() { this.StatCard(...) }
  // ...
  GridItem() { Column().height(24) }  // 尝试作为底部留白
}

问题出在:在 Grid 的 ForEach 循环外,我加了一个 GridItem() 包含一个空 Column 作为最后一个元素来充当底部留白。但这个空 GridItem 占据了完整的一个网格列宽,导致底部有一个 1fr 宽度的空白块,非常突兀。

正确的做法是不在 Grid 内部做底部留白,而是调整 Grid 所在的父容器的 padding,或者在 Grid 之后的 Column 中追加一个空白 Column:

// 正确的写法
Grid() {
  ForEach(...) { ... }
}
.padding({ ..., bottom: 24 })  // 用 padding 做底部留白

// 或者
Grid() { ... }
Column().height(24);  // Grid 外部的留白

坑的本质是:GridItem 是 Grid 布局的最小单元,所有 GridItem 都参与网格流布局。不要把 “间距” 误当做 GridItem 来处理——间距应该用 padding 或 gap 属性表达。

总结

@Builder 是 ArkUI 中一个看似简单但设计精妙的机制。它的本质约束——无状态、无生命周期、无属性装饰器——不是为了限制开发者,而是为了在编译期提供更强的类型安全保障和性能优化空间。

在 HarmonyKit 项目中,@Builder 的核心价值体现在两点:

第一,消除重复代码。5 个 Tab 共享一个 ToolGrid Builder,6 个统计指标共享一个 StatCard Builder。改了 Builder,所有使用处同步更新。这不是代码重用,这是 UI 一致性的强制机制。

第二,明确设计意图。Builder 就是 Builder,Component 就是 Component。两者的选择标准清晰——需要状态和生命周期就用 Component,否则用 Builder。这种二分法让代码的意图一目了然,新人阅读代码时不需要猜测"这个组件是否有隐藏的状态逻辑"。

如果你正在编写 ArkUI 应用,一个实用的建议是:当你发现自己写了第二份几乎相同的 UI 布局代码时,立刻停下来,提取一个 Builder。 不要等到第三份、第四份出现。重复代码在它产生的第一刻就应该被消除,而不是"等重构时再说"。

项目仓库:https://atomgit.com/VON-/harmony-kit

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐