基于真实项目踩坑经验,覆盖 Controller 模式、Emitter 事件总线、@BuilderParam 插槽、@Provide/@Consume 跨层级通信,附完整 Demo 代码走读
适用版本:HarmonyOS NEXT API 12+ / SDK 5.0+
关键词:Controller、emitter、@BuilderParam、@Provide、@Consume、组件通信、插槽、事件总线


写在前面

做 HarmonyOS NEXT 项目做到第三个月,我越来越觉得"组件化"三个字说起来简单,做起来全是细节。拆组件谁不会?把一段 UI 抽出来包个 @Component 就完事了。但真正让人头疼的是——拆完之后,组件之间怎么通信?

父组件要控制子组件的行为,子组件要通知父组件发生了什么事,两个压根不在同一个组件树上的模块要互传消息,还有那种"我就是个壳子,内容让调用方来填"的组件组合需求……每一种场景都有对应的解法,但每种解法又有各自的坑。

这篇文章把我在项目中用到的四种通信模式——Controller 模式、Emitter 事件总线、@BuilderParam 插槽、@Provide/@Consume 跨层级通信——全部捋一遍。不是 API 文档搬运,是踩坑实录。每个模式我都会讲清楚什么时候用、怎么用、以及哪里容易翻车。

请添加图片描述


一、先搞清楚问题:组件通信的四种典型场景

在讲具体方案之前,先对齐一下我们到底在解决什么问题。我遇到过的组件通信需求,基本都能归到下面四类:

场景一:父组件主动调用子组件的方法

比如你有个计数器组件 CounterBox,父组件想在按钮点击时让计数器增加、减少、重置。这不是数据驱动的场景——你不想传一个 count 进去让子组件渲染,而是想让子组件执行一个动作。这就需要命令式 API。

场景二:跨组件树、跨页面的消息通知

比如用户在设置页修改了语言偏好,首页、详情页、搜索页都要刷新界面。这些页面可能压根不在同一个组件树里,@Provide/@Consume 这种基于组件树层级的方案根本够不着。你需要一个脱离组件树的事件总线。

场景三:组件组合——我出壳子你出内容

比如你做了个卡片容器组件 CardContainer,有自己的圆角、阴影、边距样式,但卡片里面放什么内容由使用方决定。这就是插槽模式,Web 开发里叫 slot,ArkUI 里叫 @BuilderParam

场景四:深层嵌套组件共享状态

这个在之前第7篇《跨层级通信方案对比》里详细讲过了。根组件有个主题色,三四层以下的按钮组件需要读取。一层层传 props 太蠢,需要一个跨层级的管道。

这四种场景对应四种解法,下面一个一个讲。


二、Controller 模式:命令式 API 的正确打开方式

2.1 什么是 Controller 模式

先看一段最常见的反面教材:

// 反面教材:通过 @Link 双向绑定来"控制"子组件
@Component
struct CounterBox {
  @Link count: number  // 父子双向绑定,谁都能改

  build() {
    Column() {
      Text('计数: ' + String(this.count))
      Button('+').onClick(() => { this.count += 1 })
    }
  }
}

这种写法的问题在于——数据流向是模糊的。父组件改 count 能影响子组件,子组件自己改 count 也能影响父组件。一旦组件树复杂起来,你根本不知道 count 是被谁改的,调试的时候追都追不到。

Controller 模式的思路完全不同:父组件持有控制器对象,通过调用控制器的方法来驱动子组件行为。数据流是单向的——父→子,子组件只是执行命令和展示结果。

2.2 Demo 中的实现

看看我们的 Demo 代码:

class CounterController {
  count: number = 0

  increment(): void {
    this.count += 1
  }

  decrement(): void {
    this.count -= 1
  }

  reset(): void {
    this.count = 0
  }

  getCount(): number {
    return this.count
  }
}

CounterController 就是一个纯逻辑类,不依赖任何 ArkUI 装饰器,不涉及状态管理。它只做一件事——维护 count 并提供修改 count 的方法。

然后看子组件怎么用:

@Component
struct CounterBox {
  controller: CounterController = new CounterController()
  onCountChange: (count: number) => void = (_count: number) => {}

  build() {
    Column() {
      Text('计数: ' + String(this.controller.getCount()))
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 12 })
      Row() {
        Button('-')
          .onClick(() => {
            this.controller.decrement()
            this.onCountChange(this.controller.getCount())
          })
        Button('重置')
          .onClick(() => {
            this.controller.reset()
            this.onCountChange(this.controller.getCount())
          })
        Button('+')
          .onClick(() => {
            this.controller.increment()
            this.onCountChange(this.controller.getCount())
          })
      }
    }
    .padding(16)
    .borderRadius(12)
    .backgroundColor(Color.White)
  }
}

关键点:

  1. controller 是一个普通 prop,不是 @Prop 也不是 @Link,就是从父组件传进来的对象引用。
  2. 子组件通过 this.controller.increment() 调用控制器方法,然后通过 onCountChange 回调通知父组件数据变了。
  3. 数据流清晰:父组件调用 Controller 方法 → Controller 修改内部状态 → 子组件展示 → 通过回调通知父组件。

再看父组件怎么用:

@Entry
@Component
struct ComponentArchDemo {
  @State controllerCount: number = 0
  private controller: CounterController = new CounterController()

  // 在 ControllerSection 中:
  CounterBox({
    controller: this.controller,
    onCountChange: (count: number) => {
      this.controllerCount = count
      this.addLog('Controller: count变为 ' + String(count))
    }
  })

  // 父组件也能直接调用 Controller
  Button('父组件 +')
    .onClick(() => {
      this.controller.increment()
      this.controllerCount = this.controller.getCount()
    })
}

父组件创建了 CounterController 实例,把它传给 CounterBox。同时父组件自己也持有同一个引用,所以父组件也能直接调用 this.controller.increment() 来控制子组件的行为。

这就是 Controller 模式的精髓——同一个控制器实例,父子组件都能调用,但命令是从父组件发出的,子组件是执行者和展示者

2.3 Controller 模式的适用场景

Controller 模式最适合以下场景:

  • 需要暴露命令式 API 的组件:比如播放器组件(play、pause、seek)、表单组件(validate、reset、submit)、弹窗组件(show、hide)。
  • 父组件需要主动触发子组件行为:不是"子组件根据数据变化自己响应",而是"父组件告诉子组件去做某件事"。
  • 需要精细控制组件行为:比单纯的数据驱动更灵活,能表达"动作"语义。

2.4 踩坑:Controller 引用在组件销毁后无效

这是 Controller 模式最大的坑。我在项目里翻过一次车,花了一下午才查出来。

场景是这样的:父组件通过条件渲染控制子组件的显示隐藏,同时持有子组件的 Controller 引用。子组件被销毁后,父组件仍然调用了 Controller 的方法。因为 Controller 本身是个普通对象,它的方法不会因为组件销毁而失效——increment() 照样能执行,count 照样能改——但组件已经不在了,UI 不会刷新

更糟糕的情况是,如果 Controller 的方法里依赖了组件的上下文(比如访问了 this.xxx),组件销毁后这些引用可能已经失效,直接抛运行时异常。

我的解决方式是在 Controller 里加一个 isValid 标记,组件销毁时置为 false

class CounterController {
  count: number = 0
  isValid: boolean = true

  increment(): void {
    if (!this.isValid) return
    this.count += 1
  }

  // 组件销毁时调用
  destroy(): void {
    this.isValid = false
  }
}

// 子组件
aboutToDisappear(): void {
  this.controller.destroy()
}

丑吗?丑。但安全。ArkUI 没有提供原生的 Controller 生命周期管理机制,这个得自己兜底。

2.5 Controller 不驱动 UI 刷新——一个容易被忽略的事实

这里有一个设计选择值得一提:Demo 里的 onCountChange 回调。为什么不用 @Watch 监听 Controller 的 count 变化?

因为 Controller 是普通对象,它的属性变化不会触发 ArkUI 的状态观测机制count 从 0 变成 1,ArkUI 不知道,UI 不会刷新。

所以子组件在每次调用 Controller 方法后,手动通过 onCountChange 回调通知父组件"count 变了"。父组件收到回调后修改自己的 @State 变量,从而触发 UI 刷新。

这是一个关键认知:Controller 模式本身不驱动 UI 刷新,你需要配合回调或状态管理来让 UI 响应 Controller 的状态变化。

这也是为什么 Controller 模式不等于"把 @Link 换个写法"——@Link 是声明式的、自动刷新的;Controller 是命令式的、手动刷新的。两者解决的不是同一个问题。


三、Emitter 事件总线:脱离组件树的广播机制

3.1 为什么需要 Emitter

Controller 模式解决了父子组件的命令式通信,@Provide/@Consume 解决了同组件树内的跨层级通信。但有一种场景它们都搞不定——跨组件树、跨页面的通信

举个实际例子:用户在"个人中心"页面退出登录,"首页"和"消息"页面需要立即清除用户数据并跳转到登录页。这几个页面可能通过 Navigation 管理,各自有独立的组件树,@Provide/@Consume 的组件树传播根本到不了。

Emitter 就是干这个的。它是 @kit.BasicServicesKit 提供的全局事件总线,完全脱离组件树,任何地方都能 emit,任何地方都能 on。

3.2 Demo 中的实现

先看事件定义:

import { emitter } from '@kit.BasicServicesKit'

const EVENT_INCREMENT: number = 10001
const EVENT_DECREMENT: number = 10002
const EVENT_DATA_UPDATE: number = 10003

interface EventData {
  eventId: number
  data: string
  timestamp: string
}

事件 ID 是一个数字常量。我这里从 10001 开始编号,是因为 Emitter 的事件 ID 是全局共享的,选一个合理的起始值能降低冲突概率(后面讲坑的时候细说)。

发送事件:

// 无数据的简单事件
emitter.emit({ eventId: EVENT_INCREMENT })

// 携带数据的事件
let innerEvent: emitter.InnerEvent = { eventId: EVENT_DATA_UPDATE }
let emitData: emitter.EventData = { data: { message: 'Hello Emitter' } }
emitter.emit(innerEvent, emitData)

两种用法:一种只发 eventId,一种带上 payload。EventData.data 是一个 Record<string, Object> 类型,可以塞任意键值对。

接收事件:

aboutToAppear(): void {
  emitter.on({ eventId: EVENT_INCREMENT }, () => {
    this.lastEventId = EVENT_INCREMENT
    this.lastEventData = '增加事件'
    this.addLog('收到增加事件 (ID: ' + String(EVENT_INCREMENT) + ')')
  })

  emitter.on({ eventId: EVENT_DECREMENT }, () => {
    this.lastEventId = EVENT_DECREMENT
    this.lastEventData = '减少事件'
    this.addLog('收到减少事件 (ID: ' + String(EVENT_DECREMENT) + ')')
  })

  emitter.on({ eventId: EVENT_DATA_UPDATE }, (eventData: emitter.EventData) => {
    this.lastEventId = EVENT_DATA_UPDATE
    let payload: string = ''
    if (eventData.data && eventData.data.message) {
      payload = String(eventData.data.message)
    }
    this.lastEventData = payload
    this.addLog('收到数据更新 (ID: ' + String(EVENT_DATA_UPDATE) + ', data: ' + payload + ')')
  })
}

注意第三个 emitter.on 的回调函数带了参数 eventData。前两个回调是无参的,只关心"事件发生了";第三个需要读取 payload 里的 message 字段。

注销事件:

aboutToDisappear(): void {
  emitter.off(EVENT_INCREMENT)
  emitter.off(EVENT_DECREMENT)
  emitter.off(EVENT_DATA_UPDATE)
}

这是 必须 做的。组件销毁时如果不 emitter.off,回调函数仍然挂载在 Emitter 上。下次 emit 同 eventId 的时候,已经销毁的组件的回调照样执行——而此时组件的上下文已经不存在了,轻则 UI 不更新,重则访问到无效引用直接崩溃。

3.3 Emitter 的适用场景

  • 跨页面通知:登录/登出、权限变更、配置更新等需要全局广播的事件。
  • 跨模块通信:两个没有组件层级关系的模块需要互传消息。
  • 解耦发布/订阅:事件发送方不需要知道谁在监听,监听方也不需要知道谁在发送。

3.4 踩坑一:事件 ID 冲突导致误触发

这个坑我踩得最惨。项目里有三个同学各自写了 Emitter 通信,事件 ID 都从 1 开始编号。结果用户在 A 页面点了个按钮,B 页面和 C 页面同时响应了——因为三个页面都监听了 eventId = 1

Emitter 的事件 ID 是进程级全局的,不像 @Provide/@Consume 有组件树隔离。两个毫不相干的模块只要 eventId 一样,就会互相触发。

我的解决方案是按模块分段分配 ID:

// 模块A: 10001-10100
const MODULE_A_BASE: number = 10001
const EVENT_A_LOGIN: number = MODULE_A_BASE + 0
const EVENT_A_LOGOUT: number = MODULE_A_BASE + 1

// 模块B: 10101-10200
const MODULE_B_BASE: number = 10101
const EVENT_B_DATA_REFRESH: number = MODULE_B_BASE + 0

// 模块C: 10201-10300
const MODULE_C_BASE: number = 10201

更好的做法是把这些常量集中到一个文件里统一管理,别让每个模块各写各的:

// emitter_events.ets
export const EVENT_USER_LOGIN: number = 10001
export const EVENT_USER_LOGOUT: number = 10002
export const EVENT_CART_UPDATE: number = 10003
export const EVENT_THEME_CHANGE: number = 10004
// ...

Demo 里我从 10001 开始编号,就是这个原因。你要是直接写 123,迟早有一天会跟别人的事件撞上。

3.5 踩坑二:Emitter 回调 this 绑定丢失

这个问题在 Demo 代码里其实已经规避了——因为用了箭头函数。但如果你写普通函数,就一定会踩坑:

// 错误写法:普通函数,this 指向丢失
emitter.on({ eventId: EVENT_INCREMENT }, function() {
  // 这里的 this 不是组件实例!
  this.lastEventId = EVENT_INCREMENT  // 运行时报错:Cannot read property
})

// 正确写法:箭头函数,this 绑定外层作用域
emitter.on({ eventId: EVENT_INCREMENT }, () => {
  this.lastEventId = EVENT_INCREMENT  // OK,this 指向组件实例
})

这不是 ArkUI 的问题,是 JavaScript 的 this 绑定机制。Emitter 内部调用回调时,this 不会指向你的组件实例。箭头函数通过词法作用域绑定了外层的 this,所以能正确访问组件属性。

这一点对于从 Java/Kotlin 转过来的同学尤其容易忽略——在那些语言里,lambda 的 this 指向是外部类的实例,而 JS 的普通 function 有自己的 this。ArkTS 继承了 JS 的行为,普通 function 里的 this 在 Emitter 回调中指向的是 undefined(严格模式)或全局对象(非严格模式),反正不是你的组件。

3.6 踩坑三:忘记 emitter.off 导致内存泄漏和僵尸回调

这个在 3.2 节已经提了,但值得再强调一遍。我见过最离谱的 case 是一个组件注册了 emitter.on 但从没 off 过,每次进入该页面就多注册一次回调。进10次页面,emit 一次事件触发10次回调,UI 刷新10次,性能直接拉胯。

铁律:在 aboutToDisappear 中注销所有 emitter.on 注册的回调。

如果你担心漏掉,可以在组件里维护一个注册列表:

private registeredEvents: number[] = []

aboutToAppear(): void {
  [EVENT_INCREMENT, EVENT_DECREMENT, EVENT_DATA_UPDATE].forEach((eventId: number) => {
    emitter.on({ eventId: eventId }, () => { /* ... */ })
    this.registeredEvents.push(eventId)
  })
}

aboutToDisappear(): void {
  this.registeredEvents.forEach((eventId: number) => {
    emitter.off(eventId)
  })
  this.registeredEvents = []
}

这样至少不会遗漏。不过要注意,emitter.off(eventId) 会注销该 eventId 上的所有回调,不只是当前组件注册的那个。如果你的项目里多个组件同时监听同一个 eventId,某个组件 off 掉会把别人的回调也干掉。这种情况需要用 emitter.off(eventId, callback) 精确注销,保存回调引用:

private onIncrement: () => void = () => {
  this.lastEventId = EVENT_INCREMENT
  this.lastEventData = '增加事件'
}

aboutToAppear(): void {
  emitter.on({ eventId: EVENT_INCREMENT }, this.onIncrement)
}

aboutToDisappear(): void {
  emitter.off(EVENT_INCREMENT, this.onIncrement)  // 只注销自己的回调
}

但注意——这里又有个坑!如果 onIncrement 是箭头函数属性,每次组件重建都会创建新的函数引用,off 的时候引用对不上,注销不了。所以必须把回调定义为组件的方法或属性,确保引用稳定。

Emitter 的坑真是一环套一环,用之前把这些全想清楚。


请添加图片描述

四、@BuilderParam 插槽:组件组合的正确姿势

4.1 从 Web 的 slot 说起

写前端的应该都熟悉 Vue 的 slot 和 React 的 children 概念。本质就是——父组件决定子组件内部的一部分内容

在 ArkUI 里,这个能力通过 @BuilderParam 实现。子组件声明一个插槽参数,父组件在调用子组件时传入 @Builder 函数来填充插槽内容。

4.2 Demo 中的实现

先看容器组件:

@Component
struct CardContainer {
  @BuilderParam content: () => void

  build() {
    Column() {
      this.content()
    }
    .padding(16)
    .borderRadius(12)
    .border({ width: 1, color: '#e0e0e0' })
    .backgroundColor(Color.White)
    .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
    .width('100%')
  }
}

CardContainer 声明了一个 @BuilderParam content 插槽,在 build 里通过 this.content() 渲染插槽内容。容器本身只负责外壳样式——padding、border、shadow——内容完全由外部决定。

然后定义两个不同的插槽内容:

@Builder
function textSlotBuilder() {
  Column() {
    Text('@BuilderParam 插槽内容')
      .fontSize(16)
      .fontWeight(FontWeight.Medium)
      .margin({ bottom: 8 })
    Text('这段文字通过 @BuilderParam 传入 CardContainer,实现组件组合。')
      .fontSize(14)
      .fontColor('#666666')
  }
  .alignItems(HorizontalAlign.Start)
}

@Builder
function buttonSlotBuilder() {
  Column() {
    Text('操作按钮插槽')
      .fontSize(16)
      .fontWeight(FontWeight.Medium)
      .margin({ bottom: 12 })
    Row() {
      Button('确认')
        .backgroundColor('#4CAF50')
        .fontColor(Color.White)
        .height(36)
      Button('取消')
        .backgroundColor('#f44336')
        .fontColor(Color.White)
        .height(36)
        .margin({ left: 12 })
    }
  }
  .alignItems(HorizontalAlign.Start)
}

同一个 CardContainer,传入不同的 Builder 函数就渲染出不同的内容:

CardContainer({ content: textSlotBuilder })
CardContainer({ content: buttonSlotBuilder })

这就是组件组合的魅力——容器组件专注壳子和样式,业务组件专注内容和交互,各司其职

4.3 @BuilderParam 的适用场景

  • 通用容器组件:卡片、面板、对话框等有固定外壳但内容可变的组件。
  • 布局模板:比如"左侧固定 + 右侧灵活"的分栏布局,左侧是导航,右侧内容由调用方填充。
  • 列表项模板:List 组件的每个 item 外壳一样,但内容不同,可以用 @BuilderParam 定义模板。

4.4 踩坑:V1 只支持单个 @BuilderParam

这是 @BuilderParam 最大的限制。V1 的 @Component 中,一个组件只能声明一个 @BuilderParam

如果你需要多个插槽(比如一个卡片需要 header、body、footer 三个插槽),V1 里做不到。你只能把多个区域的内容塞到一个 Builder 里,用条件判断来区分——丑且不灵活。

Demo 中的 CardContainer 只有一个 content 插槽,所以没问题。但如果我想要这样:

// 想要的效果:多个插槽(V1做不到)
@Component
struct CardContainer {
  @BuilderParam header: () => void   // 编译报错!V1不支持多个@BuilderParam
  @BuilderParam body: () => void
  @BuilderParam footer: () => void
}

V1 里只能回退到用 props 传递多个 Builder 函数:

// V1的workaround:用普通props传多个Builder
@Component
struct CardContainer {
  header: () => void = () => {}
  body: () => void = () => {}
  footer: () => void = () => {}

  build() {
    Column() {
      this.header()
      this.body()
      this.footer()
    }
  }
}

这样写虽然能跑,但 headerbodyfooter 不再享有 @BuilderParam 的 UI 刷新能力——它们只是普通函数,不会随状态变化自动更新。

V2 的解法:@WrapperParam

从 API 12 开始,V2 的 @ComponentV2 引入了 @WrapperParam,支持多插槽:

@ComponentV2
struct CardContainer {
  @WrapperParam header: () => void
  @WrapperParam body: () => void
  @WrapperParam footer: () => void

  build() {
    Column() {
      this.header()
      this.body()
      this.footer()
    }
  }
}

@WrapperParam 的行为类似 @BuilderParam,但不受"单插槽"限制。如果你需要多插槽,迁移到 V2 是唯一正解。

所以这里的选择很明确:单插槽用 @BuilderParam(V1/V2都行),多插槽只能 @WrapperParam(必须V2)。别在 V1 里搞什么奇技淫巧模拟多插槽,最后只会把自己绕进去。

4.5 @BuilderParam 按引用传递 vs 按值传递

@BuilderParam 传递的 Builder 函数有两种调用方式,这影响了数据刷新行为:

按引用传递@BuilderParam content: () => void):Builder 函数内部引用的 @State 等状态变量变化时,UI 会自动刷新。这是默认行为,Demo 里用的就是这种。

按值传递(通过闭包捕获值):如果 Builder 函数通过闭包捕获了外部变量,捕获的是值而不是引用,变量变化时不会触发刷新。

在实际开发中,90% 的场景用按引用传递就够了。只有当你需要向 Builder 传参时才需要注意这个区别——传参场景下,参数值变化时 UI 不会自动刷新,需要手动触发。


五、@Provide/@Consume:跨层级双向同步

5.1 为什么还需要它

前面讲了 Controller 和 Emitter,一个管父子命令式通信,一个管跨组件树广播。但还有一种中间地带——同组件树内,跨层级(但不跨页面)的双向状态共享

@Provide/@Consume 是 V1 的方案,V2 对应的是 @Provider/@Consumer。这一节重点讲 V1 的 @Provide/@Consume,因为 Demo 代码用的是 V1 组件。V2 的差异在第7篇已经详细对比过了,这里不赘述。

5.2 基本用法

@Component
struct GrandParent {
  @Provide('counter') counter: number = 0

  build() {
    Column() {
      Text('根组件: ' + String(this.counter))
      MiddleLayer()
    }
  }
}

@Component
struct MiddleLayer {
  // 中间层不需要做任何转发
  build() {
    Column() {
      DeepChild()
    }
  }
}

@Component
struct DeepChild {
  @Consume('counter') counter: number

  build() {
    Button('深层组件: ' + String(this.counter))
      .onClick(() => { this.counter += 1 })  // 修改会同步回根组件
  }
}

DeepChild 跨了三层直接拿到了 GrandParentcounter,而且修改会双向同步回根组件。中间层 MiddleLayer 完全透明,不需要声明任何参数来转发。

5.3 @Provide/@Consume 的适用场景

  • 深层嵌套共享状态:超过3层的嵌套,逐层传 props 成本太高。
  • 多分支共享:同一个状态被多个子树消费,一处声明多处使用。
  • 主题/配置类数据:全局主题色、字体大小、语言设置等"无处不在"的数据。

5.4 踩坑提醒

关于 @Provide/@Consume 的坑,第7篇已经讲得非常详细了。这里只列要点:

  1. 双 key 匹配的隐式绑定:alias 和属性名都能匹配,两个不相干的组件恰好属性名一样就被隐式绑在一起了,排查 bug 时怀疑人生。
  2. @Consume 不允许本地初始化:找不到 @Provider 直接崩溃,组件无法独立运行,复用性和可测试性为零。
  3. 不支持函数类型:子→父事件不能通过 @Provide/@Consume 传,V2 的 @Provider/@Consumer 才支持,V1 里只能老老实实一层层传回调。
  4. 同名 @Provide 默认冲突:需要显式 allowOverride 才能覆盖,封装组件库时经常踩雷。

六、四种通信方案对比

讲了这么多,来一张对比表把四种方案放在一起看:

维度 Controller Emitter @BuilderParam @Provide/@Consume
通信方向 父→子(命令式) 跨组件/跨页面(广播) 父→子(插槽内容) 跨层级双向同步
耦合度 低(接口明确) 极低(完全解耦) 低(接口明确) 中(隐式绑定)
适用场景 暴露命令式API 跨模块通知 布局组合复用 深层嵌套共享状态
数据流 单向(父调子方法) 单向(emit→on) 单向(父传入内容) 双向同步
跨页面 否(同一组件树内)
生命周期管理 需手动判空 必须 emitter.off 无特殊要求 跟随组件树
V2升级 无变化 无变化 → @WrapperParam(多插槽) → @Provider/@Consumer
典型坑 组件销毁后引用无效 ID冲突误触发、this丢失 V1只支持单插槽 双key隐式绑定、无默认值崩溃

选型决策树

简单画个决策流程:

需要通信的两个组件是什么关系?

├── 父子关系
│   ├── 父要主动调子方法 → Controller
│   ├── 父要传"内容"给子 → @BuilderParam
│   └── 父要传"数据"给子 → @Prop / @Param(常规方式)
│
├── 跨层级但同组件树
│   └── → @Provide/@Consume(V2用@Provider/@Consumer)
│
└── 跨组件树或跨页面
    └── → Emitter

一句话总结:Controller 管命令,Emitter 管广播,@BuilderParam 管组合,@Provide/@Consume 管共享。别拿 Emitter 当万能胶水到处用,也别在跨页面的场景硬塞 @Provide。


七、完整代码走读

接下来逐段走读 ComponentArchDemo.ets 的完整实现。

7.1 文件头部:事件常量与数据模型

import { emitter } from '@kit.BasicServicesKit'

const EVENT_INCREMENT: number = 10001
const EVENT_DECREMENT: number = 10002
const EVENT_DATA_UPDATE: number = 10003

interface EventData {
  eventId: number
  data: string
  timestamp: string
}

EventData 接口定义了事件数据的结构。这里有个小细节——虽然 emitter.EventData 自带 data 字段(类型是 Record<string, Object>),但我们额外定义了自己的 EventData 接口。这是因为 ArkTS 的类型系统比较严格,自定义接口可以让代码意图更清晰。

7.2 CounterController:纯逻辑控制器

class CounterController {
  count: number = 0

  increment(): void { this.count += 1 }
  decrement(): void { this.count -= 1 }
  reset(): void { this.count = 0 }
  getCount(): number { return this.count }
}

没有任何 ArkUI 装饰器,纯粹的业务逻辑类。它的好处是:

  • 可测试:可以脱离 UI 直接单元测试 Controller 的逻辑。
  • 可复用:同一个 Controller 实例可以被多个组件共享。
  • 职责单一:只管数据和逻辑,不管 UI。

7.3 CounterBox:使用 Controller 的子组件

@Component
struct CounterBox {
  controller: CounterController = new CounterController()
  onCountChange: (count: number) => void = (_count: number) => {}

  build() {
    Column() {
      Text('计数: ' + String(this.controller.getCount()))
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 12 })
      Row() {
        Button('-')
          .width(56).height(40)
          .onClick(() => {
            this.controller.decrement()
            this.onCountChange(this.controller.getCount())
          })
        Button('重置')
          .width(72).height(40)
          .margin({ left: 8, right: 8 })
          .onClick(() => {
            this.controller.reset()
            this.onCountChange(this.controller.getCount())
          })
        Button('+')
          .width(56).height(40)
          .onClick(() => {
            this.controller.increment()
            this.onCountChange(this.controller.getCount())
          })
      }
    }
    .padding(16)
    .borderRadius(12)
    .backgroundColor(Color.White)
    .shadow({ radius: 8, color: '#1a000000', offsetX: 0, offsetY: 2 })
  }
}

这里有一个设计选择值得一提:onCountChange 回调。为什么不用 @Watch 监听 Controller 的 count 变化?因为 Controller 是普通对象,它的属性变化不会触发 ArkUI 的状态观测机制count 从 0 变成 1,ArkUI 不知道,UI 不会刷新。

所以子组件在每次调用 Controller 方法后,手动通过 onCountChange 回调通知父组件"count 变了"。父组件收到回调后修改自己的 @State 变量,从而触发 UI 刷新。

7.4 CardContainer:@BuilderParam 插槽容器

@Component
struct CardContainer {
  @BuilderParam content: () => void

  build() {
    Column() {
      this.content()
    }
    .padding(16)
    .borderRadius(12)
    .border({ width: 1, color: '#e0e0e0' })
    .backgroundColor(Color.White)
    .shadow({ radius: 6, color: '#14000000', offsetX: 0, offsetY: 2 })
    .width('100%')
  }
}

简洁的插槽容器。this.content() 就是插槽内容的渲染入口。所有样式定义在容器上,内容完全由外部控制。

7.5 ComponentArchDemo 主页面

@Entry
@Component
struct ComponentArchDemo {
  @State controllerCount: number = 0
  @State lastEventId: number = 0
  @State lastEventData: string = ''
  @State logText: string = ''
  @State activeTab: number = 0
  private controller: CounterController = new CounterController()

主页面维护了五块状态:

  • controllerCount:Controller 模式展示的计数值
  • lastEventIdlastEventData:Emitter 接收到的最新事件信息
  • logText:操作日志文本
  • activeTab:Tabs 当前页索引

controller 是 private 的,不会触发 UI 刷新,但它的 count 值变化会通过 onCountChange 回调同步到 controllerCount(这是 @State,会触发刷新)。

7.6 aboutToAppear / aboutToDisappear:Emitter 生命周期管理

aboutToAppear(): void {
  emitter.on({ eventId: EVENT_INCREMENT }, () => {
    this.lastEventId = EVENT_INCREMENT
    this.lastEventData = '增加事件'
    this.addLog('收到增加事件 (ID: ' + String(EVENT_INCREMENT) + ')')
  })
  emitter.on({ eventId: EVENT_DECREMENT }, () => {
    this.lastEventId = EVENT_DECREMENT
    this.lastEventData = '减少事件'
    this.addLog('收到减少事件 (ID: ' + String(EVENT_DECREMENT) + ')')
  })
  emitter.on({ eventId: EVENT_DATA_UPDATE }, (eventData: emitter.EventData) => {
    this.lastEventId = EVENT_DATA_UPDATE
    let payload: string = ''
    if (eventData.data && eventData.data.message) {
      payload = String(eventData.data.message)
    }
    this.lastEventData = payload
    this.addLog('收到数据更新 (ID: ' + String(EVENT_DATA_UPDATE) + ', data: ' + payload + ')')
  })
}

aboutToDisappear(): void {
  emitter.off(EVENT_INCREMENT)
  emitter.off(EVENT_DECREMENT)
  emitter.off(EVENT_DATA_UPDATE)
}

aboutToAppear 注册三个事件监听,aboutToDisappear 注销三个。成对出现,这是 Emitter 使用的标准范式。

注意所有回调都是箭头函数,确保 this 指向组件实例。如果用普通函数,this 会在 Emitter 调用时丢失。

7.7 主页面 build:双 Tab 布局

build() {
  Column() {
    Row() {
      Image($r('sys.symbol.chevron_left'))
        .width(24).height(24).fillColor(Color.White)
      Text('组件化架构与模块通信')
        .fontSize(20).fontWeight(FontWeight.Bold).fontColor(Color.White)
    }
    .width('100%').height(56).padding({ left: 16, right: 16 })
    .backgroundColor('#1976D2')

    Tabs({ index: this.activeTab }) {
      TabContent() {
        Scroll() {
          Column() {
            this.ControllerSection()
            this.EmitterSection()
            this.BuilderParamSection()
            this.LogSection()
          }
          .padding(16)
        }
      }.tabBar('实战演示')

      TabContent() {
        Scroll() {
          Column() {
            this.ComparisonTable()
            this.PrinciplesSection()
            this.PitfallsSection()
          }
          .padding(16)
        }
      }.tabBar('对比指南')
    }
    .width('100%').layoutWeight(1).barMode(BarMode.Fixed)
  }
  .width('100%').height('100%').backgroundColor('#f5f5f5')
}

Demo 用了双 Tab 结构:第一个 Tab 是实战演示,包含 Controller、Emitter、@BuilderParam 三个交互区和一个日志区;第二个 Tab 是对比指南,包含通信方案对比表、组件封装原则和常见坑。

7.8 对比指南 Tab:表格与踩坑列表

对比指南 Tab 里的 ComparisonTable 用 Row + Text 手动实现了一个表格,直观展示四种通信方案的差异。PitfallsSection 列出了四大坑:

  1. Controller 引用在组件销毁后无效,需判空
  2. Emitter 事件 ID 冲突会导致误触发
  3. @BuilderParam 不支持多个插槽(V1),V2 的 @WrapperParam 支持
  4. Emitter 的 onData 回调中 this 指向可能丢失

这些坑在实际项目中都真真切切踩过,不是理论推导出来的。


八、踩坑汇总:一张表搞定所有坑

模式 症状 解法
Controller 组件销毁后引用无效 调用方法不报错但UI不刷新,或运行时异常 Controller 加 isValid 标记,aboutToDisappear 时 destroy
Controller 属性变化不触发UI刷新 Controller.count 改了,页面不变 配合回调通知父组件修改 @State
Emitter 事件 ID 冲突误触发 A 页面 emit,B/C 页面也响应 按模块分段分配 ID,集中管理常量
Emitter 回调 this 绑定丢失 回调内访问 this.xxx 报 Cannot read property 用箭头函数,不用普通 function
Emitter 忘记 off 导致僵尸回调 重复进页面后 emit 触发多次 aboutToDisappear 中 emitter.off
Emitter off(eventId) 误杀他人回调 多组件监听同 eventId,一个 off 全部失效 用 off(eventId, callback) 精确注销
@BuilderParam V1 只支持单插槽 声明第二个 @BuilderParam 编译报错 迁移 V2 用 @WrapperParam
@BuilderParam 普通props传Builder无刷新 状态变了但插槽内容不更新 用 @BuilderParam 而非普通 props 传 Builder
@Provide/@Consume 双 key 隐式绑定 没写 alias 但属性名相同意外绑定 始终写 alias,不依赖属性名匹配
@Provide/@Consume 找不到 @Provide 崩溃 @Consume 无默认值,缺 @Provider 直接白屏 V2 用 @Consumer 带默认值;V1 确保组件树完整

九、从 Demo 到实战:我的项目经验总结

做完这个 Demo 回顾实际项目,我最大的感触是——通信方案的选择不是技术问题,是架构问题

技术层面,四种方案各有所长,没有银弹。但架构层面,选择通信方式的核心原则只有一条:最小耦合原则。能用 props 传的别用 @Provide,能用 @Provide 的别用 Emitter,能用 @BuilderParam 的别搞继承。

我在项目里见过最典型的反面案例,是一个同学把所有组件间通信都塞进了 Emitter。登录状态变更用 Emitter,列表刷新用 Emitter,弹窗关闭也用 Emitter,甚至连表单校验结果都用 Emitter 广播。结果就是:emitter_events.ets 里有 200 多个事件常量,没人知道哪个事件谁在监听、谁在发送,改一个功能要小心翼翼确认不会影响其他页面。

Emitter 的"极低耦合度"是双刃剑——解耦到了极致就是失控。真正的架构追求不是零耦合,而是耦合可控。Controller、@BuilderParam、@Provide/@Consume 都有明确的"接口边界"——谁是数据的提供者、谁是消费者,在代码里一目了然。Emitter 没有,所以它只应该出现在确实需要"不知道谁在监听"的场景里。

另一个感触是:不要用单一方案覆盖所有场景。四种通信模式不是互斥的,而是互补的。一个复杂页面可能同时用到 Controller(控制子组件行为)、@BuilderParam(组合布局)、@Provide/@Consume(跨层级共享主题)和 Emitter(响应全局事件)。关键是每个场景选对工具,别拿锤子当螺丝刀使。


十、总结

维度 Controller Emitter @BuilderParam @Provide/@Consume
心智模型 “我命令你做” “我广播一件事” “我给你一段内容” “我共享一个状态”
通信本质 方法调用 事件发布/订阅 内容注入 状态绑定
适用层级 父子 任意 父子 跨层级同组件树
最佳实践 纯逻辑类 + 回调通知 箭头函数 + aboutToDisappear off 单插槽 V1/V2,多插槽 V2 V2 @Provider/@Consumer 优先
核心风险 销毁后引用失效 ID冲突 + this丢失 + 忘记off V1单插槽限制 隐式绑定 + 无默认值崩溃

四条铁律

  1. Controller 必须处理销毁后引用——加 isValid 标记或判空,别赌组件一定在。
  2. Emitter 必须在 aboutToDisappear 中 off——不 off 就等着僵尸回调吃内存。
  3. @BuilderParam 需要多插槽就上 V2——别在 V1 里折腾 workaround。
  4. @Provide/@Consume 新项目用 V2 的 @Provider/@Consumer——容错默认值和函数类型支持,不香吗?

最后再强调一遍选型核心:Controller 管命令,Emitter 管广播,@BuilderParam 管组合,@Provide/@Consume 管共享。记住这四个关键词,选型就不会跑偏。


本文 Demo 代码来源:entry/src/main/ets/pages/ComponentArchDemo.ets
官方文档:@BuilderParam装饰器:引用@Builder函数Emitter订阅与发布@Provide装饰器和@Consume装饰器:与后代组件双向同步

Logo

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

更多推荐