> 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 定义

Python
@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 抽象

Python
@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 模板

Python
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):

原子写模式

Python
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 第一次出现时没测试兜底,第二次出现时一定会有。
  • 4bee4ca89f4984,6 个 commit 把"一个能跑的引擎"变成了"一个可扩展的引擎框架"。

> 下一篇拼音模式:trie、词频、用户词典。讲讲 trie 这个数据结构是怎么在 110 行内撑起整个拼音查询的,以及为什么"无 IO"是这个设计的关键。

最后修改: 2026年7月1日

作者

评论

发表评论

您的邮箱地址不会被公开。