输入法处理杂谈------Windows 下的 IMM32 输入法处理机制和Chrome如何桥接TSF输入法
在 Windows 平台谈输入法,绕不开两个名字:IMM32 和 TSF 。TSF 是更现代的文本服务框架,但在大量桌面程序、老控件、乃至系统组件中,IMM32 依然真实存在,并且每天都在被调用。
如果你写的是标准 Edit / RichEdit,IMM32 对你几乎是透明的,但一旦你开始写自定义编辑控件、游戏 UI、渲染引擎内文本系统 ,IMM32 就会从"你不用关心的系统组件",变成必须正面理解的存在。
一、IMM32 到底在输入链的哪一层?
先从最底层说起。硬件键盘产生中断后,事件进入 Windows 内核,再被送入用户态消息队列 。对普通键盘输入来说,流程到这里就已经结束了:WM_KEYDOWN → TranslateMessage → WM_CHAR → 窗口过程。但输入法并不是"字符生成器",而是一个状态机 + UI 系统。
IMM32 就插在这里:当系统检测到当前键盘布局是 IME 时,按键不会直接变成字符 ,而是被 IMM32 截获,并交给对应的 IME DLL 处理。一个容易被忽略、但非常关键的事实是:
IME 是 DLL,直接加载进你的应用进程中运行。
这意味着:
- IME 代码与你的程序在同一地址空间
- 任何回调、通知、重入,都是"真·进程内行为"
- 稳定性、安全性、线程问题都不是抽象概念
IMM32 的职责,是在应用窗口线程中 协调 IME 的运行,并最终通过 WM_IME_ 消息*把结果"翻译"给应用。
二、HIMC:一切输入法状态的载体
理解 IMM32,必须先接受一个核心对象:HIMC(Input Method Context)。
HIMC 并不是某条消息,而是一个状态容器,里面保存着:
- 当前 composition string(未确认文本)
- result string(已确认文本)
- 候选列表信息
- 光标位置、clause 分段
- composition / candidate window 的布局信息
应用通过 ImmGetContext(hwnd) 获取 HIMC,通过 ImmReleaseContext 释放。**几乎所有 IMM32 API,最终都是在操作 HIMC 里的状态。**这也是为什么很多新手代码会"看起来能跑,但行为诡异"------你读的是消息,但真正的数据在 HIMC 里。
三、什么是 Composition?为什么它不是"临时字符串"那么简单?
在输入法语境里,composition 指的是------用户正在输入,但尚未最终确认的那段文本状态。拼音输入时的拼音串、日文假名转汉字前的状态,都是 composition。而关键在于:composition 不是一次性的,而是一个不断更新的过程。在这个过程中,IME 会反复修改:
- composition string
- 光标位置
- clause 划分
- 候选状态
而 IMM32 的做法是:每一次变化,都通过 WM_IME_COMPOSITION 通知应用。
四、WM_IME_*:不是事件列表,而是一条时间线
很多资料把 WM_IME_* 罗列成一堆消息,但那样很难真正用对。更合理的方式是,把它们当成一段时间内的连续信号。
组合开始:WM_IME_STARTCOMPOSITION
当用户第一次触发输入法组合(比如敲下第一个拼音字母),系统会发:
WM_IME_STARTCOMPOSITION
这不是"有字符了",而是一个生命周期信号:
从现在开始,输入法进入 composition 模式。
这一步通常是你设置 composition 窗口位置的最佳时机:
c
ImmSetCompositionWindow(hIMC, &cf);
ImmSetCompositionFont(hIMC, &lf);
注意:
这只是"建议",IME 不一定必须听你的。
组合更新:WM_IME_COMPOSITION(最重要)
真正的核心在这里。
WM_IME_COMPOSITION 可能会被发送很多次,而且每一次携带的信息都不同 。你不能假设"一次消息 = 一次状态"。是否有内容,要看 lParam 中的 GCS_* 标志。
- 如果包含
GCS_COMPSTR
说明当前有未确认的 composition string,可以用ImmGetCompositionString读出来,用于临时显示。 - 如果包含
GCS_RESULTSTR
说明有已确认的文本,你应该立刻把它插入到自己的文本缓冲中。
一个非常重要的经验是:
不要假设 result 一定发生在 END 之后。
很多 IME 会在 WM_IME_COMPOSITION 中就发送 GCS_RESULTSTR,
然后再补一个 WM_IME_ENDCOMPOSITION 作为结束通知。
3️⃣ 组合结束:WM_IME_ENDCOMPOSITION
当 IME 认为这轮输入结束时,会发送:WM_IME_ENDCOMPOSITION
这通常意味着:
- composition 生命周期结束
- 你可以清理临时 UI 状态
但要注意:这里不一定还有结果字符串 。结果可能已经在之前的 WM_IME_COMPOSITION 中给过你了。
五、候选窗口:IME 的 UI,不是你的控件
候选窗口的出现与变化,并不通过 composition 消息传递,而是:
WM_IME_NOTIFY + IMN_* 通知
这些通知告诉你:
- 候选窗口打开了
- 内容变了
- 关闭了
- IME 希望你提供一个更合理的位置
在大多数情况下,候选窗口由 IME 自己绘制,你只需要在合适的时候调用:
c
ImmSetCandidateWindow(hIMC, &cf);
把"插入点在屏幕上的位置"告诉 IME。只有在极端定制 UI (比如完全自绘候选列表)时,才需要去 ImmGetCandidateList 读取候选内容。
六、一个完整输入周期,实际上发生了什么?
把上面这些串起来,一次典型的输入过程是这样的:
- 用户按键
- IME 截获并进入组合
- 系统发送
WM_IME_STARTCOMPOSITION - 多次
WM_IME_COMPOSITION(更新拼音、光标、候选) - 某次
WM_IME_COMPOSITION携带GCS_RESULTSTR - 应用插入最终文本
WM_IME_ENDCOMPOSITION收尾
不同 IME 在细节上会有差异,但这个生命周期几乎是通用的。
七、ImmGetCompositionString:最容易踩坑的 API
所有 composition / result 数据,最终都靠:
c
ImmGetCompositionString(hIMC, index, buffer, size);
这里最常见的坑有三个:
第一,Unicode 字节数问题 。
返回值是字节数,不是字符数,W 版本下要记得除以 sizeof(wchar_t)。
第二,CursorPos 的语义 。
GCS_CURSORPOS 返回的是"在 composition 中的位置",不是全局文本光标。
第三,调用时机 。
只能在对应的消息处理中调用,离开消息后 HIMC 状态可能已经变化。
八、自定义控件为什么"IME 特别容易出 bug"?
因为标准控件已经帮你做了这些事:
- 管理 HIMC
- 正确响应所有 WM_IME_* 消息
- 处理不同 IME 的行为差异
而自定义控件必须自己承担完整生命周期管理。
这意味着你至少要正确处理:
- START / COMPOSITION / END 的状态同步
- result 与 composition 的区分
- 光标位置 → 屏幕坐标的映射
- 不同 IME 的消息顺序差异
很多所谓的"输入法 bug",其实不是 IME 的问题,而是:
应用假设了一个并不存在的消息顺序。
九、IMM32 与 TSF:现实世界的过渡态
最后必须说一句现实情况。
IMM32 是旧架构,TSF 是推荐方案,但:
- Windows 内部仍然维护 IMM32 兼容层
- 大量 IME 同时支持 TSF 与 IMM32
- 很多程序(包括 Chrome)两套机制同时存在
所以理解 IMM32 的价值不在于"继续使用它",而在于:
你能看懂 Windows 输入法在干什么。
而这,正是调试、兼容、迁移到 TSF 的基础。
Chrome 在 Windows 下是如何接入 TSF 输入法的
笔者给一个软件排查输入法的问题,这里是特别整理的。
可能很少人会注意到------
- 输入法不是"发字符"那么简单
- 一旦涉及 composition、候选窗、光标定位,事情会迅速失控
Chrome 在 Windows 下选择了 TSF(Text Services Framework) 作为主要输入法路径,而不是传统的 IMM32。TSF 是一套 以文档和编辑会话为中心 的 COM 框架,这也直接决定了 Chrome 的整体设计形态。
所以,我们打算讨论的是三个事情:
- Chrome 是如何接收和处理 TSF 输入法事件的
- Chrome 是如何让一个控件"能用输入法"的
- 为什么源码里会出现这么多
Bridge / Store / Router,而不是一个简单的OnImeComposition
TSF 在 Chrome 里长什么样?
如果你习惯 IMM32 的思路(WM_IME_COMPOSITION / WM_IME_ENDCOMPOSITION),那先把这个模型放一边。
在 TSF 模型下:
输入法不是"给窗口发消息",
而是对一个"可编辑文档"发起编辑会话(edit session)。
Chrome 为了适配这个模型,把职责拆成了几块:
- InputMethodWinTSF
Windows 平台的"输入法总控",负责焦点变化、生命周期,以及和 Aura/Views 的对接。 - TSFBridge(线程局部单例)
负责管理ITfThreadMgr、TSF Document / Context、以及焦点切换时的各种顺序问题。 - TSFTextStore
核心角色,实现ITextStoreACP,把 TSF 的编辑请求映射成 Chromium 内部的TextInputClient调用。 - TSFEventRouter
用来监听候选窗、composition 起止等"状态型事件",让上层知道 IME 当前在干嘛。
一句话总结就是:
TSF → TSFTextStore → TextInputClient → Blink / 控件
二、Chrome 不是"收输入法事件",而是"提供一个文档"
这是理解 TSF 的关键转折点。
在 TSF 模型里,IME 关心的不是窗口,而是:
- 这个文档现在有哪些文本?
- 光标在哪?
- selection 是什么?
- 我能不能锁住它,改一段内容?
所以 Chrome 必须"伪装"成一个 TSF 能理解的 可编辑文档。
这个文档,就是 TSFTextStore。
三、TSFTextStore:IME 眼里的"编辑器内核"
TSFTextStore 实现了 ITextStoreACP,这意味着:
- TSF 会对它 RequestLock
- 在 lock 期间调用:
SetSelectionSetTextInsertTextAtSelection
- edit session 结束后,Chrome 再把结果同步给真正的控件
1️⃣ RequestLock 是一切的起点
cpp
TSFTextStore::RequestLock(...)
TSF 会请求 READ / READWRITE lock。拿到 lock 之后:
- TSF 同步地 在这个 session 内修改文本
- Chrome 只能在 lock 内更新自己的缓存
- 不能立刻把变化同步给 Blink
这是 TSF 的硬约束。
所以 TSFTextStore 内部维护了一套非常"工程味"的状态缓存:
- 文档 buffer(
string_buffer_document_) - selection 缓存
- composition range
- 本次 edit session 中产生的待提交文本
等 edit session 结束,再一次性把变化映射到 TextInputClient。
四、Composition 在 Chrome 里是怎么"活过来"的?
在 TSF 世界里,composition 并不是一个明确的消息,而是一种状态 。Chrome 是这样识别它的:通过 ITfTextEditSink::OnEndEdit,然后枚举当前 context 里的 composition range,最后看看对比"上一次是否有 composition"。基于此,我们得到了一个状态机:
- 上一帧没有,这一帧有 → StartComposition
- 文本发生变化 → UpdateComposition
- 上一帧有,这一帧没了 → EndComposition
真正同步到控件时,对应的就是:
TextInputClient::SetCompositionTextTextInputClient::InsertTextTextInputClient::ClearCompositionText
所以你在 Blink / JS 里看到的 composition 行为,其实是 TSFTextStore 在 edit session 结束时"翻译"出来的结果。
五、候选窗不是消息,而是 UI Element
TSF 下,候选窗不再是一个 WM_IME_* 消息,而是一个 UI Element。
TSFEventRouter 监听的是:
ITfUIElementSink::BeginUIElementITfUIElementSink::EndUIElement
如果发现是 ITfCandidateListUIElement,就标记:
"候选窗打开了 / 关闭了"
InputMethodWinTSF 再把这个状态暴露成 IsCandidatePopupOpen(),给上层逻辑用(比如避免误处理键盘事件)。
六、那控件是怎么"接入输入法"的?
这是很多人最关心、也最容易误解的一点。
在 Chrome 里,不存在一个简单的 EnableIME(true)。
控件要能用输入法,必须满足三件事:
1️⃣ 它实现了 TextInputClient
这是一套 Chromium 内部的抽象,提供:
- 文本 / selection 的读写
- composition 的设置 / 清除
- 光标、文本范围的屏幕坐标
2️⃣ 焦点变化时,InputMethodWinTSF 介入
当 focused client 改变:
cpp
InputMethodWinTSF::OnDidChangeFocusedClient(...)
会发生三件事:
- 把这个
TextInputClient注入给所有TSFTextStore - 根据
TextInputType切换 TSF Document - 强制刷新 caret / layout
3️⃣ 按 TextInputType 切 Document(非常关键)
Chrome 不是在一个 TSF context 里动态改属性,而是:
每种 TextInputType 都有一个独立的 TSF Document
甚至连 PASSWORD 都是一个特殊 document,只不过:
- 在 context 里设置了
GUID_COMPARTMENT_KEYBOARD_DISABLED - 等价于"告诉 TSF:这里不该启用 IME"
为什么要这么做?
源码里的注释说得很直白:
有些 IME 只有在 document focus 切换时 才会真正改变状态。
这是典型的"为了兼容现实世界的 IME 行为而牺牲优雅性"的工程设计。
七、光标位置为什么这么麻烦?
IME 要正确摆放候选窗,必须知道:
- 控件在屏幕上的矩形
- composing 文本的屏幕范围
于是 TSFTextStore 实现了:
GetScreenExtGetTextExt
并且:
- 处理 DIP → Screen 的 DPI 转换
- 在拿不到逐字符 bounds 时 fallback 到 caret bounds
(为 Flash / 特殊控件兜底)
你看到的候选窗"刚好贴着光标",背后其实是这一整套几何计算。
八、为什么 Chrome 还留着 IMM32?
在 input_method_win_imm32.cc 里你还能看到完整的 IMM32 实现。
原因很现实:
- 仍有少量 TSF-unaware 的场景
- 一些老 IME / 特殊路径仍依赖 WM_IME_*
- Chrome 需要一个 fallback
但整体方向已经很明确: TSF 是主路径,IMM32 是兼容层