一、先看两次爆炸:@Prop 的值语义陷阱

1.1 爆炸一:@Prop controller → 按钮完全无响应

E017 Coverflow 的程序化导航(next / previous / animateTo)最初这样传:

// ❌ 宿主侧
CoverflowCarousel({
  controller: this.controller,   // 想当然地当普通参数传
  ...
})

组件内 aboutToAppearthis.controller.attach(handlers) 把命令处理器挂上。构建通过、页面正常渲染,真机上点 next 按钮,轮播纹丝不动

排障记录(E017 真机故障表原文):

现象

根因

修正

按钮完全无响应

@Prop controller 深拷贝,attach 丢失

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 的输入通道里:

通道

语义

适合

@Prop

值语义,深拷贝,单向

纯数据:number / string / enum / 小配置对象

@Link / @ObjectLink

引用 + 双向同步

与宿主同生命周期的状态

普通成员(无装饰器)

引用语义

对象、闭包、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 引用 + @Prop @Watch 计数

@State 计数 + .id() 拼接

共同思想:把「资源变了」编码成一个单调递增的数字,让 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/小对象)

@Prop

值语义,单向同步,正是它的本职

与宿主共生命的双向状态

@Link

引用 + 双向

Controller / 闭包集合

普通成员 + attach

引用语义,@Prop 深拷贝会断链

大资源列表 / native 句柄

holder 引用 + epoch 计数

引用换新零拷贝,epoch 触发重绑

异步就绪的单个结果

@State + .id(epoch)

强制节点重建,绕开 Builder 快照

状态回报

构造参数回调(值载荷)

引用安全,值载荷无副作用

最后是可抄走的组件对外接口契约(E017/E019 风险表 + E018 Device Lessons 的合集):

  1. Controller、holder、回调永不加 @Prop

  2. 组件 aboutToAppear attach,aboutToDisappear detach——卸载必摘;

  3. 命令方法一律空守卫(?. 或 reject),未 attach 不炸;

  4. 一个 controller 默认控制一个实例,需要强约束就 attach 时 throw;

  5. 有时长的命令返回 Promise,支持宿主编排;

  6. 资源替换走 holder + epoch,不卸载组件树;

  7. 异步 native 结果落节点,epoch 拼进 .id()

  8. 回调载荷用值;每帧级连续状态走 listener 订阅,不走构造回调。

结语

回头看,这两套标准件没有一行代码依赖什么高深 API——它们只是把 ArkUI 值语义/引用语义的边界摆正了:值走值的通道(@Prop、命令对象、回调载荷),引用走引用的通道(普通成员、holder、闭包),在两种语义需要握手的地方放一个 epoch 数字做扳机。

《分层范式》讲「组件内部怎么长」,《对外接口范式》讲「组件怎么和别人相处」。两篇合起来,ArkUILab 里一个自定义组件从内到外的骨架就齐了——这也是为什么后面每个新组件(BookFlip、GrokBot、ThinkingOrbs……)写起来越来越快:骨架不用再想,只剩几何和绘制要动脑。

Logo

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

更多推荐