一、注册表分发:一行 if/else 都为类型而写

渲染层要回答的第一个问题:给定一个块,用什么组件画它?朴素写法是一串 if (type === 'paragraph') ... else if (type === 'image') ...——每加一种块类型改一次分发处,和第 05 篇批判过的菜单硬编码是同一种病。ArkBlocks 的解药也是同一副:注册表。

export type RenderCategory = 'editable' | 'divider' | 'card' | 'image';

export class RendererRegistry {
  private entries: Map<string, RendererEntry> = new Map();

  register(blockType: string, category: RenderCategory): void { ... }

  getCategory(blockType: string): RenderCategory {
    const entry = this.entries.get(blockType);
    return entry !== undefined ? entry.category : 'editable';   // 缺省:可编辑文本
  }

  registerDefaults(): void {
    this.register('paragraph', 'editable');
    this.register('heading', 'editable');
    this.register('checkListItem', 'editable');
    this.register('bulletListItem', 'editable');
    this.register('numberedListItem', 'editable');
    this.register('quote', 'editable');
    this.register('divider', 'divider');
    // 注意:image / card / callout 不在这里——
    // 它们由各自的官方插件经 PluginContext.registerRenderer() 注册
  }
}

两个设计点。其一,分类只有四种,因为渲染路径的差异是结构性的:editable(走 RichEditor 的文本块)、divider(无内容横线)、card(带变体尺寸的容器)、image(带布局选项的媒体)。类型可以无限多,渲染机制是收敛的——注册表映射的是机制而不是组件。其二,缺省 editable:未注册的类型按文本块处理,新块类型接入的前一刻也不会渲染崩溃。

为什么注册的是"分类"而不是"渲染函数"?第 06 篇埋过这个伏笔:ArkUI 静态编译,组件不能运行时动态加载——Web 编辑器"注册一个返回组件的函数"这条路在鸿蒙不存在。现实的做法是插件声明分类、应用在分发处按分类路由到具体组件。平台约束再次改写了 API 的形状。

分发处的真实代码(PlaygroundBlock.build())如今长这样:

build() {
  if (this.isDivider()) {
    DividerBlockRenderer({ block: this.block, ... })
  } else if (this.isCard()) {
    CardBlockRenderer({ block: this.block, ... })
  } else if (this.isImage()) {
    ImageBlockRenderer({ block: this.block, ... })
  } else {
    this.buildEditable();      // 文本块:RichEditor 路径
  }
}

注意这已经不是被批判的 if/else 链——分支的判据来自注册表的分类,分支的目标是四个独立组件文件。if/else 本身无罪,无罪的分支 + 有主的数据才是健康的形态。

二、RendererDiff:互斥契约下的精确受影响集合

第 03 篇说过,diff 是事务执行的副产品。这里看它的完整契约——六个列表,且互斥规则写进了类型注释

export interface RendererDiff {
  insertedIds: string[];       // 新插入的块
  removedIds: string[];        // 被移除的块
  updatedIds: string[];        // 内容/属性/结构变了,但身份与位置不变
  movedIds: string[];          // 位置变了(含深度变化)
  propsChangedIds: string[];   // updatedIds 的子集:只有属性变
  contentChangedIds: string[]; // updatedIds 的子集:只有行内内容变
}

契约的三条规则值得原文引用:

  1. 一个块在一次事务中最多出现在一个列表里——插入即隐含内容(insertedIds 不需要再进 updatedIds);
  2. propsChangedIds / contentChangedIds 是 updatedIds 的子集,专门为"只刷属性不刷内容"的细粒度优化而设;
  3. 渲染层拿到的永远是精确受影响集合,不需要自己反推。

回忆第 03 篇的执行器代码:insertBlock 时 collectDescendantIds 把整棵子树收进 insertedIdsmergeBlock 一次产生 updated + removed + moved 三类条目。这些账都是操作应用时顺手记的——diff 的完整性由事务管道保证,渲染层只管消费。职责线画在这里,渲染层就永远不会"漏刷"或"多刷到不知道为什么"。

三、真机悬案:修好闪烁就丢样式,修好样式又闪烁

接下来是本篇的主菜——MEMORY.md 里记录最完整的一次真机调试。场景平凡:Todo 块的 Checkbox 点选切换。现象不平凡:全页闪烁勾选后样式丢失(删除线和淡色不生效)交替出现,前后四轮修复,修好一个就坏另一个。

先把两个根因摆出来,它们方向相反,这正是"交替出现"的原因:

根因一:ForEach 的 key 里含全局 renderRevision Playground 的行 key 大致是 "${renderRevision}:${block.id}",而 renderRevision 在任何 notifyChange() 时递增。于是哪怕只是勾选了一个 Checkbox,所有行的 key 同时变化,ArkUI 判定为"全新组件列表",销毁并重建全部 RichEditor——用户看到的就是一次全页闪烁。此时勾选样式反而是对的(重建时 onReady 从引擎数据正确应用了样式)。

根因二:key 不变时,ForEach 对 @Prop 的更新不可靠。 为了消除闪烁改走"属性级更新、不 bump revision",问题转到另一面:ArkUI 的 ForEach 在 key 不变时复用组件,但对复用组件的 @Prop 传递不保证触发更新——复杂对象 @Prop block: Block 的嵌套属性变化(block.props.checked)不触发,甚至拆成独立基本类型 @Prop checked: boolean + @Watch 也可能不触发。样式依赖这些信号重绘,信号不来,样式就丢。

四轮失败尝试的完整轨迹(MEMORY.md 按时间顺序原样记录,这份"死路地图"比答案更值钱):

# 尝试 闪烁 样式 结论
1 直接 notifyChange()(全量重建) 重建换来闪烁,不可接受
2 拆分 notifyPropsChange() + @Watch 监听 block.props.checked @Watch 对复杂对象嵌套属性不触发
3 拆出独立 @Prop checked: boolean + @Watch key 不变时基本类型 @Prop 也不可靠
4 key 里加入 checked 状态(仍有 renderRevision) 任何 bump revision 的写法都会全量重建

看清楚这个震荡的结构:**方案 1/4 靠"重建"保证正确性,牺牲性能;方案 2/3 靠"复用"保证性能,但复用路径上的更新信号不可靠。**两条路单走都通不了,答案必须是绕开这条二选一。

四、Durable Fix:组件本地状态 + 三步同步

最终方案的核心是一句换轨:不依赖 ForEach 的 @Prop 传递驱动样式,让组件用本地状态直接管理即时 UI,异步通知引擎持久化

// PlaygroundBlock 内部
private localChecked: boolean = false;     // 本地状态:样式由它驱动

// Checkbox 的 onChange —— 三步同步
.onChange((checked: boolean) => {
  this.localChecked = checked;             // ① 立即更新本地状态 → UI 即时响应
  this.applyEditorText(this.currentText);  // ② 立即重放文本样式(删除线/淡色)
  this.onToggleChecked(this.block.id);     // ③ 异步通知引擎持久化
})

// 组件(重)创建时:onReady 从引擎数据初始化本地状态
// localChecked = block.props.checked

引擎侧配合:toggleChecked() 走 notifyPropsChange()(非 notifyChange()),renderRevision 不动,ForEach key 不变,组件不重建、不闪烁。而本地状态的初始值在 onReady 里从引擎取——那些必须重建的场景(Undo/Redo 走 notifyChange() → revision 递增 → 重建),重建出来的组件从引擎快照拿到正确的初始样式,闭环不破。

把这套模式浓缩成一张双通道表,这是本篇最值得带走的产物:

变更类型 典型操作 通知方法 renderRevision 组件行为
结构性 插入 / 删除 / 移动 / 拆分 / 合并 / Undo / 加载文档 notifyChange() +1 key 变 → 组件重建,从引擎快照恢复
属性性 Checkbox 勾选 / 图片布局切换 / 选中态 notifyPropsChange() + 轻量 payload 不变 key 不变 → 复用组件,本地状态直接驱动 UI

判断标准只有一条:**块的身份与位置变没变?**变了走结构通道(重建是唯一能保证 RichEditor 等有状态组件不携带陈旧快照的手段);没变走属性通道(用户交互即时改本地 UI,引擎异步记账)。两条通道各自极致,互不越界。

五、第二个案例:图片布局切换的闪烁

双通道模式很快在第二个场景复验了价值。Image Block 左滑打开 Action Sheet 切换布局(半宽 ↔ 全宽)时,页面明显闪动——图片区块尤其刺眼:组件重建期间先露出选中态的蓝色背景,图片再重新出现。

根因与 Checkbox 案例同构:布局切换只改 widthMode / aspectRatio 两个 props,属属性性变更,实现却调了 notifyChange(),revision 递增导致全表重建。修复走属性通道,并带来两个增量模式:

其一:轻量 payload 通知复用中的组件。 由于 @Prop 嵌套更新不可靠(根因二仍在),属性变化用一条 JSON 字符串 payload 显式推给目标组件:

this.imageUpdatePayload = JSON.stringify({
  revision: this.imageUpdateRevision,
  blockId,
  src: ..., localUri: ...,
  widthMode: ..., aspectRatio: ...,
});

组件监听 payload 变化、解析并更新自己的 src / widthMode 等本地状态——复用中的组件就地换装,不重建。

其二:per-block 刷新版本,只动目标行的 key。 当 payload 仍不够(个别真机上 @Watch 不触发),还有一层兜底:为每个块维护独立的 blockRefreshRevisions,行 key 拼入该行自己的 revision 而非全局值——

private getRenderRowKey(row: RenderRow): string {
  const blockRevisions = row.items.map(item => {
    const revision = this.blockRefreshRevisions.get(item.block.id) ?? 0;
    return `${item.block.id}:${revision}`;
  }).join('|');
  return `${row.getKey(this.renderRevision)}:${blockRevisions}`;
}

强制 remount 时,只有包含目标块的行重建,其他行纹丝不动。MEMORY.md 里给这条教训写了加粗警告:"不要把 refresh revision 拼进所有 row key,否则会退回整页重建闪烁。"全局版本号留给结构性变更,行级版本号留给顽固的属性性变更,粒度必须与变更范围对齐。

六、@Builder 的墙与组件拆分的账本

渲染层的另一个 ArkUI 硬约束:@Builder 方法不能从外部文件导入,必须长在所属 @Component 内部。当一个组件的 build() 里挤了四种互不相关的渲染路径(当时的 PlaygroundBlock.ets 有 1451 行,混着 Divider、Card、Image、Editable 四条路径),这个约束看似把路堵死了——其实只是把"抽函数"换成了"抽组件":

  • 每条独立渲染路径提取为独立 @Component(独立 .ets 文件);
  • @Prop 传数据、回调传事件;
  • 原组件退化为分发器,只保留共享状态与强耦合逻辑(Editable 路径因与 RichEditorController 强耦合,留在原组件内);
  • 组件文件放到对应块/插件目录,代码与所有权 co-location——ImageBlockRenderer.ets 就住在 plugins/official/image/ 里。

拆分账本:PlaygroundBlock.ets 从 1451 行降到约 1070 行,同时产出 ImageBlockRenderer(约 195 行)、CardBlockRenderer(约 248 行)、DividerBlockRenderer(约 72 行)三个自治组件。想改图片渲染(比如加点击预览)的人,不再需要在千行文件里寻宝。

七、从 Playground 到正式 Renderer

最后把边界再交代一遍:M5 里程碑(正式渲染器)未启动,renderer/ 目录下除注册表与 diff 外还是骨架,本篇的一切分发与刷新逻辑当前由 Playground 演示层承担。但 Playground 不是一次性脚手架——它沉淀的每一样东西都是正式 Renderer 的直接输入:

Playground 沉淀 M5 正式 Renderer 的对应物
RenderCategory 注册表(已就位,插件可注册) 渲染分发核心
RendererDiff 六列表(事务管道已生产) 增量更新的驱动信号
结构性/属性性双通道通知 通知协议的正式化
per-block 行级刷新 虚拟化渲染的前置技术
RichEditor 组件生命周期管理 M6 同步协议的一部分

大文档的渲染虚拟化(Open Risks 里列的 Medium 优先级风险)也会建立在这套"精确受影响集合 + 行级粒度"的地基上。

八、小结

  1. 注册表分发:四类渲染机制收敛了无限多的块类型;静态编译平台注册的是"分类"而非"组件";缺省 editable 保证新类型即插即渲染。
  2. diff 是副产品不是反推:六列表 + 互斥契约,事务管道保证完整性,渲染层只消费精确受影响集合。
  3. ForEach 双根因:key 全局化 → 全量重建闪烁;key 稳定 → @Prop 更新不可靠。四轮失败尝试证明"重建派"与"复用派"单走皆死。
  4. 双通道是唯一出路:结构性变更走重建(身份位置变了,重建才不携带陈旧状态),属性性变更走本地状态即时响应 + 引擎异步记账;判断标准是"身份与位置变没变"。
  5. 粒度对齐:全局 revision 只配结构性变更;顽固的属性性变更用行级 revision 兜底——粒度必须与变更范围一致。
  6. @Builder 不能跨文件:抽函数换抽组件,@Prop 进、回调出、原组件变分发器。

下一篇我们进入文本块渲染的心脏——RichEditor。那里有一组更凶险的同步问题:屏幕上的文本和引擎里的 Block 模型,凭什么保证是同一份?

下一篇:《鸿蒙编辑器框架的 RichEditor 集成:真机才教会我们的文本同步》——onDidChange 主路径与 IME-complete 补充路径的分工、Enter 拆分前的强制全量同步、isApplyingEngineUpdate 守卫,以及四个只有真机能暴露的根因。

Logo

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

更多推荐