> InputMode 协议不是设计出来的——是被 bug 一个个雕刻出来的。
本篇是 《TUX IM 开发日志:从零到 0.1》 系列第 3/8 篇。
第 3 篇 · 6 个 bug 修复拼出的核心架构
第 3 篇 · 6 个 bug 修复拼出的核心架构
上一篇讲了崩溃——但崩溃只是表象。表象下面藏着更深的东西:这 6 个 commit 实际上定义了整个项目的架构。engine/shortcut/pinyin/lexicon/user-word 这些模块的边界,不是某天我坐下来精心设计出来的——是被 bug 一个个"雕刻"出来的。这篇讲这个过程。
架构不是设计出来的,是被 bug 雕刻出来的

刚开始写的时候,我的"架构"是这样的:
engine.py一个文件 800 行。pinyin.py里直接调trie.lookup()。- 用户词典、词频、Rime 词典加载,全堆在
lexicon.py一个文件里。 - shortcut 直接 if/elif 链。
没有 InputMode 协议,没有 KeyResult 契约,没有"模式可插拔"这种抽象。然后 bug 来了——每一个 bug 都逼着我抽出一层抽象。
第一个 bug 逼出 InputMode 协议:ae1084b
Bug:用户按 Shift+空格(commit_first),拼音 buffer 没提交,反而把空格送到了应用里。
原因:shortcut 路径在 mode 路径之后。当拼音模式看到空格时,它自己消费了空格(handled=True),commit_first shortcut 永远拦不到。
修复:把 shortcut 挪到 mode 之前。这一个改动让我意识到——引擎必须是一个能容纳多种 input mode 的容器。于是我开始想:什么是所有 mode 都必须遵守的接口?
> InputMode 协议就是从这一个挪位 bug 里长出来的。它不是设计阶段的产物,是 bug 修复时被强制定义的。
KeyResult 契约
所有 mode 必须用同一种方式告诉引擎"这个键怎么处理"。于是:
KeyResult 定义
@dataclass
class KeyResult:
handled: bool = False # True = 引擎吃掉这个键
commit: str | None = None # 上屏文本
clear: bool = False # True = 引擎在 commit 后调用 mode.reset()三个字段,定义了三件事:要不要传给应用、要不要上屏、要不要清 buffer。这个契约后来成为整个项目扩展性的基石——加 emoji mode、Google Pinyin mode 时,engine.py 一行没改。
第二个 bug 逼出 Lexicon 模块:215a797
Bug:第一次启动用了某个词典,重启想换词典,必须改代码重启 engine。
修复:在 main.py 里加 Gio.FileMonitor,监控 config.toml。文件变了,reload Config,重建 Lexicon,重建 ShortcutManager。
这让我意识到——Lexicon 必须是个独立的、可替换的对象,不能是 engine 的一部分。所以 Lexicon 抽出来了:
Lexicon 抽象
@dataclass
class Lexicon:
pinyin_trie: Trie
wubi_trie: Trie
user_words: dict[str, int] # code -> freq
_dirty: bool
_user_words_path: Path第三个 bug 逼出 try/except 隔离层:cddcb40
Bug:任何生命周期 hook 抛异常 → daemon 自杀。
修复:每个 public do_*() 方法都用 try/except 包一层,里面调 _do_*_impl()。
这个修复直接塑造了 engine.py 的代码风格——每个 public hook 都是 4 行薄包装:
crash barrier 模板
def do_process_key_event(self, keyval, state):
try:
return self._do_process_key_event_impl(keyval, state)
except Exception:
log.exception("do_process_key_event crashed")
return True # 永远不让 daemon 自杀
def _do_process_key_event_impl(self, keyval, state):
# 真正的实现,可以放心抛异常这个模式后来在 commit 215a797(do_disable)和 881dabb(N5 type-check config)里反复出现——所有可能抛异常的地方都用这个模式封装。
第四个 bug 逼出 N5 类型校验:881dabb
Bug:用户在 config.toml 里写了 max_candidates = "9"(字符串),启动后崩溃。
原因:_merge() 直接 setattr(instance, key, value),没校验类型。Config dataclass 期望 int,进来个 str,整个 Lexicon 内部计算就乱了。
修复:_merge() 用 get_origin()/get_args() 解析 dataclass 字段的类型注解(Optional[T]、Union[A, B] 都要处理),类型不匹配就 log 并丢弃。
> 这个 commit 还顺手修了 candidate_10 的问题——按 0 选第 10 个候选(Rime/fcitx5 约定)。这个 fix 强迫所有 select() 实现都要绝对位置查找,不能依赖当前页的 offset。
第五个 bug 逼出原子持久化:d9c5f29
Bug:用户词典在崩溃时损坏——重启后所有学习记录丢失。
原因:之前的 user-word 持久化直接 open(path, 'w') 写,如果写到一半进程被杀,文件就半截。
修复(d9c5f29):
原子写模式
def _flush_now(self):
tmp = self._user_words_path.with_suffix('.tmp')
tmp.write_text(self._serialize())
os.replace(tmp, self._user_words_path) # POSIX 原子 rename这一改让 Lexicon 变成有状态的(dirty flag、debounce timer、atexit handler)。但更重要的是:Lexicon 不再是只读的查询对象,它有了写入路径、加了学习流程。架构从"查找器"演化成了"状态机"。
第六个 bug 逼出模块拆分:89f4984
Bug:lexicon.py 已经 311 行——混着 trie、文件 IO、TSV 解析、Rime 词典加载、用户词典管理。写测试时无法只测 trie 算法(必须 mock 文件系统)。
修复:拆成 lexicon/ 子包:
_trie.py—— 纯数据结构,零 IO。_persistence.py—— 文件读写。__init__.py—— Lexicon 类 + 重导出。
14 个 trie 算法测试一字未改全部通过。这一拆让 base.py 终于可以放完整的协议文档了——之前 base.py 只有 50 行,现在 224 行,全是契约说明。
从 bug 到架构的映射
> 每个核心模块都对应一个具体的 bug:
- InputMode 协议 ← ae1084b(shortcut 顺序 bug)
- Lexicon 抽象 ← 215a797(配置热重载 bug)
- crash isolation ← cddcb40(daemon 自杀 bug)
- 类型校验 ← 881dabb(max_candidates 字符串 bug)
- 原子持久化 ← d9c5f29(用户词典损坏 bug)
- 模块拆分 ← 89f4984(测试困难 bug)
本篇小结

- 架构不是设计出来的——是被 bug 一个个雕刻出来的。
- 每个抽象层都对应一个具体的 bug 修复 commit。
- 测试驱动是这个过程的关键——bug 第一次出现时没测试兜底,第二次出现时一定会有。
- 从
4bee4ca到89f4984,6 个 commit 把"一个能跑的引擎"变成了"一个可扩展的引擎框架"。
> 下一篇:拼音模式:trie、词频、用户词典。讲讲 trie 这个数据结构是怎么在 110 行内撑起整个拼音查询的,以及为什么"无 IO"是这个设计的关键。
评论