一、两份真相的问题

任何"所见即所得"的编辑器都逃不开这个结构:数据模型一份文本,渲染缓冲一份文本。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 保证闸旗在任何路径下必然复位,包括写回中途抛错。

数一数上文的代码:onWillChangeonDidChangeonIMEInputCompleteaboutToDelete 四个回调入口,每一个的第一行都在检查这面旗。闸旗的有效性不取决于"主要路径记得检查",而取决于"所有入口无一例外"。

七、规则四:结构性变更后重建组件,不信旧快照

症状二的最后一环:按 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——将来同步协议无论落在哪个控件上,骨架不变。

十、现状的诚实交代

三条必须说清的边界:

  1. 本篇全部是 Playground 集成层经验,MEMORY.md 的原话是"Playground integration lessons, not new architecture"。正式的同步协议要在 M6 以 Adapter 形式系统化设计。
  2. Open Risks 里它的优先级是 High:RichEditor 同步协议、选区 ↔ IME 交互,这两项不解决,编辑器的文本编辑就不能宣称达到产品级。
  3. 部分模式目前是"Playground 特供"currentText 缓存、payload 推送这类实现细节,正式协议可能采用完全不同的机制(比如以 RichEditor 的 span 模型为准做双向 diff)。教训是可迁移的,代码不是。

十一、小结

  1. 两份真相是常态,同步协议是生命线:控件自治 + 引擎权威,谁在何时读、谁在何时写必须制度化。
  2. 读方向三层onDidChange 主路径、IME-complete 幂等补充、结构操作前强制全量——宁可多读一次,不可基于陈旧文本做结构决策。
  3. 写方向一律上闸isApplyingEngineUpdate 覆盖全部回调入口,try/finally 必然复位——撤销永不伪装成输入。
  4. 边界处换算计量单位:引擎码点、平台码元,显式换算函数是适配层职责的一部分。
  5. 无变不记:全同跳过不是优化小技巧,是历史栈可信任的前提。

下一篇换个轻松些的主题——复制粘贴。那里的核心问题从"状态同步"变成"数据搬家":块树怎么进剪贴板、外部文本怎么进块树、以及为什么粘贴出来的每个块都必须是新身份。

下一篇:《鸿蒙编辑器框架的传输引擎:与平台剪贴板解耦的多格式复制粘贴》——TransferObject 与多格式编解码、无损 JSON 优先的读取策略、粘贴换新 ID 的身份铁律,以及纯文本降级的设计取舍。

Logo

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

更多推荐