引子:一个每天在用,却几乎不了解的软件
写 CxxIME 之前,我用了十几年输入法。
时间长了会注意到一些变化:安装包一年比一年大,装完之后系统里多出好几处目录和几个常驻进程,候选栏里也不再只有候选词。每次安装完成,我都会顺手翻一下安装目录和启动项,看看系统里多了什么。
这些现象本身未必意味着什么,却让我逐渐意识到:输入法是我每天都在使用的软件,我却不知道它由哪些部分组成,也说不清一次按键最终变成文字的过程中发生了什么。
输入法可能是电脑上最特殊的软件之一:你在几乎每个窗口里输入文字,都要经过它;但它如何进入系统、如何理解输入,又如何与不同的应用协作,对大多数人来说始终是一个黑盒。
我也一样。
于是我想,不如自己写一个能看懂的。
一、从一个空目录开始
2026 年 5 月,我建了一个空目录 CxxIME,有了第一个目标:先让它出现在 Windows 的输入法列表里。
我没有基于 librime 直接修改,并不是因为它不够好。恰恰相反,Rime 和 Weasel 是这个领域非常重要的参考。只是 CxxIME 的目标之一,就是亲手走一遍“按键怎样变成候选、候选怎样进入应用”的完整路径。如果一开始站在成熟框架之上,很多我最想理解的细节会被现成抽象接管。
Windows 上的现代输入法主要通过 TSF(Text Services Framework)接入系统。它不是一个“监听键盘、弹出窗口”的小程序,而是一组由宿主应用调用的 COM 接口:什么时候获得焦点,哪个按键由输入法处理,预编辑文本写到哪里,候选列表是否交给应用绘制,几乎每一步都要和宿主协作。
因此,最早能运行的 CxxIME 只有一条很短的链路:注册 TSF 文本服务,接收按键,创建 composition(组合文本,也就是尚未正式上屏的输入),再把结果提交给应用。那时它还没有真正的词典和候选排序,却已经需要同时面对 32 位与 64 位宿主、COM 生命周期、线程管理器和编辑会话。
项目最终采用了客户端 / 服务端结构:
应用程序
|
v
TSF DLL(x64 / x86)
按键、composition、宿主交互、候选呈现
|
v 命名管道 / 异步 I/O
cxxime-server.exe
拼音、五笔、混输、词典、会话与用户数据
cxxime-settings.exe
设置、词库管理、备份与导入
这样做的原因很实际。TSF DLL 会被记事本、浏览器、编辑器和游戏分别加载。如果把词典和索引塞进 DLL,每个宿主进程都要承担一份内存和初始化成本;放进独立服务端后,所有输入会话可以共享同一份数据。引擎发生异常时,也不至于直接拖垮正在输入的应用。
代价是每次按键都多了一次进程间通信。这个问题后来确实出现了,但架构本身没有成为瓶颈,真正的瓶颈藏在一个看似负责“保险”的系统调用里。这个代价后面会用实测数据说明。
二、从 nihao 到“你好”
把 nihao 变成“你好”,表面上是一条查词操作,实际至少包含四步:
nihao
|
|- 识别可能的音节边界:ni + hao
|- 查询完整词、前缀词和用户词
|- 合并不同切分路径产生的候选
|- 按匹配程度、词频和用户偏好排序
拼音输入没有天然的边界。nihao 可以解释为 ni + hao,简拼、模糊音和不完整音节又会带来更多路径。CxxIME 在构建词典时展开拼写规则,运行时通过拼写索引寻找可能的音节,再对有效路径查词。主词典来自雾凇拼音,约 190 万条词语;用户自己添加的词、手工调整的候选顺序和选词学习数据,则分别保存和参与排序。
第一版做到这里时,我以为拼音引擎已经“能用了”。实际使用很快推翻了这个判断。
例如输入 wuzong。如果词典里没有一个足够好的完整匹配,理想的操作应该是先选择“乌”,消耗 wu,再继续为 zong 选“总”。早期实现虽然也能生成部分匹配,却把单字、三字和四字候选混在连续页面中。用户翻了几页仍然找不到“乌”,从算法角度看候选并非不存在,从使用角度看却等于不可用。
这个问题最后不是靠给“乌”加一个特例解决的,而是重新定义候选集合的职责:前 10 个位置优先留给高质量完整候选,之后集中提供能够继续消耗 preedit(尚未确认的输入编码)的分离候选。于是 wuzong 可以先选“乌”,huaruijishu 也可以先选“华锐”,剩余的 jishu 继续参与查询。
这里有一个很重要的区别:**候选质量不只是排序分数,还包括用户能否找到下一步。**一个词排在第 30 位和一个词根本不存在,在实现上差别很大,在用户手里往往没有差别。
编辑中的拼音也暴露了类似问题。输入 nihao 时首选是“你好”,把光标移到中间删除 i,编码变成 nhao,早期版本的首选却会变成“你好啊”。原因不是词频异常,而是完整匹配与前缀扩展进入同一条排序路径后,较长候选占了不该占的位置。修复时同样没有针对“你好”写规则,而是让匹配跨度成为明确的排序依据。
这些例子逐渐确定了 CxxIME 的一个原则:用户应该能够知道候选为什么出现,也应该能改变它。设置里的词库管理因此不只负责增删词,还能查看和调整候选顺序;手工固定、自动学习和随包词典各自有清楚的边界,删除学习记录也不会顺手破坏用户维护的词库。
三、五笔需要另一条路
做完拼音再加五笔,很容易产生一种错觉:只要把另一份词典接到相同接口上就行。
真正接入后会发现,两者的输入模型并不相同。拼音面对的是“一个连续字符串可能如何切分”,五笔面对的是“一个确定编码对应哪些重码字词”。如果让五笔走拼音的音节图和动态评分管线,不但没有收益,还会引入额外延迟和难以解释的顺序变化。
CxxIME 为五笔建立了独立的前缀索引。词典构建阶段预先排好每个编码前缀的候选,运行时只需定位编码并读取结果。四码唯一候选、第五码首选上屏、四码无候选后重新编码等行为,也都留在五笔自己的状态机中。
混输则不是简单地把两组候选拼起来。短编码下,五笔单字和拼音词语都可能合理;长编码下,拼音完整句通常更有价值。CxxIME 会根据编码长度和匹配质量决定来源优先级,也允许用户明确选择“五笔首选”的交错顺序。
这部分让我重新理解了“统一体验”。统一不意味着所有模式共用同一套算法,而是它们在候选选择、翻页、preedit、学习和设置中的行为可以被用户预测。底层路径可以不同,表层规则不能互相打架。
四、怎么把输入状态显示清楚
引擎决定“给什么候选”,候选窗口决定用户能否理解当前发生了什么。
多段选词加入后,单纯显示一串字母已经不够。假设输入 huaruijishu,选中“华锐”以后,用户需要同时看清三件事:哪些文字已经确认、当前候选将消耗哪一段、光标位于哪里。
CxxIME 最终把拼音显示为带音节分隔的形式,例如 hua'rui'ji'shu。已确认前缀与未确认部分留出间隔,浅色焦点矩形包住当前正在选择的音节。分隔符放在音节之间,而不放进矩形内部,因此视觉关系更接近:
华锐 [ji]'shu
而不是:
华锐 [ji']shu
前一种表达里,矩形表示“当前作用范围”,撇号只表示音节边界,两种视觉元素各做一件事。拼音、五笔和混输共用这套表达:即使五笔输入暂时没有候选,例如输入 hse,活动矩形仍然保留,不会因为候选列表为空而突然失去当前输入状态。
静态光标也经历了几轮调整。最初的细竖线在高分辨率屏幕上几乎看不见;简单加宽以后,它又会覆盖后面的字符,看起来像文字突然变粗。最终做法是参考系统文本光标(caret)的宽度,同时给它一个真实的布局槽位,让文字为光标留出空间。光标移动后会短暂强调,但不会一直用高对比色打扰阅读。
这些细节很小,却直接决定一个输入法是“可以操作”,还是“看一眼就知道怎么操作”。
五、怎么和应用一起工作
到这里,CxxIME 已经能够生成候选,也能够把它们画出来。但 Windows 输入法并不是一个独立运行的程序。它嵌在应用的文本系统里,候选由谁呈现、组合文本如何更新、窗口应该放在哪里,都需要和应用协作。
应用也会参与输入
浏览器、Office、Qt、Electron、Windows Terminal、系统搜索框和游戏对 TSF 的实现各不相同。有的应用让输入法自己画候选窗口;有的通过 TSF 的 UIElement 接口接管 preedit 和候选列表;有的在提权后改变窗口与进程边界;有的会在焦点切换时重建 composition。
DOTA2 是一个很典型的例子。它不会像普通桌面编辑框那样显示输入法窗口,而是由游戏宿主接管候选数据并在自己的界面中呈现。CxxIME 必须识别候选的所有权:宿主接管时只提供数据,不再叠加自己的窗口;宿主不接管时再恢复自绘。Windows 搜索框则需要另一套本地候选与 composition 占位行为,才能让首键正确展开搜索界面。
还有一些问题只在生命周期边缘出现:应用仍占用旧版 DLL 时升级,系统休眠后恢复,焦点快速切换,宿主销毁编辑上下文,或者异步编辑会话晚于 composition 返回。它们往往不是某个判断写反了,而是多个本来正确的动作以错误顺序发生。
因此,CxxIME 的兼容性并不是一个“支持 Windows 10/11”的复选框,而是一份持续增长的宿主行为清单。每解决一个应用里的问题,都要先判断它暴露的是该应用特例,还是输入法自己的状态模型不完整。能修正模型时,就不为单个应用堆补丁。
候选窗口应该出现在哪里
如果只看接口文档,候选窗口定位似乎很简单:向宿主询问当前文本范围的矩形,把窗口放在矩形下方,超出屏幕时再放到上方。
现实里,宿主返回的矩形不总是稳定的。
有的编辑框只给出一条极窄的 caret;有的会返回屏幕外的临时位置;有的在 composition 刚建立时还没有准备好几何信息;多显示器和不同 DPI 又会让逻辑坐标、物理像素与工作区坐标混在一起。Windows Terminal 中还出现过一种尤其难抓的问题:候选窗口大多数时候跟在光标旁边,偶尔却突然跳到左侧很远的位置,下一帧又恢复。
这种现象没有稳定复现步骤,肉眼只能看到一次闪动。直到为 TSF、宿主和服务端补齐分层日志,从最后一次异常向前对齐事件,才确认它不是错觉:接口调用成功了,但连续采样中确实混入了与当前输入位置不一致的瞬时矩形。
修复不能简单地“发现位置变化就不动”。用户用鼠标切换输入行、跨显示器拖动窗口时,候选窗本来就应该立即跟随。CxxIME 最后把位置更新分成了几类:
- composition 初次出现时,对首个几何结果做确认,避免使用宿主尚未稳定的初始位置;
- 连续输入中,对距离异常且很快恢复的采样进行过滤;
- 焦点、编辑上下文或显示器确实发生变化时,立即接受锚点;
- 窗口尽量位于 caret 下方,空间不足时才翻到上方;屏幕边界使用当前显示器范围,允许覆盖任务栏区域。
这里最反直觉的一点是:**系统接口返回成功,只表示拿到了一个矩形,不保证这个矩形适合直接呈现给用户。**输入法仍然要结合时序、上下文和上一帧状态判断它是否可信。
候选窗口定位大概是整个项目中最像“工程”而不是“算法”的部分。没有一个公式能解决所有宿主,只能先把坐标系和状态边界定义清楚,再用真实应用逐个验证。
六、性能与代价
输入法对性能的要求和普通应用不太一样。页面加载偶尔慢 100 毫秒,用户可能没有感觉;打字时偶尔停一下,手指会立刻知道。
IPC:从同步管道到 IOCP
最早的 IPC 使用同步命名管道:客户端和服务端都通过同步的 ReadFile / WriteFile 完成请求与响应,写入后还会调用 FlushFileBuffers。当时的想法是确保消息尽快到达对端,实测却让一次 preedit 往返平均耗时约 14.7 毫秒。
问题恰好出在这个“确保”上。命名管道已经使用消息模式,WriteFile 完成后数据已进入内核缓冲;额外刷新会阻塞等待对端消费,平白增加调度等待。
重构后,客户端仍然同步等待当前按键的结果,但不再刷新管道;服务端则改为 IOCP(I/O 完成端口)与异步 I/O。同一项测试的平均往返时间先降到约 110 微秒;用户词典随后也从 SQLite 查询改为内存数据结构,最终稳定在约 50 微秒。需要同步返回结果并不等于整条链路都要使用同步 I/O,真正需要删除的是那些没有提供额外保证的等待。
词典加载:从 mmap 到全量读取
最初,CxxIME 会在启动时现场构建音节 ID 索引,耗时约 12 秒。后来把索引提前生成,并通过 mmap(内存映射)加载词典和索引,启动时间降到了 0.5 秒以内。mmap 很适合大型只读文件:创建映射很快,真正用到哪些页面,再由操作系统按需载入。
问题也出在“按需”上。词典一段时间没有被访问,或者系统出现内存压力时,映射页面可能被回收。下一次查询会在按键路径上触发缺页,文件系统过滤驱动和杀毒软件也可能参与这次读取。实际测试中,切换到英文、让词典一段时间未被访问后,第一次中文输入曾出现约 483 毫秒的延迟。平均性能没有明显问题,用户却会感觉第一个按键突然卡住。
最终,词典及其各类索引都改为通过 ReadFile 一次性读入堆内存。启动或词典重新加载时,先把新资源完整读取并校验,再交给输入会话使用。这样把文件读取集中在可控阶段,也减少了查询过程中内存映射按需调页带来的波动。
这并不意味着 mmap 本身有问题。它优化的是映射和按需访问,而输入法更在意任意一次按键的延迟上限。两者关注的指标不同,适合的选择也就不同。
查询:把工作移出按键路径
稳定了 IPC 和词典加载以后,查询本身还有几处不该留在按键路径上的工作:
- 长拼音产生过多切分路径,就限制搜索空间并尽早淘汰无效路径;
- 查询先收集全部候选再排序,就改成带扫描预算的有界收集,并设置 30 毫秒截止时间保护最坏情况;
- 高频短输入反复走完整管线,就在构建期生成短输入索引,命中后直接返回。
这些变化很少来自某个循环快了百分之十,更多是删除了一段运行时根本不该发生的工作。一组历史查询基准在每轮预热 100 次后重复 500 次,并取三轮的中位结果:nihao 查询流程的 P50(中位耗时)从约 3.8 毫秒降到 60 微秒,nihaoshijie 则从约 25.9 毫秒降到 170 微秒。不同机器上的绝对数字会变化,但量级变化足以说明问题。
当然,稳定也不是没有代价的。全量读取让加载阶段真正读完文件,服务端也会占用较多内存。未来可以通过分片和压缩降低内存,但不能把不可控的磁盘访问重新带回按键路径。现阶段我选择先保证响应稳定,再继续寻找不会牺牲确定性的优化办法。
七、诊断与测试
输入法问题很难描述。“候选窗口刚才好像闪了一下”、“这个字偶尔要按两次”、“刚才有一点卡”,这些反馈都是真实体验,但仅靠一句话无法去定位。
CxxIME 后来把诊断信息分到 TSF 宿主侧、服务端和候选窗口等不同层级,并为一次输入会话保留可以互相对齐的事件。日常使用时关闭诊断日志,需要排查时再显式开启,导出日志和环境摘要。
这套可观测性最直接的价值,就是能从结果反推过程。Windows Terminal 的候选窗口跳动无法靠稳定步骤重放,但日志可以证明异常采样发生在哪一帧、前后 composition 是否相同、caret 和窗口最终采用了哪组坐标。没有这些证据,任何修复都只是猜测,也很容易把正常的鼠标跳转一起过滤掉。
自动测试承担另一部分责任。引擎测试检查切分、排序、分页和学习边界;UI 测试检查 preedit 几何、光标占位和窗口放置;IPC 与安装测试检查协议和生命周期。显示器、游戏和具体宿主仍需要手工回归。
测试的目的不是留住每一段历史实现,而是描述当前产品必须成立的行为。这个原则同样来自教训:测试如果只复刻内部函数,很容易反过来绑住错误设计;只有从用户可见结果出发,它才真的能阻止回归。
八、安装、卸载与迁移
输入法 DLL 一旦被应用加载,安装器就不一定能立即替换或删除它。早期安装布局只轮换两个版本目录,看似简单,却会在旧文件被占用、关机启用了快速启动、下一次继续安装时留下难以解释的“待清理内容”。
0.5.0 改成了独立版本目录:新版本可以先完成注册,让新启动的应用立即使用;仍加载旧 DLL 的应用在关闭后自然退出旧版本;无法立刻删除的文件交给后续安装或 Windows 完整重启清理。安装和卸载都不强迫用户当场重启,只在确实存在待清理文件时说明状态。
这看起来不像输入法功能,却直接影响用户是否敢升级。一个新版本即使候选更准,只要安装时要求关闭所有工作窗口、处理中途又弹出旧卸载器,它的体验就是不完整的。
CxxIME 没有云服务,因此用户数据也需要一条明确的迁移路径。设置窗口提供“备份与导入”,可以带走配置、手工词库、候选顺序、选词偏好和整句学习记录。导入采用合并语义:另一台机器上的数据加入本机现状,不因为一项记录失败回滚已经成功的部分,也不删除本机新增的词。窗口位置和诊断开关这类设备相关设置默认不迁移。
这里选择“导入”而不是“恢复”,是因为真实场景通常不是回到某个历史快照。用户可能在电脑 A 上用了三天,又从电脑 B 带回一份备份;他想要的是两边的数据合在一起,而不是让其中一边覆盖另一边。
九、我做出的取舍
CxxIME 不是为了证明所有功能都应该自己重写,也不追求在功能数量上追赶商业输入法。它更像一套可以完整解释的选择。
**本地优先。**输入、词典查询、候选学习和用户配置都在本机完成。目前没有云候选、账号同步和联网推荐。代价是缺少跨设备自动同步,也得不到云端语言模型对长句的补充。
**依赖尽量少。**运行时核心使用 C++17 和 Windows 系统组件,词典在构建阶段转换为自有二进制格式。设置、安装和诊断也尽量保持可审查。少依赖不是目的本身,它只是让“某个候选从哪里来、一次按键经过了什么”更容易追踪。
**规则必须能解释。**候选排序可以复杂,但用户手工固定的顺序必须高于自动学习;分段选择不能污染整句学习;拼音、五笔和混输的数据不能互相串写。遇到质量问题时,优先修正候选集合和排序语义,而不是为一个词增加永久特例。
**先保证确定性。**这也是目前愿意用更多内存换稳定时延的原因。它未必是最终最优解,但取舍是明确的,后续优化也有可以守住的基线。
十、现在,它走到了 0.5.0
写到这里,CxxIME 已经从“Windows 能认出它”走到了可以进行日常使用的阶段。
目前支持拼音、五笔 86 和混输三种模式:拼音包含全拼、简拼、模糊音、动态组句和多段选词;五笔包含简码、补码提示、四码唯一候选与第五码行为;候选窗口支持横排、竖排、D2D / GDI 双渲染,以及六组各含深浅版本的主题。设置中可以维护词库、候选顺序、学习数据和布局,也可以备份并合并导入用户数据。
0.5.0 的重点不是再增加一种输入方式,而是把已有链路补完整:长拼音可以分段选择,preedit 能表达音节与当前作用范围,无候选时视觉状态保持一致,候选不足一页时仍可继续查询,安装升级不再被占用中的旧 DLL 阻断。
它仍然有明确的限制:动态组句是本地词典上的有界搜索,不是语言模型;常驻内存仍然偏高;某些游戏宿主能否鼠标选择候选取决于它开放的 TSF 接口;新应用和新版本 Windows 也会继续带来没有见过的宿主行为。
但至少,当一个词打不出来、一个候选排错位置,或者窗口突然跳了一下时,我现在可以顺着代码、数据和日志找到它为什么发生,并真正改变它。
这正是最初想写“一个能看懂的输入法”时,我对“能看懂”的定义。
写在最后
从 5 月动手时,我以为输入法最难的是分词、词典和排序。真正写下来才发现,算法往往有清楚的输入和输出,难的是让这些结果在每个 Windows 应用里都以正确的时机、正确的位置和一致的方式出现。
这个项目也改变了我看待“偶现问题”的方式。用户看到的一次闪烁,背后可能是三个进程、两套坐标系和一个只存在几毫秒的宿主状态。它不是无法复现,只是原来的系统没有留下足够证据。
从零写 CxxIME 并没有让我得到一个没有缺点的输入法。它让我第一次能回答:一个候选为什么出现,一次按键为什么变慢,一个窗口为什么移动,以及我愿意为哪种体验付出什么代价。
项目代码按 Apache License 2.0 发布。拼音词典派生自 rime-ice(GPL-3.0-only),五笔词典派生自 rime-wubi86-jidian(Apache-2.0);第三方组件和数据授权在仓库的 THIRD_PARTY_NOTICES.txt 中单独列出。
- GitHub: github.com/deanxyuan/c…
- Gitee: gitee.com/shadowyuan/…
如果后面继续写,我想分别展开候选排序、Windows 宿主兼容和安装生命周期。每一个主题,都比一篇项目总览能容纳的内容更多。