鸿蒙编辑器框架的 RichEditor 集成:真机才教会我们的文本同步
一、两份真相的问题
任何"所见即所得"的编辑器都逃不开这个结构:数据模型一份文本,渲染缓冲一份文本。Web 编辑器里这个问题相对温和——contenteditable 的 DOM 就是唯一缓冲,浏览器替你维护光标与选区,模型同步慢半拍顶多是性能问题。RichEditor 集成的凶险在于:RichEditor 是一个自治的输入控件,它有自己的缓冲、自己的 IME 交互节奏、自己的回调时机,而引擎(Block 模型)对这一切没有感知权,只有事件通知。
于是同步问题变成两个方向的四道题:
- 读方向(控件 → 引擎):控件内容变了,引擎什么时候知道?以什么为准?
- 写方向(引擎 → 控件):引擎变了(撤销、拆分、AI 写入),怎么安全地回放到控件,且不被误认为新的用户输入?
先交代定位:正式的 RichEditor 同步协议是 M6 里程碑的工作,也是项目 Open Risks 里最高优先级的一项(另一个是选区 ↔ IME 交互)。本篇的所有结论来自 Playground 集成层的真机调试——它们是未来协议的设计输入,不是协议本身。这个边界 PROJECT_STATUS 和 MEMORY.md 都写得很清楚,本篇照实转述。
二、两个症状,四条根因
PR-0004 给 Playground 加上撤销/重做后,真机上出现两个看似荒谬的症状:
症状一:打了字再按 Undo,前几下毫无反应,然后某一下把所有文字一次性删光。 症状二:按键盘 Enter,整页文本突然消失。
荒谬感来自"症状与原因隔了三层":按 Undo 的问题是读方向的——引擎从没收到你打的字(根因一),Undo 栈里没有这些输入的记录,按一下就回退一步"引擎以为的状态",而你看到的屏幕纹丝不动;退到最后一步结构操作时(比如那次拆分),屏幕内容瞬间被引擎状态覆盖——看起来就是"突然全删了"。按 Enter 的问题则是读写两个方向的共谋:拆分命令带着过期的缓存文本出发(根因二),引擎正确地把缓存里的空文本拆给了前块,而复用的组件又回放了过期的空快照(根因四)——引擎没算错,屏幕被两次"旧数据"夹击后清空。
四个根因各自对应一条修复规则,下面逐条拆。
三、规则一:onDidChange 是主路径,onIMEInputComplete 只是补充
直觉的实现是监听 onIMEInputComplete——名字看起来就是"输入完成"的官方通知。真机给出的教训:可见的 RichEditor 内容可能先于引擎收到可靠的 IME-complete 通知而发生变化。以它为唯一同步源,引擎就会系统性地落后于屏幕——这正是症状一的温床。
修复后的双路径分工(真实代码,摘自 PlaygroundBlock.ets):
.onDidChange((rangeBefore: TextRange, rangeAfter: TextRange) => {
if (!this.isApplyingEngineUpdate) { // 引擎回放期间不采集(规则三)
this.syncEditorText(); // ★ 主同步路径
this.checkSelection();
}
})
.onIMEInputComplete((span: RichEditorTextSpanResult) => {
if (this.isApplyingEngineUpdate) {
return;
}
this.syncEditorText(); // 补充路径:幂等,同步同一份
})
两条路径指向同一个 syncEditorText()——这个幂等性是设计出来的:补充路径的价值不在于"多一次机会",而在于覆盖 onDidChange 可能漏掉的 IME 组合输入收尾时刻。同步函数本身有防御(同样真实代码):
private syncEditorText(): string {
const text = this.extractEditorText(); // 从控件读
if (text.length === 0 && this.currentText.length > 0) {
return this.currentText; // 读到空但缓存非空:可能是时序毛刺,保守保留
}
this.currentText = text;
if (!this.isApplyingEngineUpdate) {
... // 通知引擎(文本或带样式内容),并检查斜杠菜单触发
}
return text;
}
注意那个"读到空但缓存非空就保留缓存"的防御——控件在某些瞬时状态下 getSpans() 会给出空结果,直接采信会制造一次幽灵删除。同步层的第一美德是保守:不确定时不动。
四、规则二:结构操作前,从控件读一次全量
Enter 的处理路径(onWillChange 拦截)藏着本篇最重要的时序纪律:
.onWillChange((value: RichEditorChangeValue) => {
if (this.isApplyingEngineUpdate) {
return true; // 引擎回放:放行,不拦截
}
const spans = value.replacedSpans;
if (spans !== undefined && spans !== null) {
for (const span of spans) {
if (span.value !== undefined && span.value.includes('\n')) {
const text = this.syncEditorText(); // ★ 先强制读一次控件全量
const rawOffset = this.richEditorController.getCaretOffset();
const offset = codeUnitOffsetToCodePointOffset(text, rawOffset); // 码元→码点
this.onEnterPress(this.block.id, text, offset); // 带着新鲜文本拆分
return false; // 拦截默认换行插入
}
}
}
return true;
})
为什么必须先 syncEditorText()?因为缓存的 this.currentText 可能落后于可见缓冲——上一条 onDidChange 还在路上,Enter 已经按下了。拆分命令一旦带着过期文本出发,引擎会忠实地把错误的旧文本拆成两半,屏幕与引擎从此各说各话。splitBlock 之前的那一次全量读取,成本是几个 span 的拼接,换来的是"结构操作永远基于控件此刻的真实内容"。
顺带注意这里的时序是"控件内先发生、引擎后追赶"的必然结果:结构操作的发起方必须自己负责把读方向推进到最新,不能假设常规同步已经到位。
五、插入一段:码点与码元的换算
上面代码里那行 codeUnitOffsetToCodePointOffset(text, rawOffset) 值得单独一节。第 03 篇讲过,引擎的所有文本偏移按 Unicode 码点计(一个 emoji 算一个偏移);而 RichEditor 的 getCaretOffset() 等平台 API 按 UTF-16 码元计(一个 emoji 占两个偏移)。两个世界各说各话,光标位置必须显式换算:
- 控件 → 引擎(读方向):
codeUnitOffsetToCodePointOffset - 引擎 → 控件(写方向):
codePointOffsetToCodeUnitOffset(样式区间的写回也用它)
这个细节是平台边界的微观样本:**适配层翻译的不只是事件语义,还有计量单位。**忘了换算的 bug 极难在纯英文测试文本上暴露(码点数 = 码元数),一上中文 emoji 输入法立刻光标错乱。第 03 篇说"码点计数从根上避免光标错位"——现在补全了后半句:引擎内码点、平台接口码元,边界处显式换算,两头都别让步。
六、规则三:引擎回放必须上闸——isApplyingEngineUpdate
写方向的死结:撤销/重做要把引擎状态写回控件,而写回动作会触发控件自己的 change 回调;回调里若执行"采集文本同步给引擎",这次撤销就被当成了新的用户输入——撤销本身产生新历史,用户看到的就是"Undo 像是失效了",因为每按一次,系统都在忙着撤销你上一次撤销。
修复是一面闸旗,所有引擎驱动的写回都必须在旗下进行(真实代码):
onBlockUpdated(_propName: string): void {
if (!this.isReady) return;
this.localSelected = this.isSelected;
this.localInlineContent = undefined;
this.isApplyingEngineUpdate = true; // ★ 拉闸
try {
if (this.hasStyledContent()) {
this.applyStyledContent(this.block.content!);
} else {
const engineText = this.getBlockText();
if (engineText === this.currentText) {
return; // 引擎与控件已一致:跳过(见第八节)
}
this.applyEditorText(engineText);
}
} finally {
this.isApplyingEngineUpdate = false; // ★ 任何路径都放闸
}
}
呼应第 04 篇:HistoryStack 里有 _isUndoRedoInProgress 守卫防"撤销记入历史",这里有 isApplyingEngineUpdate 防"回放记为输入"——同一个自指问题在两层各设一道闸。也呼应第 08 篇的守卫纪律:try/finally 保证闸旗在任何路径下必然复位,包括写回中途抛错。
数一数上文的代码:onWillChange、onDidChange、onIMEInputComplete、aboutToDelete 四个回调入口,每一个的第一行都在检查这面旗。闸旗的有效性不取决于"主要路径记得检查",而取决于"所有入口无一例外"。
七、规则四:结构性变更后重建组件,不信旧快照
症状二的最后一环:按 block.id 复用 RichEditor 组件时,组件可能还持有过期的 @Prop block 快照。实测的失败链条是——引擎已正确把文本拆给前块,而复用的旧组件把快照里的空内容回放出来,可见文本被清空。引擎无辜,屏幕死于组件的"记忆"。
修复直接采用第 08 篇的双通道结论:结构性变更(拆分、合并、撤销、加载)递增 renderRevision,让 ForEach key 变化、组件重建,新组件从引擎最新快照初始化——绝不信任旧组件内存里的东西。这与"属性性变更走复用 + 本地状态"互为补集:重建有重建的成本,复用有复用的风险,分界线依旧是一条——身份与位置变没变。
八、第四个细节:no-op 也要拦
规则一的代码里有一处容易读漏的防御:
const engineText = this.getBlockText();
if (engineText === this.currentText) {
return; // 引擎已有同样的文本:不写回、不同步、不开事务
}
以及 MEMORY.md 记录的姊妹规则:"当引擎已拥有相同文本时跳过 syncText 事务,避免 no-op 历史条目。"——同步是高频动作,回车、焦点切换、样式刷新都可能触发一次"其实什么都没变"的同步;不拦住,历史栈里会积累一堆空转条目,用户的 Undo 要连按 N 次才"有感"。第 07 篇属性更新里的"全同则不开事务"、第 09 篇这里的"全同则不写回",是同一条纪律的两个现场:变更必须值得记录,不值得记录的连事务都不许开。
九、统一的心智模型
把四条规则收进一张 2×2,整个同步协议的骨架就浮现了:
| 控件 → 引擎(读) | 引擎 → 控件(写) | |
|---|---|---|
| 常规 | onDidChange 主路径 + onIMEInputComplete 幂等补充(规则一) |
引擎回放全部在 isApplyingEngineUpdate 闸下(规则三) |
| 结构操作 | 拆分/合并前强制读控件全量(规则二) | revision 递增强制重建组件(规则四) |
| 护栏 | 读到空且缓存非空 → 保守保留 | 全同 → no-op 跳过;单位换算在边界显式完成 |
再压成三句话:读要分层(高频轻量 + 低频兜底 + 结构前强制)、写要上闸(引擎回放永不伪装成用户输入)、粒度要对(结构重建、属性复用、无变不记)。这三句话不依赖 RichEditor 的任何具体 API——将来同步协议无论落在哪个控件上,骨架不变。
十、现状的诚实交代
三条必须说清的边界:
- 本篇全部是 Playground 集成层经验,MEMORY.md 的原话是"Playground integration lessons, not new architecture"。正式的同步协议要在 M6 以 Adapter 形式系统化设计。
- Open Risks 里它的优先级是 High:RichEditor 同步协议、选区 ↔ IME 交互,这两项不解决,编辑器的文本编辑就不能宣称达到产品级。
- 部分模式目前是"Playground 特供":
currentText缓存、payload 推送这类实现细节,正式协议可能采用完全不同的机制(比如以 RichEditor 的 span 模型为准做双向 diff)。教训是可迁移的,代码不是。
十一、小结
- 两份真相是常态,同步协议是生命线:控件自治 + 引擎权威,谁在何时读、谁在何时写必须制度化。
- 读方向三层:
onDidChange主路径、IME-complete 幂等补充、结构操作前强制全量——宁可多读一次,不可基于陈旧文本做结构决策。 - 写方向一律上闸:
isApplyingEngineUpdate覆盖全部回调入口,try/finally 必然复位——撤销永不伪装成输入。 - 边界处换算计量单位:引擎码点、平台码元,显式换算函数是适配层职责的一部分。
- 无变不记:全同跳过不是优化小技巧,是历史栈可信任的前提。
下一篇换个轻松些的主题——复制粘贴。那里的核心问题从"状态同步"变成"数据搬家":块树怎么进剪贴板、外部文本怎么进块树、以及为什么粘贴出来的每个块都必须是新身份。
下一篇:《鸿蒙编辑器框架的传输引擎:与平台剪贴板解耦的多格式复制粘贴》——TransferObject 与多格式编解码、无损 JSON 优先的读取策略、粘贴换新 ID 的身份铁律,以及纯文本降级的设计取舍。
更多推荐





所有评论(0)