中文输入法"丢字"之谜:鸿蒙编辑器 composing 事件桥接攻坚

所见即所得编辑器最怕的对手不是复杂排版,是中文输入法。这篇讲 MarkPin 早期最关键的一仗:让 CodeMirror 6 在鸿蒙 ArkWeb 里安全地陪伴拼音输入法工作。这个问题解决不了,产品就是一个"只能打英文的 Markdown 编辑器"。方案后来整理成了开源文档(v1.0),这篇讲它的完整来龙去脉——包括一个后来被实战推翻的设计决策。

摘要:本文记录 MarkPin 编辑器在鸿蒙 ArkWeb 中解决中文输入法"丢字"问题的完整过程。核心思路是双层防护——在 CodeMirror 6 自带的 view.composing 之外,显式监听 DOM 的 compositionstart/end 事件作为兜底,并把渲染决策留在 Web 侧、原生侧只透传不控制。文中还复盘了一个被实战推翻的设计决策:"所有插件统一冻结"在公式渲染(替换型装饰)上不成立,必须按装饰类型分语义——可见型保留旧集、替换型清空回退。最终在模拟器上实现连续输入 100 字 0 丢字 0 重排,真机清单仍在验证中。

一、问题背景:拼音上屏前的"危险期"

中文输入法的输入分两个阶段:

用户按键 → [组合输入阶段 composition] → 用户选词 → [上屏 commit]

组合输入阶段(composing)里,“nihao"这样的拼音串以预编辑文本的形式存在于文档里,随按键不断变化。这个阶段编辑器有一个铁律:不该触发渲染重排。一旦装饰层在 composing 期间刷新,轻则候选框闪烁、光标错位,重则上屏文字丢失——用户视角就是"丢字”。

标准浏览器里这不是问题:CodeMirror 6 自己监听 composition 事件、维护 view.composing 标志、自动暂停装饰渲染,Chrome/Firefox/Safari 上这套机制久经考验。但 MarkPin 的编辑器跑在鸿蒙 ArkWeb 里——基于 Chromium 内核,但 IME 事件传播与标准浏览器存在差异。实测观察到的风险点:composition 事件的传播可能延迟或丢失、composing 状态同步需要辅助确认、不同输入法行为还不一致。一旦 view.composing 不可靠,渲染层就会在组合输入期间错误刷新——这就是丢字的根源。

二、分析定位:怎么确认"内核的自动挡不可靠"

定位过程分三步,方法比结论更值得说:

  1. 事件级验证:在编辑器 DOM 上直接监听 compositionstart/end,与 CodeMirror 的 view.composing 对照。结论是 ArkWeb 下两者可能出现不同步,单靠自动挡有风险;
  2. 失败模式枚举:如果 composing 状态不可靠,最坏情况是什么?——渲染插件在组合输入中重建装饰 → 候选文本被装饰改写 → 丢字。所以要防的不是"事件丢了",是"渲染层在 composing 期间动手";
  3. 控制面设计:谁能可靠知道 composing 状态?Web 侧的 DOM 事件是第一手信号;原生侧通过 ArkTS 桥也能收到透传。二者如何分工?——这引出了方案的核心设计。

三、方案设计:双层防护,渲染决策留在 Web 侧

最终架构是双层防护

Layer 1(Web 侧):CodeMirror 自带的 view.composing 自动检测
    ↓ 如果不可靠
Layer 2(Web 侧 DOM 事件):compositionstart/end 显式监听 → isComposing 标志
    ↓ 可观测性
桥接层:状态透传给原生(EditorBridge),仅日志与联动守卫

三条设计决策,每条都有明确理由:

  • 决策一:渲染决策在 Web 侧,原生侧只透传不控制。渲染层在 Web,原生控制渲染要跨层通信,徒增延迟。原生侧收到的 composing 状态只用于日志和快捷键守卫;
  • 决策二:只听 start/end,不依赖 compositionupdate。update 在标准浏览器里每次拼音变化都触发,ArkWeb 里频率还可能不同——渲染层只需要"冻结/恢复"两个状态,中间过程无关紧要;
  • 决策三(v1.0 版):所有 Decoration 插件统一"composing 冻结 = 保留旧装饰集"。公式渲染、代码高亮等所有用 Decoration 的插件,composing 期间一律不更新。——注意这条,后面会被实战修正。

四、解决代码

4.1 事件监听与状态维护(Web 侧)

// editor-build/src/main.ts(节选,真实代码)
// FR-004:组合输入状态可观测透传(渲染决策在插件内,桥接仅日志)
// S3:同时驱动 modeController 的 composing 守卫(FR-009)
view.contentDOM.addEventListener('compositionstart', () => {
  setComposing(true);
  notifyComposition('compose-start');
});
view.contentDOM.addEventListener('compositionend', () => {
  setComposing(false);
  notifyComposition('compose-end');
  // BUG-023 修复:composition 结束后补发大纲和字数更新
  scheduleOutlineUpdate(view);
  scheduleWordCountUpdate(view);
});

注意 compositionend 里的"补发"——这是坑三(BUG-023)的修复:组合输入期间大纲/字数统计被抑制,结束瞬间必须补一次,否则刚上屏的字不会反映到大纲和状态栏。

4.2 渲染层冻结(Web 侧)

// editor-build/src/render/renderPlugin.ts(节选,真实代码)
update(update: ViewUpdate): void {
  // composing 冻结(FR-004):组合输入期间保留旧装饰集
  if (update.view.composing) {
    return;
  }
  // ...正常装饰重建
}

主渲染插件是"保留旧装饰集"语义——view.composing 为真直接返回,屏幕上的装饰保持上一帧的样子,什么都不动。

4.3 输入法与快捷键的纠缠(原生侧守卫)

输入法家族的问题不止丢字。两个连带 bug 一起修:

// entry/src/main/ets/pages/Index.ets(节选,真实代码)
this.editorBridge.onCompositionEvent = (composing: boolean) => {
  this.isComposing = composing;
  // BUG-004 修复:输入法开始时重置 Alt 键状态
  // 防止 Alt 释放事件被输入法消费后 altPressed 残留为 true
  if (composing) {
    this.altPressed = false;
    this.altDigitConsumed = false;
  }
};

现象是"Alt+数字切换视图模式时灵时不灵":Alt 的释放事件有时被输入法消费掉,原生侧的 altPressed 残留为 true,下一次 Alt 组合键判定就乱了。修法是把 composing 状态当成"输入法正在接管"的信号,进入组合输入时主动清空快捷键的中间状态。Web 侧同样加守卫:

// editor-build/src/mode/modeController.ts(节选,真实代码)
export function handleAltKey(e: KeyboardEvent, direct: ViewMode | undefined): void {
  if (e.repeat) return;
  if (mainView !== undefined && (mainView.composing || isComposing)) {
    return; // composing 守卫:组合输入期间忽略且不 preventDefault(不干扰上屏)
  }
  // ...150ms 去重 + 模式切换
}

组合输入期间按 Alt 键直接忽略、也不 preventDefault——防止抢走本该给输入法的事件。

五、实战演进:决策三被推翻的那天

方案上线后安稳了一阵,直到 2026-08-29 的一轮渲染崩溃:输入中文时公式渲染管线整个中断,报错是 CodeMirror 的 “Decorations that replace line breaks may not be specified via plugins” 和 “Index out of range”。

定位结论很有意思:"composing 冻结 = 保留旧装饰集"在公式渲染上不成立。KaTeX 公式用的是 Decoration.replace(把 LaTeX 源文本整段替换成渲染结果),而 IME 的组合输入会把文档按分片改写——旧装饰集记录的位置经过变更映射(map)后可能越界、可能跨越了行边界,CM6 直接抛异常,且该会话的渲染管线持续瘫痪。

修正后的语义:公式渲染在 composing 期间不是"保留旧装饰",而是"清空替换型装饰"——公式位置回显 $$...$$ 源文本(与所见即所得的活动行回显恰好同一语义),组合结束后全量重建:

// editor-build/src/render/mathRender.ts(节选,真实代码含演进注释)
update(update: ViewUpdate): void {
  // P1-3(2026-08-29):原 composing 冻结(保留旧装饰集)在 IME 分片
  // 改写 doc 后,旧 replace 装饰被 map 可越界/跨行——抛
  // "Decorations that replace line breaks may not be specified via
  // plugins" / "Index out of range",该会话渲染管线持续中断。
  // 改为组合期间清空 replace 装饰(回显 $$ LaTeX 源文本,与活动行
  // 回显语义一致),composition 结束后的 update 全量重建恢复。
  if (update.view.composing) {
    this.decorations = Decoration.none;
    return;
  }
  // P1-3 兜底:装饰构建异常时回退空集保活(管线不中断)
  try {
    this.decorations = buildMathDecorations(update.state, mirror, update.view.visibleRanges);
  } catch (e) { /* 回退空集 */ }
}

这轮演进给开源文档补上了最重要的一课:"冻结"不是统一答案,要按装饰类型分语义——可见性装饰(隐没语法符号)可以保留旧集,替换型装饰(整段替换)必须清空回退。设计文档写的"所有插件统一冻结",在 IME 分片改写面前是错的。

六、验证与效果

验证分两层:

  • 模拟器(自动化):连续输入 100 字 0 丢字 0 重排;组合输入期间渲染层不刷新;上屏后渲染恢复;连续输入视口 0 跳动;另覆盖组合中切模式/关标签/粘贴等边界。S2 阶段验收通过;
  • 真机(人工):清单在册未关闭——搜狗与系统输入法各 100 字、中英混输、万字文档末尾输入、公式编辑中打中文,共 5 项。模拟器的 IME 环境与真机不一致(实测连自动化注入都会被中文态污染),输入法类问题的最终裁判是真机,这个清单跑完我会回来续写。

顺带一提模拟器验证的坑:模拟器里中文 IME 态会污染按键注入(>»、Tab 被候选补全劫持),所以 composing 类问题连"自动化取证"都要绕行——这也是把它列入真机清单的原因。

七、能力边界表

事项AI 表现我的结论
双层防护架构设计方案成型快,决策理由充分"渲染决策留 Web 侧、原生只透传"是正确分层
"所有插件统一冻结"的泛化被实战推翻按装饰类型分语义:可见型保留、替换型清空——泛化设计要过真实场景
连带问题发现(Alt 残留/大纲抖动)同族问题一并定位输入法问题是个家族,修丢字时顺手盘点周边
模拟器验证部分可信IME 行为模拟器与真机不一致,最终裁判是真机

八、三条心得

  1. IME 是编辑器的"最坏邻居":它会在任何时刻改写你的文档、消费你的按键。所有跨层状态(快捷键、统计、渲染)都要回答"composing 期间我该怎么办";
  2. 防御性设计的"泛化"要打问号:统一规则很优雅,但不同装饰类型的失败模式不同,实战会逼你拆开;
  3. 平台差异靠对照实验确认:标准浏览器的机制不能假设在 ArkWeb 成立,事件级对照验证是唯一途径。

如果你在做编辑器或输入法相关工作,或者想看 MarkPin 后续,关注专栏。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐