HarmonyOS 鸿蒙 自定义组件的对外接口范式 —— Controller 引用附加与 epoch 资源重绑
一、先看两次爆炸:@Prop 的值语义陷阱
1.1 爆炸一:@Prop controller → 按钮完全无响应
E017 Coverflow 的程序化导航(next / previous / animateTo)最初这样传:
// ❌ 宿主侧
CoverflowCarousel({
controller: this.controller, // 想当然地当普通参数传
...
})
组件内 aboutToAppear 时 this.controller.attach(handlers) 把命令处理器挂上。构建通过、页面正常渲染,真机上点 next 按钮,轮播纹丝不动。
排障记录(E017 真机故障表原文):
|
现象 |
根因 |
修正 |
|---|---|---|
|
按钮完全无响应 |
|
Controller 禁止 @Prop,引用传入 |
根因:@Prop 装饰的成员是值语义,ArkUI 会对它做深拷贝。宿主手里攥着 controller 原件,组件里 attach 的是复印件——宿主调 next() 时,复印件上的处理器根本没挂。更阴险的是它不报错:调用链每一环都「正常执行」,只是对象不是同一个。
1.2 爆炸二:@Prop PixelMap → native 句柄断裂
E019 BookFlip 立项时把这条写进了风险表并直接封死:
Controller
@Prop失效 → 禁止 @Prop,引用 attach。 PixelMap 泄漏 / 异步不刷新 → 生命周期表 + epoch 重建(E018)。
@Prop 深拷贝对普通数据是性能浪费,对 PixelMap / SurfaceBuffer 这类 native 句柄则是正确性问题:拷贝动作会破坏 native 侧的句柄对应关系,轻则纹理不更新,重则句柄悬空。
1.3 病根:值语义撞上引用语义
两次爆炸是同一个病根。ArkUI 的输入通道里:
|
通道 |
语义 |
适合 |
|---|---|---|
|
|
值语义,深拷贝,单向 |
纯数据:number / string / enum / 小配置对象 |
|
|
引用 + 双向同步 |
与宿主同生命周期的状态 |
|
普通成员(无装饰器) |
引用语义 |
对象、闭包、controller、holder |
controller 持有的是行为(闭包),PixelMap 持有的是native 资源——它们天然是引用语义的东西,塞进值语义通道就会被拷坏。所以规矩只有一条:这类东西走普通成员引用传入,永不加 @Prop。
二、Controller 引用附加:命令进组件
2.1 标准形态
E017 沉淀、后续所有组件沿用的标准形态,以 CoverflowController 为例:
/** Explicit attach handlers — ArkTS forbids untyped object-literal types. */
export class CoverflowAttachHandlers {
next: () => void = (): void => {};
previous: () => void = (): void => {};
animateTo: (index: number) => void = (_index: number): void => {};
}
export class CoverflowController {
private nextFn?: () => void;
private previousFn?: () => void;
private animateToFn?: (index: number) => void;
private page: number = 0;
private listeners: Array<CoverflowPageListener> = [];
// ── 宿主调用的命令面 ──
next(): void { this.nextFn?.(); }
previous(): void { this.previousFn?.(); }
animateTo(index: number): void { this.animateToFn?.(index); }
// ── 状态回推面 ──
getPage(): number { return this.page; }
onPageChange(listener: CoverflowPageListener): void { this.listeners.push(listener); }
// ── 组件侧生命周期(宿主不要调)──
attach(handlers: CoverflowAttachHandlers): void {
this.nextFn = handlers.next;
this.previousFn = handlers.previous;
this.animateToFn = handlers.animateTo;
}
detach(): void {
this.nextFn = undefined;
this.previousFn = undefined;
this.animateToFn = undefined;
}
updatePage(page: number): void {
if (this.page === page) { return; }
this.page = page;
for (let i = 0; i < this.listeners.length; i++) { this.listeners[i](page); }
}
dispose(): void { this.detach(); this.listeners = []; }
}
四个面各司其职:
-
命令面(
next/previous/animateTo):宿主调用,空守卫?.保证未 attach 时静默无害; -
回推面(
updatePage+ listeners):组件把状态变化写回 controller,宿主可轮询getPage()或订阅onPageChange; -
attach 面:组件在
aboutToAppear时传入一组闭包; -
dispose 面:宿主废弃 controller 时清干净监听。
组件侧的配合代码:
// 组件内
private attachController(): void {
if (this.controller === undefined) { return; }
const handlers: CoverflowAttachHandlers = new CoverflowAttachHandlers();
handlers.next = (): void => { this.programmaticNext(); };
handlers.animateTo = (index: number): void => { this.animateToIndex(index); };
this.controller.attach(handlers); // ← 组件主动挂,宿主被动收
}
aboutToDisappear(): void {
this.controller?.detach(); // ← 卸载必摘,防悬空调用
}
宿主侧的用法(注意 controller 是普通成员引用传入):
@State private controller: CoverflowController = new CoverflowController();
CoverflowCarousel({
controller: this.controller, // 引用字段,组件侧不加任何装饰器
...
})
Button('下一页').onClick(() => this.controller.next())
2.2 变体二:GrokBotController —— 单绑定强约束 + Promise 命令
表情头像的命令(blink / spin)有两个新需求:命令有时长(眨眼 300ms、转两圈 1.2s,宿主要 await 串联),以及一个 controller 只该控制一个挂载实例。于是变体长这样:
export class GrokBotController {
private binding: GrokBotControllerBinding | null = null;
attach(binding: GrokBotControllerBinding): void {
if (this.binding !== null && this.binding !== binding) {
throw new Error('A GrokBotController can only control one GrokBot at a time.');
}
this.binding = binding;
}
spin(turns: number = 1, duration: number = 1200): Promise<void> {
if (this.binding === null) { return Promise.reject(new Error('GrokBotController is not attached.')); }
return this.binding.spin(turns, duration); // ← 返回 Promise,宿主可 await
}
}
两个设计差异:
-
attach 二次绑定不同对象直接 throw——把「一个遥控器控制两台电视」这类配置错误在开发期炸出来,而不是真机上出现玄学行为;
-
命令返回
Promise<void>,动画命令天然有时长,返回 Promise 让宿主能编排命令序列(先眨眼、再转圈、最后 reset)。未 attach 时返回 reject 而不是静默,调用方能感知。
2.3 变体三:SlotTextController —— 命令对象 + 单 handler
滚字组件的命令带富参数(文本 + 样式 + 回退时长),值语义的命令对象比多参数闭包更合适:
export enum SlotTextCommandType { Set = 'set', Flash = 'flash', Finish = 'finish' }
export class SlotTextCommand {
constructor(type: SlotTextCommandType, text: string = '',
options?: SlotTextOptions, revertAfter: number = 1400) { ... }
}
export class SlotTextController {
private handler?: (command: SlotTextCommand) => void;
bind(handler: (command: SlotTextCommand) => void): void { this.handler = handler; }
unbind(): void { this.handler = undefined; }
flash(text: string, options?: SlotTextOptions, revertAfter: number = 1400): void {
this.handler?.(new SlotTextCommand(SlotTextCommandType.Flash, text, options, revertAfter));
}
}
命令是值对象(可以拷贝、可以排队、可以单测断言),通道是单个 handler 引用——值语义和引用语义各归其位,这就是没踩坑的样子。
2.4 三个变体的选型
|
Coverflow |
GrokBot |
SlotText |
|
|---|---|---|---|
|
attach 形态 |
typed handlers 类,多闭包 |
binding 对象,单绑定 throw |
单 handler 函数 |
|
命令返回 |
void |
Promise(可 await 编排) |
void |
|
命令载荷 |
无参/单参 |
数值参数 |
命令值对象(富参数) |
|
状态回推 |
updatePage + listeners |
—(命令即反馈) |
— |
选型口诀:命令简单 → handlers 类;命令有时长要编排 → Promise;命令参数富 → 命令对象。 但无论哪个变体,铁律不变:引用传入、组件 attach、卸载 detach、命令空守卫。
三、holder + epoch:资源进组件
命令通道解决了,第二个问题是大资源(页列表、封面纹理)怎么换。直接换 @Prop 数组会触发深拷贝;卸载重挂组件树又黑屏闪断。
3.1 pagesHolder + pagesEpoch:同挂载重绑
E019 BookFlip 的解法是一个引用容器 + 一个变化计数器:
// 容器:引用语义,宿主和组件看到同一个数组
export class BookFlipPagesHolder {
items: Array<image.PixelMap> = [];
}
// 组件侧
pagesHolder: BookFlipPagesHolder = new BookFlipPagesHolder(); // 普通成员
@Prop @Watch('onPagesEpoch') pagesEpoch: number = 0; // 唯一的值语义输入:一个数字
private onPagesEpoch(): void {
this.bindPagesAndController(true); // 重新绑定纹理、重置状态
this.scheduleRepaints();
}
宿主换页流程:
this.pagesHolder.items = newPages; // ① 引用替换,零拷贝
this.pagesEpoch++; // ② 数字 +1,触发 @Watch 重绑
组件不卸载、布局不重建,只有纹理绑定刷新——换 6/8/12 页没有黑屏闪断。@Prop 在这里只承担它擅长的事:传一个会被深拷贝也无所谓的小数字。
3.2 previewEpoch + .id():强制 native 节点重建
E018 贴纸实验暴露了另一面:异步生成的 PixelMap 塞进 @State 后,@Builder 值参数在构建时拍了快照,新 PixelMap 不会重新绑定到 Builder 内部的 Image。解法是让 Image 的 .id() 含 epoch,递增即强制重建渲染节点:
@State private previewEpoch: number = 0;
// 每次异步结果就绪
this.foregroundResult = result;
this.previewEpoch++; // 新结果 → 新 epoch
Image(this.stickerResult)
.id(`e018_sticker_${this.previewEpoch}`) // id 变 → 节点重建 → native 句柄重绑
配套规则:异步 native 资源不做 @Builder 值参数的长期渲染绑定。要么让 Builder 直接读宿主 @State,要么 epoch 换 id。BookFlip 的 paintEpoch(DrawModifier 场景)是同一招的变体。
3.3 两种 epoch 的区别:重绑 vs 重建
|
pagesEpoch(E019) |
previewEpoch(E018) |
|
|---|---|---|
|
动作 |
重绑:引用换新,组件树不动 |
重建:渲染节点推倒重来 |
|
适用 |
整组资源热替换(换书、换页数) |
单个异步结果落到已有节点 |
|
代价 |
小(无布局重建) |
中(节点重建一次) |
|
通道 |
holder 引用 + |
|
共同思想:把「资源变了」编码成一个单调递增的数字,让 ArkUI 的状态系统替你触发正确的更新路径。 数字是可以随便深拷贝的,资源不行。
四、状态怎么出组件:回调保持值语义
第三个方向最简单也最容易被做乱:组件向宿主回报状态。项目里统一用构造参数回调,载荷全部是值:
onSpreadChanged: (spread: number) => void
onFlipStart: (spread: number, dir: FlipDirection) => void
onOpenChanged: (open: boolean) => void
onSelectionChange: (index: number) => void
回调在 @Builder/组件字面量里是引用传递(闭包),不经过深拷贝,天然安全。要守的只有两条:载荷用值不用对象引用(避免宿主拿到组件内部可变状态),高频回调(每帧 progress)不提供——要连续状态就让宿主订阅 Controller 的 listener,而不是拖一条每帧回调进 Builder。
五、决策表与契约清单
全部通道一张表收口:
|
要传什么 |
通道 |
理由 |
|---|---|---|
|
纯配置(number/string/enum/小对象) |
|
值语义,单向同步,正是它的本职 |
|
与宿主共生命的双向状态 |
|
引用 + 双向 |
|
Controller / 闭包集合 |
普通成员 + attach |
引用语义,@Prop 深拷贝会断链 |
|
大资源列表 / native 句柄 |
holder 引用 + epoch 计数 |
引用换新零拷贝,epoch 触发重绑 |
|
异步就绪的单个结果 |
|
强制节点重建,绕开 Builder 快照 |
|
状态回报 |
构造参数回调(值载荷) |
引用安全,值载荷无副作用 |
最后是可抄走的组件对外接口契约(E017/E019 风险表 + E018 Device Lessons 的合集):
-
Controller、holder、回调永不加
@Prop; -
组件
aboutToAppearattach,aboutToDisappeardetach——卸载必摘; -
命令方法一律空守卫(
?.或 reject),未 attach 不炸; -
一个 controller 默认控制一个实例,需要强约束就 attach 时 throw;
-
有时长的命令返回 Promise,支持宿主编排;
-
资源替换走 holder + epoch,不卸载组件树;
-
异步 native 结果落节点,epoch 拼进
.id(); -
回调载荷用值;每帧级连续状态走 listener 订阅,不走构造回调。
结语
回头看,这两套标准件没有一行代码依赖什么高深 API——它们只是把 ArkUI 值语义/引用语义的边界摆正了:值走值的通道(@Prop、命令对象、回调载荷),引用走引用的通道(普通成员、holder、闭包),在两种语义需要握手的地方放一个 epoch 数字做扳机。
《分层范式》讲「组件内部怎么长」,《对外接口范式》讲「组件怎么和别人相处」。两篇合起来,ArkUILab 里一个自定义组件从内到外的骨架就齐了——这也是为什么后面每个新组件(BookFlip、GrokBot、ThinkingOrbs……)写起来越来越快:骨架不用再想,只剩几何和绘制要动脑。
更多推荐




所有评论(0)