在这里插入图片描述

在 ArkUI 开发中,组件是界面的基本单元。然而,当页面规模逐渐扩大、业务逻辑日益复杂时,我们会不可避免地遇到一类共同问题:相同结构的 UI 代码在多个地方重复出现、样式定义散落在各处难以统一维护、父子组件之间的状态同步变得纠缠不清。这些问题并非某个特定场景的专属痛点,而是几乎每一个中大型 HarmonyOS 应用都会面临的结构性挑战。

HarmonyOS NEXT 提供的 @Builder@Extend@Styles@Link@ObjectLink 等机制,正是为解决这些问题而设计的。它们不是孤立的语法糖,而是形成了一套互补的组件复用与状态管理体系。掌握这些技法的底层原理和使用边界,能够让我们的代码从「能用」走向「优雅」。

本文将逐一拆解这五种核心能力,配合简洁的代码片段讲透原理,帮助你构建出结构清晰、易于维护的 ArkUI 应用。


一、@Builder:自定义构建器与链式调用

1.1 什么是 @Builder

@Builder 是 ArkUI 中用于封装 UI 构造逻辑的装饰器。它与普通自定义组件的核心区别在于:后者是一个完整的 UI 节点,具备独立的生命周期和渲染作用域;而 @Builder 本质上是一个受控的渲染方法——它允许我们将一段 UI 模板提取为可复用的构建函数,并在需要的地方反复调用。

举一个最常见的场景:列表中的每一个卡片都具有相同的结构,但数据内容各不相同。如果为每个卡片都写一遍布局代码,不仅冗余,后续修改也极其痛苦。@Builder 就是来解决这个问题的。

@Builder
function ArticleCard(title: string, summary: string, author: string) {
  Column() {
    Text(title).fontSize(20).fontWeight(FontWeight.Bold)
    Text(summary).fontColor('#666666').maxLines(2)
    Row() {
      Text(author).fontSize(12).fontColor('#999999')
      Blank()
      Text('阅读更多 >').fontSize(12).fontColor('#007AFF')
    }.width('100%').margin({ top: 8 })
  }
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
}

在页面的 build() 方法中,可以直接调用这个构建器函数:

build() {
  Column() {
    ArticleCard('HarmonyOS 分布式技术详解', '本文深入分析了...', '李明')
    ArticleCard('ArkUI 状态管理实战', '状态管理是...', '王芳')
  }
  .width('100%')
  .padding(16)
}

这样做的好处显而易见:UI 结构被提取为独立函数,维护成本大幅降低,任何对卡片布局的调整只需修改一处。

1.2 链式调用的实现方式

在实际的业务场景中,我们常常需要对一个基础 UI 结构进行渐进式的个性化配置。@Builder 支持通过返回 this 或构造配置对象的方式实现链式调用,从而让构建过程更具表达力。

@Builder
function TagBuilder() {
  this
}

TagBuilder.prototype.config = function(color: string, text: string) {
  Row() {
    Text(text).fontSize(12).fontColor(color)
  }
  .backgroundColor(color + '20')
  .borderRadius(4)
  .padding({ left: 8, right: 8, top: 4, bottom: 4 })
  return this
}

// 调用链
TagBuilder().config('#007AFF', '热门')
TagBuilder().config('#34C759', '推荐')

这种模式模拟了流式接口(Fluent API)的体验:调用方可以根据需要选择性地配置标签的颜色和文字,每次调用 config() 后返回自身,使得多条配置可以串联书写。需要注意的是,这种链式写法更多适用于动态配置场景,在静态页面中直接使用参数化 @Builder 仍然是更推荐的方式。

1.3 参数传递与局部状态

@Builder 支持两种参数传递模式:值传递和引用传递。通过 $ 前缀,可以将父组件的状态变量以引用方式传入构建器,使构建器内部能够响应式地读取父组件的变化。

@Entry
@Component
struct ParentPage {
  @State userName: string = '张三'
  @State isVip: boolean = true

  @Builder
  ProfileBadge($name: string, $isVip: boolean) {
    Row() {
      Text($name).fontSize(16)
      if ($isVip) {
        Text('VIP').fontSize(10).backgroundColor('#FFD700')
      }
    }
  }

  build() {
    Column() {
      this.ProfileBadge(this.userName, this.isVip)
      Button('修改昵称').onClick(() => {
        this.userName = '李四'
      })
    }
  }
}

在这里,$name$isVip 以引用方式接收父组件的状态。当点击按钮修改 userName 时,构建器内部的 Text 会自动响应这一变化,重新渲染显示新的昵称。这种机制既保留了构建器的轻量特性,又赋予了它响应式数据绑定的能力。


二、@Extend:扩展原生组件的样式修饰符

2.1 为什么需要 @Extend

ArkUI 原生组件库提供了丰富的基础组件,但它们的默认样式属性有时并不能满足业务需求。我们当然可以为每个组件重复设置相同的样式属性,但当同一种样式组合需要在数十个地方使用时,代码就会变得臃肿且难以维护。

@Extend 的出现解决了这一困境。它允许我们为特定组件类型扩展自定义的样式修饰符集合,定义一次,反复使用。结合 ArkUI 的链式调用语法,使用体验非常接近为原生组件添加了新的「成员方法」。

2.2 为 Text 扩展自定义样式

最常见的用法之一是为 Text 组件扩展标题样式、正文样式等变体。

@Extend(Text)
function TitleText() {
  .fontSize(24)
  .fontWeight(FontWeight.Bold)
  .fontColor('#1A1A1A')
  .lineHeight(32)
}

@Extend(Text)
function CaptionText() {
  .fontSize(12)
  .fontColor('#8E8E93')
  .fontWeight(FontWeight.Medium)
}

// 使用
Text('页面标题').TitleText()
Text('这是一段描述文字').CaptionText()

定义好扩展之后,所有 Text 组件都可以像调用原生方法一样调用 TitleText()CaptionText()。如果后续品牌色或字体规范发生变化,只需在一个地方修改扩展定义,整个应用的文本样式就会统一更新。

2.3 为 Button 添加业务专属样式

在企业级应用中,按钮往往需要根据业务含义使用不同的视觉风格——主按钮、次按钮、危险操作按钮等。

@Extend(Button)
function PrimaryButton() {
  .type(ButtonType.Normal)
  .borderRadius(8)
  .backgroundColor('#007AFF')
  .fontColor('#FFFFFF')
  .fontSize(16)
  .height(44)
  .width('100%')
}

@Extend(Button)
function DangerButton() {
  .type(ButtonType.Normal)
  .borderRadius(8)
  .backgroundColor('#FF3B30')
  .fontColor('#FFFFFF')
  .fontSize(16)
  .height(44)
}

@Extend(Button)
function GhostButton() {
  .type(ButtonType.Normal)
  .borderRadius(8)
  .backgroundColor('transparent')
  .fontColor('#007AFF')
  .border({ width: 1, color: '#007AFF' })
  .fontSize(16)
  .height(44)
}

// 使用
Button('提交').PrimaryButton()
Button('删除').DangerButton()
Button('取消').GhostButton()

通过这种方式,按钮的视觉规范与业务语义被显式地绑定在一起。开发者无需记忆每一套样式参数,只需要根据操作意图选择对应的样式方法,代码的可读性和一致性都得到了显著提升。

2.4 @Extend 的使用边界

理解 @Extend 的局限性同样重要。它只能为已有的组件类型添加样式,不支持跨类型复用——即不能定义一个同时适用于 TextImage 的通用样式。此外,@Extend 定义的是静态样式,不包含响应式逻辑。如果需要包含状态判断或条件渲染,应当使用 @Styles 结合状态变量,或者直接使用自定义组件。


三、@Styles:跨组件共享样式集

3.1 @Styles 与 @Extend 的区别

初学者容易将 @Styles@Extend 混淆。两者的核心区别在于作用范围:@Extend 针对特定组件类型,而 @Styles 则是类型无关的样式集合。

@Styles 接收一个通用的组件实例作为参数,可以在其中为任意支持的属性赋值。这意味着同一套样式逻辑可以同时应用于 TextButtonImage 等多种组件类型,灵活性更高。

3.2 通用阴影与圆角样式

在移动端应用中,卡片式布局是最常见的 UI 模式之一。卡片通常具有统一的圆角和阴影效果。

@Styles
function CardStyle() {
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .shadow({
    radius: 8,
    color: 'rgba(0, 0, 0, 0.08)',
    offsetX: 0,
    offsetY: 2
  })
}

@Styles
function PressedStyle() {
  .opacity(0.7)
  .scale({ x: 0.98, y: 0.98 })
}

// 应用到不同组件
Column() {
  Text('内容卡片').CardStyle()
  Image($r('app.media.pic')).CardStyle()
  Button('操作卡片').CardStyle()
}

当同一个视觉规范需要应用在多种不同类型的组件上时,@Styles 展现出比 @Extend 更大的灵活性。它让样式定义与具体组件类型解耦,样式复用粒度更粗犷,适合定义全局性的视觉规范。

3.3 全局样式与局部样式的组织策略

在大型项目中,样式定义的组织方式直接影响代码的可维护性。建议遵循以下分层策略:

全局样式层定义在整个应用的公共模块中,包含颜色变量、字体规范、间距系统等基础设计令牌级别的样式。这些样式在整个应用范围内生效,不需要也不应该被重复定义。

模块样式层定义在具体业务模块内部,只在该模块的页面中可见。例如用户中心模块可能定义 UserCardStyleAvatarStyle 等专用于该模块的样式集合。

页面样式层则在单个 .ets 文件的顶部定义,只在该页面的组件间共享。

这种分层策略让样式规则各得其所,既避免了全局样式的过度膨胀,又防止了样式定义散落在业务代码的每个角落。


四、@Link 与 @ObjectLink:深层传参与状态同步原理

4.1 状态同步的基本矛盾

在 ArkUI 的组件树中,数据流向遵循严格的双向绑定规则:父组件向子组件传递数据时,使用 @State 配合普通属性传值;子组件修改数据需要通知父组件时,需要通过 @Link@ObjectLink 建立反向同步通道。

这个机制背后有一个关键的设计哲学:谁拥有状态,谁负责管理。子组件可以「借用」父组件的状态进行渲染,也可以「代理」父组件管理状态,但最终的状态所有权始终归属于父组件。这种设计保证了应用状态的可预测性,避免了多个组件同时修改同一份状态导致的冲突。

4.2 @Link:基础类型与对象类型的值引用

@Link 是最常用的父子状态同步方式。当父组件将自身的 @State 变量通过 @Link 传递给子组件时,子组件持有的是该变量的引用,而非副本。这意味着子组件对变量的修改会直接影响父组件的状态,触发两者同步重新渲染。

@Component
struct Counter {
  @Link count: number  // 引用传递,非副本

  build() {
    Row() {
      Text(`计数: ${this.count}`)
      Button('+1').onClick(() => {
        this.count++  // 直接修改父组件的状态
      })
    }
  }
}

@Entry
@Component
struct ParentPage {
  @State totalCount: number = 0

  build() {
    Column() {
      Counter({ count: $totalCount })  // 使用 $ 传递引用
      Text(`父组件显示: ${this.totalCount}`)
    }
  }
}

在这个示例中,$totalCount 语法创建了一个双向绑定通道。当 Counter 组件内部点击按钮使 count++ 时,父组件的 totalCount 会同步增加,两个组件的渲染结果保持一致。

4.3 @ObjectLink 与 @Observed:嵌套对象的深度响应

@Link 对于简单类型(number、string、boolean)工作得很好,但面对嵌套对象时就会遇到瓶颈。ArkUI 的响应式系统默认只监听对象引用的变化,不会自动追踪对象内部属性的变更。

要实现嵌套对象的深度响应,需要借助 @Observed@ObjectLink 的组合。

@Observed
class UserProfile {
  name: string = ''
  age: number = 0
  address: Address = new Address()
}

class Address {
  city: string = ''
  district: string = ''
}

@Component
struct ProfileEditor {
  @ObjectLink user: UserProfile

  build() {
    Column() {
      TextInput({ text: this.user.name })
        .onChange((val) => { this.user.name = val })

      TextInput({ text: this.user.address.city })
        .onChange((val) => { this.user.address.city = val })
    }
  }
}

@Observed 装饰器标记了类 UserProfile,告知 ArkUI 的响应式系统需要深度监听该类实例的属性变化。当 address.city 这样的嵌套属性被修改时,系统能够精确地追踪到变更路径,只触发必要的最小化重渲染。

这里有一个容易出错的地方需要特别说明:@Observed 必须精确地装饰数据变更路径上涉及的所有类。如果 Address 类没有被 @Observed 装饰,那么修改 this.user.address.city 将不会触发任何 UI 更新。这是一个常见的陷阱,理解其原理对于正确使用深度响应至关重要。

4.4 @Link 与 @ObjectLink 的选择决策树

在实际开发中,如何选择 @Link@ObjectLink?可以参考以下决策逻辑:

如果传递的是基础类型(number、string、boolean),使用 @Link

如果传递的是单层对象,且子组件会整体替换这个对象,使用 @Link

如果传递的是嵌套对象,且子组件需要修改对象内部的深层属性,使用 @ObjectLink 并确保链路上的所有类都使用 @Observed 装饰。

这个决策树并不复杂,关键在于对数据模型的预先规划。在设计组件接口时,明确数据的所有权边界和可能的修改路径,能够帮助我们更准确地选择合适的状态同步机制。


五、自定义组件库结构与 HSP 导出思路

5.1 组件库的层次化架构

当项目规模达到一定程度,组件库的建设就会从「顺手抽取」演进为「刻意设计」。一个结构良好的组件库不仅服务于当前项目,还为后续迭代和新项目复用奠定基础。

从组织结构上,推荐将组件库分为三个层次:

原子组件层包含最基础的视觉元素,如自定义按钮、图标容器、文本标签等。这些组件通常只有纯粹的展示逻辑,不包含业务数据。

分子组件层由原子组件组合而成,具备一定的业务语义。例如 ArticleCard 由图片、标题、摘要、作者信息组合而成,封装了一个「文章卡片」的业务概念。

模板组件层则是针对特定页面类型的整体框架,例如「列表-详情」模板、「表单提交」模板等。这一层的组件通常包含完整的页面布局和交互逻辑,可直接用于快速开发。

src/
  components/
    atoms/           # 原子组件
      Gap.ets
      Divider.ets
      Badge.ets
    molecules/       # 分子组件
      ArticleCard.ets
      UserAvatar.ets
      TagGroup.ets
    templates/       # 页面模板
      ListDetailTemplate.ets
      FormTemplate.ets

这种分层与 Atomic Design 思想一脉相承,但在 ArkUI 的语境中进行了适配。关键是让每个组件的复杂度与其所在层级相匹配:原子组件保持简单,模板组件负责组装,业务页面负责填充数据。

5.2 导出与可见性控制

HarmonyOS 的模块系统支持通过 export 关键字控制组件和函数的对外可见性。合理使用导出控制,可以让组件库既对外提供必要的接口,又隐藏内部实现细节。

// components/molecules/ArticleCard.ets

// 对外暴露:允许外部使用的组件
@Component
export struct ArticleCard {
  @Prop title: string
  @Prop summary: string
  @Link isBookmarked: boolean

  // 对内使用:不对外暴露的内部构建器
  @Builder
  internalBookmarkIcon() {
    Image(this.isBookmarked ? 'bookmark_filled' : 'bookmark_outline')
      .width(20)
      .onClick(() => { this.isBookmarked = !this.isBookmarked })
  }

  build() {
    Column() {
      this.internalBookmarkIcon()
      Text(this.title).fontSize(18)
      Text(this.summary).maxLines(2)
    }
  }
}

在这里插入图片描述

通过显式的 export 声明,开发者可以清楚地知道哪些组件是可以被外部模块直接使用的公共 API,哪些是内部实现细节。长期维护一个规模较大的项目,这种显式性带来的可预期性非常重要。

5.3 HSP 共享包:跨模块复用

HarmonyOS 提供了 HSP(HarmonyOS Shared Package)作为模块间代码共享的标准方案。与 HAR(Harmony Archive)不同,HSP 在运行时与应用主包共享进程,这意味着它适合承载需要在多个模块间共享 UI 组件的场景——因为 UI 组件通常需要访问相同的主题资源和应用上下文。

module.json5 中声明 HSP 依赖:

{
  "dependencies": {
    "@shared/commponents": "^1.0.0"
  }
}

然后在代码中按路径导入:

import { ArticleCard, UserAvatar } from '@shared/components/molecules'

需要特别注意的是,HSP 中的 @State 变量与宿主应用之间不存在直接的跨包双向绑定。如果 HSP 组件需要在宿主应用中使用 @Link 同步状态,需要确保状态变量的类型在共享包和宿主应用之间保持一致,并且共享包使用 @Prop@Link 的组合而非直接的 @State 管理。

对于追求代码复用的团队来说,HSP 是目前 HarmonyOS 平台上最具工程价值的分发形式。建议在组件库相对稳定之后,再将其迁移至 HSP 中管理,避免过早地引入跨包依赖带来的版本维护复杂度。


六、综合实践:各机制协同使用

在实际项目中,上述五种机制很少孤立使用,更多时候是协同工作、各司其职。来看一个综合性的示例,展示如何在一个「商品列表卡片」组件中组合运用这些技法。

// 商品卡片组件
@Component
export struct ProductCard {
  @ObjectLink product: ProductModel
  @Link selectedItems: Set<string>

  @Builder
  internalPriceTag(price: number, discount: number) {
    if (discount > 0) {
      Row() {
        Text(`¥${price}`).fontColor('#FF3B30').fontWeight(FontWeight.Bold)
        Text(`¥${(price * discount).toFixed(0)}`)
          .fontColor('#999999')
          .decoration({ type: TextDecorationType.LineThrough })
      }
    } else {
      Text(`¥${price}`).fontColor('#1A1A1A').fontWeight(FontWeight.Bold)
    }
  }

  build() {
    Row() {
      Image(this.product.imageUrl).width(80).height(80).borderRadius(8)

      Column() {
        Text(this.product.name).fontSize(16).maxLines(1)
        this.internalPriceTag(this.product.price, this.product.discount)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Checkbox()
        .checked(this.selectedItems.has(this.product.id))
        .onChange((val) => {
          if (val) {
            this.selectedItems.add(this.product.id)
          } else {
            this.selectedItems.delete(this.product.id)
          }
        })
    }
    .ProductCardStyle()
  }
}

@Extend(Row)
function ProductCardStyle() {
  .padding(12)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
}

在这个示例中,@Builder 用于封装卡片内部的可复用 UI 结构(价格标签),@Extend 用于定义卡片的统一视觉风格(背景、圆角、内边距),@ObjectLink 用于实现嵌套商品数据的响应式绑定,@Link 用于同步多选状态。各种机制在这个小小的组件中各司其职:样式归 @Extend 管,局部 UI 构造归 @Builder 管,状态同步归 @Link@ObjectLink 管。


结语

@Builder@Extend@Styles@Link@ObjectLink 这五种机制,共同构成了 ArkUI 组件复用与状态管理的核心能力矩阵。它们各自有明确的作用边界和使用场景:轻量 UI 片段选 @Builder,统一样式选 @Extend@Styles,状态同步根据数据类型选 @Link@ObjectLink

理解这些机制的关键不在于记住它们的语法,而在于理解背后的设计意图:ArkUI 希望你用最少的代码表达最清晰的意图。组件是 UI 的基本单元,而好的复用结构是 UI 的骨架。骨架搭得好,后续的业务迭代就会流畅得多。

在实践中,建议从小处开始——先在一个页面内部抽取重复的 UI 结构,待模式成熟后再将其迁移到公共模块乃至 HSP 共享包中。这种渐进式的架构演进方式,既能避免过度设计,又能确保复用收益的真实落地。


基于 HarmonyOS NEXT(API 12+)

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐