鸿蒙 ArkWeb 中文输入法丢字攻坚:CodeMirror 6 composing 事件桥接方案与实战演进
文章目录
中文输入法"丢字"之谜:鸿蒙编辑器 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 不可靠,渲染层就会在组合输入期间错误刷新——这就是丢字的根源。
二、分析定位:怎么确认"内核的自动挡不可靠"
定位过程分三步,方法比结论更值得说:
- 事件级验证:在编辑器 DOM 上直接监听 compositionstart/end,与 CodeMirror 的
view.composing对照。结论是 ArkWeb 下两者可能出现不同步,单靠自动挡有风险; - 失败模式枚举:如果 composing 状态不可靠,最坏情况是什么?——渲染插件在组合输入中重建装饰 → 候选文本被装饰改写 → 丢字。所以要防的不是"事件丢了",是"渲染层在 composing 期间动手";
- 控制面设计:谁能可靠知道 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 行为模拟器与真机不一致,最终裁判是真机 |
八、三条心得
- IME 是编辑器的"最坏邻居":它会在任何时刻改写你的文档、消费你的按键。所有跨层状态(快捷键、统计、渲染)都要回答"composing 期间我该怎么办";
- 防御性设计的"泛化"要打问号:统一规则很优雅,但不同装饰类型的失败模式不同,实战会逼你拆开;
- 平台差异靠对照实验确认:标准浏览器的机制不能假设在 ArkWeb 成立,事件级对照验证是唯一途径。
如果你在做编辑器或输入法相关工作,或者想看 MarkPin 后续,关注专栏。
更多推荐

所有评论(0)