鸿蒙编辑器框架的渲染层:注册表分发、局部刷新与 ForEach 的坑
一、注册表分发:一行 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 的子集:只有行内内容变
}
契约的三条规则值得原文引用:
- 一个块在一次事务中最多出现在一个列表里——插入即隐含内容(
insertedIds不需要再进updatedIds); propsChangedIds/contentChangedIds是updatedIds的子集,专门为"只刷属性不刷内容"的细粒度优化而设;- 渲染层拿到的永远是精确受影响集合,不需要自己反推。
回忆第 03 篇的执行器代码:insertBlock 时 collectDescendantIds 把整棵子树收进 insertedIds;mergeBlock 一次产生 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 优先级风险)也会建立在这套"精确受影响集合 + 行级粒度"的地基上。
八、小结
- 注册表分发:四类渲染机制收敛了无限多的块类型;静态编译平台注册的是"分类"而非"组件";缺省
editable保证新类型即插即渲染。 - diff 是副产品不是反推:六列表 + 互斥契约,事务管道保证完整性,渲染层只消费精确受影响集合。
- ForEach 双根因:key 全局化 → 全量重建闪烁;key 稳定 → @Prop 更新不可靠。四轮失败尝试证明"重建派"与"复用派"单走皆死。
- 双通道是唯一出路:结构性变更走重建(身份位置变了,重建才不携带陈旧状态),属性性变更走本地状态即时响应 + 引擎异步记账;判断标准是"身份与位置变没变"。
- 粒度对齐:全局 revision 只配结构性变更;顽固的属性性变更用行级 revision 兜底——粒度必须与变更范围一致。
- @Builder 不能跨文件:抽函数换抽组件,
@Prop进、回调出、原组件变分发器。
下一篇我们进入文本块渲染的心脏——RichEditor。那里有一组更凶险的同步问题:屏幕上的文本和引擎里的 Block 模型,凭什么保证是同一份?
下一篇:《鸿蒙编辑器框架的 RichEditor 集成:真机才教会我们的文本同步》——
onDidChange主路径与 IME-complete 补充路径的分工、Enter 拆分前的强制全量同步、isApplyingEngineUpdate守卫,以及四个只有真机能暴露的根因。
更多推荐





所有评论(0)