« October 2026 | Main

November 09, 2026

复活 ABC 输入法

最近有网友用 AI 逆向了智能 ABC ,把里面的笔划码表导了出来,用 RIME 复刻了一版。

众所周知,我是 ABC 的忠实用户。从 DOS 版天汇 ABC 用到 Windows 上智能 ABC 彻底退役,一直念念不忘。在 10 年前我曾经动过念头自己写一个,后来被 IME 开发的繁杂劝退了。当时用 C 从头写了一个简单的 IME 框架,又给网上不少开源的 IME 框架提过 PR ,最终没坚持下来。有朋友提醒我说 RIME 经过过年的发展已经可以高度定制了,我一直没在意,直到这次、我才正正经经的研究了它。

试用了上面那个智能 ABC 复刻版,和我记忆中的手感还是有不少差距。原本我想先在这个基础上改改的,随之发现改不太动 AI 生成的代码。细想了一下,在 RIME 基础上做开发工作量并不大。而关键的码表已经是现成的了,就自己动手玩了一下,果真一天时间就搭了个雏形。最花时间的倒不是实现,而是搞明白 RIME 的逻辑。这部分值得记录下来。


RIME 把输入法流程分为四个阶段:processor 、segmentor、translator、filter 。

processor 用来处理按键。用户按键的原始消息在这里变成输入的字符串、或是在这里处理按键对应的交互操作。通常在这个阶段,会生成最初的输入串供后续处理。但这个输入状态并不是传递给后续阶段,而是放在全局 context 中。我理解为,不同的输入方案,需要传递的状态简繁不一,很难用一个输入流抽象。除了输入流,还有其它状态:例如当前是中文还是西文、全角还是半角,这些都会影响按键的输入如何转换为最终的数据。processor 更多的任务不在规整数据流,更在于输入法和用户的交互行为:选择备选单、如何上屏、如何管理数据流的编辑状态等等。

segmentor 则负责把当下的输入信息转换为一组组类型化的数据块。这些数据块是对整个输入串的局部快照,打上了不同的 tag 。类型信息有助于在后续阶段交给不同的子模块处理。这样、就可以把标点、文字、表情符号、专有名词分到不同的 translator 。

前两个阶段都是流式工作的,在 processor 中流转的是按键、前一个 processor 不想处理的按键就轮到下一个处理。segmentor 阶段流转的是 segment 分段集,不同的 segmentor 通常用来处理不同的数据状态。例如,输入法一般会管理英文输入和中文数据两个状态(很早以前 windows 是由系统管理的,现在则流行由输入法自行管理)。RIME 就实现了一个默认的英文输入器,基本上就是把按键直接保持不变输出(这就需要 tag 了)。如果不在英文状态,这个 segmentor 就略过,交给下个 segmentor 处理输入;否则,它会熔断 segmentor 的流程,直接去到下一步 translator 。

和前两个阶段不同,translator 是按 tag 分发的。大致上可以理解为谁订阅了特定类型 tag 谁会被调用处理。这里不可以依赖配置文件里定义的 translator 的先后次序,不可以认为写在后面的 translator 可以利用写在前面的 translator 处理的结果。我这样理解这个设计:每个输入对象(片段)都可以对应多个结果,也就是输入法里的备选单。如果有多个 translator ,它们都可以提供这些选项。而 UI 上可以呈现的选项数量却是有限的,没必要收集所有的选项。所以,单个 translator 每次只需要返回一个选项,让框架按需整合即可。在 lua 封装中,提供的正是协程风格 API 。

最后一个阶段 filter 则是过滤 translator 按需生成的结果。这个阶段的过滤器又是流式处理的了。略有不同的是,这个阶段没有熔断机制,定义的过滤器都会被执行。官方提供的 filter 是把繁体字转换为简体字,以及结果去重。因为从语义上来说,简体字是繁体字的子集,一个简体字可以对应多个繁体字、反之不然。我后来发现 filter 对我这次想实现的音形结合码非常有用。


大部分的 RIME 方案并不需要额外开发脚本,用已有的模块搭配不同的配置项都可以完成。但 ABC 方案有些不同的地方,不能完全用配置解决。下面是我认为存在的问题:

RIME 提供了两种风格的交互:以词为单位输入的 express_editor 和整句为单位的 fluid_editor 。它们都是 processor ,用来处理按键交互。express 模式下,用户确定了一组短语后就会立刻上屏,输入法自身会复位等待下一组输入;fluid 模式则在用户确定后,只是放在输入缓冲区中,用户接着独立编辑下一段输入,但输入法可以根据上下文调整之前的输入。

express 通常提供更少的交互能力,比如没有对输入片段复杂的编辑能力,但获得的好处是有更多的输入法编辑框之外的操作可以穿透输入法立刻生效;fluid 模式更像是输入法内置的编辑环境,运行输入法针对更大的上下文处理输入。一般来说,定制输入法方案应该在这两者间选一个更接近的去改动细节:通常用前置一些 processor 拦截按键消息实现。但智能 ABC 略有特殊,光靠配置项搞不定。目前我实现的版本最大的缺失也是这块,还需要慢慢打磨,最终很可能需要自己实现一套交互用的 processor 模块。

智能 ABC 从功能上说,更接近 express 模式。它主张以短语为单位输入而不是长句。原版中也没有太多预测句子中同音字的特性。但有少量单字会根据出现在词的前面还是后面改变权重。比方说,“的”不太可能出现在句首。它的词库比较小,又非常依赖用户造词,所以还需要一定的以单字构新词的编辑功能,这方面的交互又更接近 fluid 模式。这也是为何 ABC 老被人诟病需要按更多的空格键的源头。

智能 ABC 交互上的另一个问题是它依赖数字编码,这和一般输入法采用数字键选字有所冲突。原版中是把输入状态和编辑状态分开来解决这个问题的。在按空格前,属于输入状态,这个时候可以输入数字,不可以选词;在空格后,处于编辑状态,不可以修改已经输入的串,同时允许数字键选词。至于原版中,在按空格前不显示备选单,我认为只是交互上的决策或是实现上的偷懒。这次用 RIME 实现,倒不必完全退回到原来的设计。只是这个输入状态和编辑状态的切换交互设计值得重新理一下。

btw. 有人觉得 ABC 总是需要按两次空格才能把输入上屏,其实并非这样。第一次空格是确认短语的输入,进入编辑选择态。这时在按一次空格可以退出选择态把默认选择上屏,也就是两次空格上屏。但其实退格键、ESC 之外的任何按键都可以达到这个效果,而且字母按键除了让其上屏外,还保留到下一组输入的第一个按键。所以输入长句时,每个短语间并不需要两次空格,一次就够了。


ABC 输入法把 u v i 这三个不可能出现在拼音开头的字母都定义了特别的前缀语义。尤其是用 i 做前缀来输入数字非常方便。比如 i2026n 可以直接输出二〇二六年 ,i4s2cm 则对应四十二厘米,I123 可以输出大写的壹贰叁…… 这个功能在 RIME 里可以直接用模式匹配实现。ps. 前面提到的 remi-znabc 项目,把这些特性一股脑塞进了自定义 processor 模块中,是我不太满意的地方。

中括号选词是 ABC 的特色。在打单字时,往往需要组个词去掉同音字。很多人喜欢把词先打出来再用退格键删去一个字,但 ABC 直接提供了加上 [] 后缀保证只输出首字或末字。我觉得在 RIME 中实现它比较贴切的方法是定义一个 segmentor 给这种以词去字的输出片段贴上正确的 tag ,而不是生硬的将中括号编码在输入串中。这样就可以方便在后续的 translator 中处理。本来这个特性就不限于特定的输入法编码,任何输入法都可以加入这个功能。

音形码混合怎么做?

一开始我在翻阅 RIME 文档时,找到了 SpellingAlgebra 。官方用这个方法解决拼音用声母组词造句的问题。也就是用空间换时间以及算法复杂度的方法,把每种输入组合都加到字典中。比如,“我们”这个词,原始词典中只提供 wo men 一种拼写,但通过模板的预处理,将 wm wmen wom 这些组合都加到词典里去。(另外,RIME 还提供一种自动生成五笔里造词编码的方式叫做 encoder ,不适用这里的情况)ABC 这种音形码只是组合更多一点罢了:如果我们把单个字的编码分为三个部分:声母、韵母、形码;声母是必须的,而韵母和形码可以自由组合,其中形码长为 1-6 位。组合倒是够多,好在现在内存硬盘都够大,应该无所谓。

但在实现的时候我发现另一个问题。如果我把智能 ABC 原本的词典转换一下,就可以通过模板规则把它配置好。但 RIME 已经拥有了更大的现代词库,为何不用呢?但我没有找到内置的配置方法可以帮助我把已经整理好的单字形码附加到已有的词库上。首先,RIME 默认并不是以单字和单词分开保存词库的。虽然提供了字词分离的方案,但还有多音字问题:即每个单字本质上是一字多码(多音)的,而对于词,却是一词一码。其次,RIME 并没有提供一种合并两个编码库的手段,即把原有的拼音编码每个字都加上额外的形码。这就需要另外提供一个外部脚本来处理。我不喜欢这种引入外部脚本的方案。

最后我采用的解决方案是这样的:自定义一个 translator ,它的核心还是调用官方拼音 translator 去解析用户输入。但在输入前把所有的数字形码都去掉,变成纯音码;拿到输出后,再把数据源改回来。这样,我们就会得到一些冗余的选项(不符合用户输入的形码)。但是,我再自定义一个 filter ,去检查这些选项中的形码,去掉那些不匹配的,就得到了正确的选项集。


btw, 在实现过程中我想到了一个有趣点。ABC 的形码编码只用了 1-8 。既然把 8 定义为口字型,为什么不给 0 和 9 也做上定义?或许设计者其它设计上的考量,但只有 1-8 对于判定用户输入是否匹配编码多了一种有趣的方法:

传统的方法是匹配字符串前缀,比如“的”字的完整编码是 38135 ,但实际上我从来没有敲过全码,因为 38 这个前缀就完全去重了,也很好记忆:3 是撇,8 是口。在比较 38 是否是 38135 的前缀时,我们其实可以直接用数字比较(而不是字符串)。只需要把 38 扩到 6 位数 380000(ABC 的最长编码),38135 也扩展到 6 位 381350 。因为 381350 在 380000 和 389999 之间,所以 38 匹配上了这个编码。