HarmonyOS 6.1 组件化架构与模块通信实战:Controller、Emitter、@BuilderParam、@Provide/@Consume 四大模式全解析
基于真实项目踩坑经验,覆盖 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)
}
}
关键点:
controller是一个普通 prop,不是@Prop也不是@Link,就是从父组件传进来的对象引用。- 子组件通过
this.controller.increment()调用控制器方法,然后通过onCountChange回调通知父组件数据变了。 - 数据流清晰:父组件调用 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 开始编号,就是这个原因。你要是直接写 1、2、3,迟早有一天会跟别人的事件撞上。
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()
}
}
}
这样写虽然能跑,但 header、body、footer 不再享有 @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 跨了三层直接拿到了 GrandParent 的 counter,而且修改会双向同步回根组件。中间层 MiddleLayer 完全透明,不需要声明任何参数来转发。
5.3 @Provide/@Consume 的适用场景
- 深层嵌套共享状态:超过3层的嵌套,逐层传 props 成本太高。
- 多分支共享:同一个状态被多个子树消费,一处声明多处使用。
- 主题/配置类数据:全局主题色、字体大小、语言设置等"无处不在"的数据。
5.4 踩坑提醒
关于 @Provide/@Consume 的坑,第7篇已经讲得非常详细了。这里只列要点:
- 双 key 匹配的隐式绑定:alias 和属性名都能匹配,两个不相干的组件恰好属性名一样就被隐式绑在一起了,排查 bug 时怀疑人生。
- @Consume 不允许本地初始化:找不到 @Provider 直接崩溃,组件无法独立运行,复用性和可测试性为零。
- 不支持函数类型:子→父事件不能通过 @Provide/@Consume 传,V2 的 @Provider/@Consumer 才支持,V1 里只能老老实实一层层传回调。
- 同名 @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 模式展示的计数值lastEventId、lastEventData: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 列出了四大坑:
- Controller 引用在组件销毁后无效,需判空
- Emitter 事件 ID 冲突会导致误触发
- @BuilderParam 不支持多个插槽(V1),V2 的 @WrapperParam 支持
- 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单插槽限制 | 隐式绑定 + 无默认值崩溃 |
四条铁律:
- Controller 必须处理销毁后引用——加 isValid 标记或判空,别赌组件一定在。
- Emitter 必须在 aboutToDisappear 中 off——不 off 就等着僵尸回调吃内存。
- @BuilderParam 需要多插槽就上 V2——别在 V1 里折腾 workaround。
- @Provide/@Consume 新项目用 V2 的 @Provider/@Consumer——容错默认值和函数类型支持,不香吗?
最后再强调一遍选型核心:Controller 管命令,Emitter 管广播,@BuilderParam 管组合,@Provide/@Consume 管共享。记住这四个关键词,选型就不会跑偏。
本文 Demo 代码来源:
entry/src/main/ets/pages/ComponentArchDemo.ets
官方文档:@BuilderParam装饰器:引用@Builder函数、Emitter订阅与发布、@Provide装饰器和@Consume装饰器:与后代组件双向同步
更多推荐


所有评论(0)