ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零开发自然码双拼输入法:TSF框架与词库引擎实战解析

从零开发自然码双拼输入法:TSF框架与词库引擎实战解析 做输入法这件事听起来像是老程序员才碰的领域但只要你是平时用双拼的人尤其是自然码双拼用户大概率都有过这种念头系统自带输入法虽然能用可整句模型、词库排序、候选框外观各种不合心意能不能干脆自己写一套我之前花了几周时间在 64 位 Windows 上从零搭了一个自然码双拼方案的输入法代码量不算大但整个路径上涉及的知识点非常杂Windows 文本服务框架、COM 组件协议、词库索引、候选框定位、DPI 适配、32/64 位兼容……这篇文章就把这条完整的技术路径拆开讲清楚适合想理解 Windows 输入法工作原理的开发者也想给打算自己封装一套双拼方案的朋友一份能直接抄作业的参考。整体思路和代码细节都是我自己实际跑通过的不是那种只贴概念的文章。1. 动手之前先想清楚要做什么级别的输入法1.1 明确功能边界和“从零”的范围很多人听到“从零开发输入法”第一反应是做一个和搜狗、微软拼音对标的完整产品那工作量确实巨大需要几十个人的团队和几百万条语料。但个人项目不需要这么激进。我给自己定的目标很具体能输入自然码双拼编码候选窗口跟上光标支持词库联想支持基本的自学习并且能在 64 位 Windows 上干净地跑起来。至于云端词库、整句模型、手写识别、语音输入这些统统不在第一版范围内。这里有一个重要的概念需要先澄清自然码本质上是一套双拼编码方案而不是某个开发框架。它的核心思想是“把每个汉字的拼音压缩成两个键”第一个键代表声母第二个键代表韵母。比如“中”字的拼音是 zhong声母 zh 在自然码映射为 v韵母 ong 映射为 s所以“中”的双拼编码是 vs。这意味着只要我知道了这套键位映射表就可以自己写代码实现它的核心逻辑不涉及对任何商业输入法软件的破解或逆向这也是“从零开发”的最稳妥路线。技术选型上我更推荐用 C 加 Windows 原生 SDK不集成现成输入法引擎。原因主要有两个第一TSF 框架本身是 COM 接口协议C 和 COM 是原生契合的调试起来直接第二输入法核心的编码映射和词库检索并不复杂自己写反而更可控出现问题一眼就能定位。网上也有基于 Rime、Fcitx 的方案但那些更适合跨平台扩展不是“从零实现”的思路而且会引入较多额外依赖。1.2 为什么选 TSF 而不是全局键盘钩子在 Windows 上做输入法最底层有两条路一个是写全局键盘钩子当检测到按键时拦截自己弹一个悬浮窗选中词后再往焦点窗口里注入文本另一个是走系统官方的 TSFText Services Framework文本服务。很多人觉得钩子方案简单因为它不需要学 COM也不需要注册成系统输入法挂上就能用。我一开始也想过这个路子但实际调研后发现几个致命问题全局钩子在一些启用安全机制的进程里根本注入不了比如新版本 Edge 的受保护进程、UWP 应用商店应用钩子只是机械地获取键消息无法真正感知当前应用的光标位置和文本上下文候选框大概率会出现在屏幕角落而且它往目标窗口塞文本时某些应用会认为这是非法输入直接忽略。说白了钩子方案更适合做按键监控这类工具不适合做正经输入法。所以我选择实现一个真正的 TSF 文本服务。TSF 可以理解为 Windows 写给文本服务和应用程序之间的一套插槽协议输入法作为一个 COM 组件注册到系统里应用需要输入的时候就通过这套协议来和你协作。你不仅能拿到按键事件还能拿到当前窗口的文本上下文、光标位置甚至可以控制正在编辑的文本体验上有本质区别。虽然 TSF 的接口学习曲线陡峭但这是唯一能长期稳定运行的路径。2. 自然码双拼编码表与核心算法2.1 热知识双拼方案其实是张查表自然码双拼方案在双拼输入法历史上占据重要位置它的键位设计是公开的也是很多其他方案的参照对象。实现第一步就是把 26 个字母键和声母、韵母的对应关系整理清楚。声母映射相对简单zh、ch、sh 三个多字母声母分别映射到 v、i、u其余单字母声母直接用本身。韵母映射是核心我直接用实践过的表整理出来键位对应的韵母键位对应的韵母aaoo、uodaizei、oujanfenhanggengkaorer、uaniiqiuxia、uawunmianying、uailiang、uangpieciaotuebinvui、üsong、iongeeuun无韵母映射这个表有几个细节需要注意一个键对应多个韵母是完全正常的因为拼音声母和韵母之间有强制性的组合约束比如 q 后面不能接 ia 这种组合所以重码碰撞实际可控。v 键既做 zh 的声母键又做 ui、ü 的韵母键在双拼规则里两键是不同位置不会冲突。n 键没有韵母映射这意味着任何以 n 作为第二个键的组合都会被判定为非法编码需要在代码里做拦截。零声母的处理也是不可回避的问题。像“啊”a、“爱”ai、“二”er这些没有声母的汉字输入法需要一条约定俗成的规则。自然码的处理方式是“双写韵母键”例如“啊”的编码是 aa“爱”的编码是 dd“恩”的编码是 ff“二”的编码是 rr。这在实现上相当于一个特殊分支当两个键位相同且没有对应声母时直接查韵母展开。2.2 从双拼编码还原拼音串的算法输入法内部第一步是把用户按下的两个字母还原成一个或几个可能的拼音串。这一步的关键是处理好“一个键对应多个韵母”的情况。我给出一个朴素的 C 实现片段#include map #include vector #include string using PinyinList std::vectorstd::wstring; // 韵母映射表只列一部分作为示例 const std::mapwchar_t, std::vectorstd::wstring yunmuMap { {Lo, {Lo, Luo}}, {Ld, {Lai}}, {Lz, {Lei, Lou}}, {Lr, {Ler, Luan}}, {Lx, {Lia, Lua}}, {Li, {Li}}, }; PinyinList expandKeyPair(const std::wstring shengKey, const std::wstring yunKey) { std::vectorstd::wstring shengList; if (shengKey Lv) shengList.push_back(Lzh); else if (shengKey Li) shengList.push_back(Lch); else if (shengKey Lu) shengList.push_back(Lsh); else shengList.push_back(shengKey); PinyinList result; auto it yunmuMap.find(yunKey[0]); if (it yunmuMap.end()) return result; for (const auto s : shengList) { for (const auto y : it-second) { result.push_back(s y); } } return result; }这段代码的核心思路就是“先展开声母再枚举韵母两两组合”。比如用户输入 vsv 展开成 zhs 对应的韵母是 ong、iong最后得到候选拼音 zhong、zhiong后者不存在于任何汉字读音中最终在词库查询阶段会被自然淘汰。双拼相比全拼有一个天然优势不需要做拼音切分。全拼里输入“xian”可能是“xian”先也可能是“xi an”西安存在切分歧义。双拼里每个汉字固定两个键所以整个输入串一定是偶数长度直接每两个字符切一个音节完全没有歧义。这个特性极大简化了候选引擎的设计也是我觉得双拼适合从零实现的重要原因。2.3 为什么把编码表放在最前面做编码表是整个输入法的心脏但它恰恰是最容易被忽视的部分。我见过一些入门项目先做了一堆界面和注册逻辑最后才临时填键位结果候选词匹配率一塌糊涂还以为是词库的问题。正确的顺序是先把编码表作为独立模块测试好再进入 TSF 集成。我当时写了一个极小的控制台测试程序把所有双拼组合输入一遍逐一比对还原出来的拼音是否和预期一致。比如“我”应该是 wow o 键“这”应该是 vev e 键“中”应该是 vsv s 键“人”应该是 rfr 是声母 rf 是 en 键。这个测试跑通后后续所有问题都能隔离在 TSF 层和词库层而不是纠缠在编码逻辑里。3. TSF 文本服务接入与基础框架搭建3.1 创建 COM 组件与 x64 工程TSF 输入法本质上是一个进程内 COM 组件DLL 文件被系统以 text service 的方式加载。创建一个完整的 TSF 项目需要准备这些基础内容使用 Visual Studio 2022 创建动态链接库项目。工程平台选择 x64对应 64 位系统同时保留 Win32 平台以便编译 32 位版本。添加 ATL 支持用来简化 COM 对象的 IDL 和注册逻辑。生成一个唯一的 GUID 作为输入法 TIP 的标识符Visual Studio 自带 GUID 生成工具不能用随意编的十六进制数。COM 这个概念可以简单理解为 Windows 生态里的一套跨语言对象约定协议每个对象都有唯一的 GUID通过注册表注册后其他程序就能按这个标识符创建并调用对象。TSF 选择了 COM 作为接口规范所以开发输入法绕不开这一层。工程创建好后需要在 DllRegisterServer 里写注册表项。关键是下面这个路径HKLM\SOFTWARE\Microsoft\CTF\TIP\{你的GUID}同时要有一个 LanguageProfile 子项包含语言标识符和输入法显示名称。64 位系统下有个坑如果注册的是 32 位组件注册表操作会被重定向到 Wow6432Node 节点导致 64 位应用找不到输入法。所以最简单的做法是工程里同时生成 x64 和 x86 两个 DLL分别用对应版本的 regsvr32 注册。系统在加载输入法时会根据目标进程的位数自动选择对应版本。3.2 实现核心的 TSF 接口与方法TSF 输入法需要实现一系列 COM 接口核心的包括 ITfTextInputProcessorEx、ITfThreadMgrEventSink 和 ITfKeyEventSink。我做一个简化的类定义class NaturalCodeIME : public ITfTextInputProcessorEx, public ITfThreadMgrEventSink, public ITfKeyEventSink { public: // IUnknown 标准方法 STDMETHODIMP QueryInterface(REFIID riid, void** ppvObj) override; STDMETHODIMP_(ULONG) AddRef() override; STDMETHODIMP_(ULONG) Release() override; // ITfTextInputProcessorEx STDMETHODIMP Activate(ITfThreadMgr* ptim, TfClientId tid) override; STDMETHODIMP Deactivate() override; // ITfThreadMgrEventSink STDMETHODIMP OnSetFocus(ITfDocumentMgr* pdimFocus, ITfDocumentMgr* pdimPrev) override; // ITfKeyEventSink STDMETHODIMP OnKeyDown(WPARAM wParam, LPARAM lParam, BOOL* pfEaten) override; STDMETHODIMP OnKeyUp(WPARAM wParam, LPARAM lParam, BOOL* pfEaten) override; private: bool HandleKeyDown(WPARAM wParam); void CommitText(const std::wstring text); void UpdateCandidates(); void ShowCandidateWindow(); void HideCandidateWindow(); };Activate 是输入法的入口系统把线程管理器和客户端 ID 传进来这里要保存好指针并注册事件监听。OnKeyDown 是核心输入逻辑当输入法处于激活状态时每个按键都会先经过这里如果返回 pfEaten 为 TRUE应用就不会收到这个键。整个输入流程大致是字母键进入拼音缓冲区同时在候选窗口实时更新候选项空格键上屏当前高亮候选数字键选对应候选Esc 清空缓冲区。有一个细节值得注意就是中英文模式的切换。我实现的是 Shift 键切换需要截获 Shift 按下事件但同时又不能影响字母键本身模式状态用一个全局布尔变量保存。3.3 候选窗口的实现与光标跟随候选窗口是输入法最直观的 UI 部分。我第一版用的自绘无边框窗口并没有引入 Direct2D 之类的重量级界面库。窗口只需要显示几行文字几组候选词和序号GDI 足够应付。窗口创建时加上这几个扩展样式WS_EX_TOOLWINDOW不在任务栏显示、WS_EX_TOPMOST置顶、WS_EX_NOACTIVATE点击候选框不抢焦点。如果忘了 NOACTIVATE用户鼠标点击候选词时焦点会从正在输入的编辑框跳到候选框输入状态瞬间被打断这是很典型的低级问题。光标跟随是 TSF 最容易翻车的地方。隐藏的难点在于候选框必须知道当前正在编辑文本的插入点坐标。正确做法是拿到当前的 ITfContext 对象获取活动的 ITfContextView然后调用 GetTextExt 方法把当前插入点的文本范围换算成屏幕坐标RECT caretRect; ITfContextView* view nullptr; HRESULT hr pContext-GetActiveView(view); if (SUCCEEDED(hr) view) { BOOL clipped FALSE; view-GetTextExt(acp, caretRect, clipped); // 这里的 acp 是当前插入点的字符位置 }这里返回的坐标是物理像素如果你的程序没有做 DPI 感知声明Windows 会按系统缩放比例缩放界面导致高分屏上候选框明显偏移。解决方案是在 DllMain 或者初始化阶段调用 SetProcessDpiAwarenessContext声明 PerMonitorV2 级别让输入法拿到原始的物理像素坐标。4. 词库设计与候选词引擎4.1 加载词库与建立索引完成了编码和 TSF 外壳输入法还只是一个会接收按键的空壳真正的灵魂在于词库。我的实现没有用数据库直接采用文件映射加内存索引。词库文件按行组织每行一组拼音和多候选词zhong wen 中文 重文 中纹 yv yan 语言 预言 寓言加载流程是这样的启动时读取整个词库文件把拼音作为 key候选词列表作为 value存入 unordered_map。这种结构的查询效率是 O(1)几十万条词库加载到内存中也就几十兆对现代机器完全可以接受。不过要注意如果以后词库膨胀到几百万条启动耗时和内存占用都会上升届时要考虑前缀树或分片索引但第一版不必过度设计。拼音的 key 用统一格式。我直接用压缩后的双拼编码作为 key比如“中文”的拼音是 zhong wen双拼编码是 vswf查询时直接用 vswf 去查省去了从拼音再映射到编码的过程。这种设计的优点是路径最短候选引擎不需要理解拼音只需要做字符串查询。缺点是词库文件里存储的是双拼编码不直观但词库文件本身是用脚本生成的只要生成脚本正确就没问题。4.2 候选生成与排序策略当用户输入 vswf 时候选引擎先每两个字符切一个音节vs、wf然后去词库做匹配。匹配逻辑分成三个优先级整词匹配优先即直接查有没有双拼编码等于 vswf 的词条如果找不到再拆成单字分别查 vs 和 wf 的单字候选然后做二元组合最后把单字候选按频率降序排列。候选中带有大量重码排序就是核心竞争力。我的排序规则非常简单系统词库中的词频乘一个初始权重用户词库中的词频乘一个更高的权重最后的用户自造词频率最高。这个策略保证了稳定输入过的词组会排在前面而不会每次都被系统默认词抢走位置。自学习的实现也很直接用户每次按下数字键选择某个候选词就对这个词条的频率加一同时写入用户词库文件。下次启动时加载用户词库和系统词库合并相同编码的候选中用户词频高的自动排前。单一字符串查询看起来已经够用但我后来发现一个问题用户在输入全部双拼编码之前就已经能看到候选词比如打出第二个字母时候选窗口就应该展示“中”“种”“重”等字。所以查询不能只支持完整双拼编码还要支持前缀匹配。我的处理方式是在内存索引里额外保存一份单字双拼前缀的哈希映射用户输入到第奇数个字母时走前缀查询补全整码后切到精确查询。4.3 为什么不用现成分词库很多输入法实现为了节省时间直接集成第三方分词组件。我不反对这种做法但在“从零开发”的前提下双拼没有分词需求所以完全不需要引入。前面已经提到双拼每一个音节固定两个键输入串天然就是可切分的分词模块在这里属于多余的依赖。如果集成的是面向全拼的分词库反而会因为输入串已经被压缩成双拼编码而无法工作。自己写一个简单直接的单词解析器反而是最省事的做法。5. 常见问题与调试实录5.1 TSF 输入法不生效或者注册失败这类问题我踩过很多次。第一个坑是注册表写错位置或者注册命令没有以管理员权限执行。64 位系统上 regsvr32 弹出成功提示并不代表注册表一定正确因为 32 位 DLL 会被映射到 Wow6432Node 节点。排查方法是用系统自带的注册表编辑器直接查看 HKLM\SOFTWARE\Microsoft\CTF\TIP 下的 GUID 是否存在以及 LanguageProfile 是否完整。第二个坑是注册成功但切换不出来。Windows 10 和 Windows 11 对第三方 TSF 的启用方式有变化不能像 Windows 7 那样装完重启就能用。需要到“设置 - 时间和语言 - 输入”中添加语言然后在键盘选项中把自己写的输入法添加进去有些系统版本还需要先添加一个英文键盘才能看到切换入口。如果你在调试时改了 GUID 注册表项要么重新注册要么注销当前用户重新登录。第三个坑和杀毒软件有关。输入法会监听键盘事件天然容易被安全软件怀疑。X64 版本的 DLL 在编译时如果没有代码签名某些杀软会直接拦截加载。个人开发阶段可以在杀软里加信任排除如果要分发就需要购买代码签名证书。5.2 候选框位置错乱或闪烁候选框出现错乱最先检查两个地方有没有声明正确的 DPI 感知级别以及光标坐标获取的时机。DPI 问题前面已经提到PerMonitorV2 是必须的否则高分屏上候选框会偏离输入位置半个屏幕远。坐标获取时机问题也比较典型OnKeyDown 阶段文本上下文可能还没有更新需要把获取光标坐标放在按键事件处理完之后的延迟消息里或者使用异步文本布局通知。我当时是用 PostMessage 向候选窗口发自定义消息等当前按键的文本上下文布局稳定后再去取坐标闪烁问题就解决了。还有一类问题出现在全屏游戏或部分特殊窗口中候选框要么不显示要么显示后立刻闪退。TSF 虽然在受保护进程里能加载但模糊和不透明度效果受限某些游戏甚至禁用外部置顶窗口。这种现象属于架构限制正规输入法也是通过特殊模式绕过个人项目建议直接接受这个限制不要在候选框置顶方式上投入太多精力。5.3 按键没被输入法截获明明输入法已经激活但字母按键还是进入编辑器没有进入输入缓冲区。最常见的错误是事件 sink 优先级不够。TSF 允许多个监听者同时接收键盘事件每个监听者可以返回是否吃掉这个按键。如果你的 sink 注册顺序靠后前面已经有其他输入组件把按键消费掉你就拿不到了。需要在注册键盘事件 sink 时指定正确的 sink 标识并且在 OnKeyDown 里对每个键返回 pfEaten TRUE确保按键不再向下传递。另一个容易忽略的问题是焦点变化。编辑器窗口切换时TSF 会重新分配输入上下文原来的键盘事件 sink 还在但绑定的线程管理器已经变了。必须在 OnSetFocus 里重新获取新的 ITfDocumentMgr 和 ITfContext并重新注册键盘事件监听否则输入法会在某个应用里突然失效。我当时在 Chrome 和记事本之间来回切换测试花了半天才定位到这个原因。还有一个和竞态相关的细节如果候选中正在输入编码时用户用鼠标点击了编辑区导致焦点发生变化输入法的缓冲区应该被清空还是保留我一开始设计的是保留结果用户点完光标回来发现缓冲区的半截编码还在非常困惑。后来改成“失焦时自动清空缓冲区并隐藏候选框”这个交互才符合直觉。5.4 32/64 位进程的兼容问题64 位 Windows 的系统上很多普通应用其实还是 32 位进程比如一些老版本浏览器、聊天工具、音视频软件。TSF 输入法加载到哪个进程就必须和该进程的位数匹配。32 位进程不能加载 64 位 DLL。所以发布时必须同时带 x64 和 x86 两个 DLL 文件全部注册。Visual Studio 工程里可以同时配置两个平台一键生成。但要注意两个版本使用的 GUID 不能不同否则系统会把它当两个独立的输入法。如果只有 x64 版本输入法在绝大多数现代应用里能用但碰到 32 位应用就会失效。检查方法也很简单用任务管理器查看目标进程的“平台”列如果是 32 位再确认注册表里 Wow6432Node 节点下有没有对应注册信息。6. 我最终手上这套实现的可扩展方向这套自己写的输入法跑通之后后面的扩展空间其实很大。首先是整句模型目前候选引擎是基于词频的短句联想和微软拼音的整句模型差距明显但可以按需接入语言模型把整句候选举入排序过程。其次是自定义词库管理当前只是从词库文件加载、按使用频率更新还可以支持用户手动造词、批量导入专业术语词表。再就是界面皮肤自绘候选窗口已经留出了绘制接口后续想换成横排、九宫格或者带透明度效果的候选条都只是替换绘制层。我个人在实际操作中的体会是输入法这个项目真正难的不是算法而是熟悉 Windows 给输入法套上的那一层层框架约束。TSF 的接口设计很抽象遇到问题网上能找到的现成资料远不如其他开发领域多很多经验只能靠反复试验积累。但反过来也正因为门槛高把这条路走通之后你会对 Windows 底层输入机制、文本服务模型、COM 交互有非常直观的理解这种收获比单纯写业务代码要值太多。如果你现在也在折腾自己的输入法只要把编码表这个地基打牢之后不管是做 TSF 还是转去研究 Rime 配置都会顺很多。
返回列表