上一篇我们聊了如何从零搭建一个多进程隔离的现代 Windows TSF 输入法架构:《从零打造一款“真正属于自己”的现代Windows 输入法:人人(RenRen IME) 架构设计与工程实战》。
架构立住之后,真正决定一款输入法能不能“日用”的,往往不是宏大的设计图,而是启动那一秒的延迟、打字流里顺不顺手,以及在宿主进程随时可能退出的极端情况下会不会死锁或丢字。这篇文章记录了 PersonalIME v1.1.4 在冷启动异步预热、打字流内表情联想以及并发/卸载安全方面的工程实战与几轮代码 Review 踩坑复盘。
一、冷启动拆分与不可变词库快照:首字不等待、不吞键
在早期版本里,后台服务 ime-service.exe 启动时会在一次 Initialize() 中顺序完成:
- 打开 SQLite 数据库并执行迁移;
- 加载十万级内置五笔/拼音二进制词库(实测五笔约 13.6 万条、拼音约 6.5 万条);
- 加载输入策略
InputPolicy、创建InputCoordinator并恢复未完成的 WAL pending 提交; - 开始监听命名管道接收 TSF 客户端请求。
这带来了一个很直观的体验问题:如果把词库加载也阻塞在管道监听之前,服务刚拉起的一两秒内客户端连不上;如果简单粗暴地把整个 Initialize() 丢到后台线程,又会导致策略、协调器和回执恢复也不可用,甚至出现边加载边查询的数据竞争。
最终落地设计
在 v1.1.4 中,我们把冷启动严格拆成两个阶段:
- 同步关键路径优先就绪:主线程先完成 SQLite 打开与迁移、策略加载、
InputCoordinator创建以及 WAL pending 状态恢复,随后立刻监听管道。这样即使内置词库还在后台预热,中英文/方案切换与挂起提交的终态回执已经完全可用。 - 不可变词库快照发布:后台预热线程在一个完全独立的
BuiltinDictionary对象中完成词典加载,完成后通过GetBuiltinDictSnapshot()发布只读的std::shared_ptr<const BuiltinDictionary>快照。所有查询路径(包括普通候选查询、Admin 管理端反查、句子恢复反查以及 CLI 调试器)统一在调用入口持有该不可变快照,彻底消除“预热完成标志无法保护并发读者”的读写竞态。 - 预热期间安全放行,绝不吞键:如果词库快照尚未发布时用户按下了按键,服务端在修改任何编码状态前检查就绪状态,未就绪则明确返回“未处理”,不缓存按键、不阻塞线程、也不在预热结束后突然补打,直接交给宿主原样处理。
二、产品取舍:放弃独立悬浮面板,做打字流里的“末尾表情槽位((N - 1) 词 + 1 表情)”
最初我们其实设计过一套完整的“无焦点快捷内容面板”(包含表情、贴纸和长签名)。但在真实写作场景下重新审视后,很快意识到两个问题:
- 常见聊天工具或富文本输入框旁边,往往已经自带成熟的表情/贴纸选择器;
- 如果要在无焦点面板里“先清掉当前输入编码,再把所选内容上屏”,一旦中间环节失败,就会产生编码被删掉但文本没插进去的丢字中间态。
于是我们决定做减法:把需求收敛成最贴近打字流的高频场景——在候选框最后一格顺手联想一个表情。
确定后的交互与工程规则
- 最后一位动态占位,普通词顺延:由于 Manager 中支持配置每页候选数量
N(5~9 个),当表情联想总开关开启且命中有效映射时,自动腾出当前页最后一格展示表情——即(N - 1) 词 + 1 表情(例如每页 5 项时为4 词 + 1 表情,每页 9 项时为8 词 + 1 表情);原本的第N个普通词顺延到下一页,绝不丢弃普通候选。当总开关关闭或当前无匹配时,仍完整显示N个普通词。 - 确定性映射,不做模糊语义猜测:支持“显式编码映射优先,其次首选普通候选正文精确匹配”,去重后每页最多展示 1 个表情,避免候选框变成表情包列表。
- 严格的上屏边界:表情必须通过数字键、鼠标点击或明确选中后确认上屏;不参与自动上屏、不参与普通词频学习,也不参与自动造词。
- 显式候选类型 + 服务端页面快照:
- 在 IPC 协议中显式下发
IpcCandidateType,TSF 客户端解析到候选显示快照中用于渲染轻量角标([表情]),绝不通过字符串前缀或正文去猜测候选类型。 - 加入特殊槽位后,原来的
page_index * page_size + index下标算法失效。改为由服务端为当前页生成显式槽位映射快照并附带规则修订号;一旦用户在 Manager 里增删改规则或切换总开关,旧快照立即失效。旧页面上的确认按键会被拒绝,同时消费本次确认键并向客户端推送刷新后的新页面快照,避免旧页面卡死且透传数字键进宿主。
- 在 IPC 协议中显式下发
- 边界校验先行:Manager 端与仓储层在新增、修改和创建 pending 提交前,统一校验正文非空、不含嵌入
NUL,且不超过协议单次提交上限(1023 个 UTF-16 单元),防止出现“数据库存了一段长文、实际却被截断提交”的不一致。
三、魔鬼在细节里:几轮 Review 抓出的并发与生命周期隐患
功能跑通之后,我们做了几轮针对边界和并发的集中 Review,下面这两个坑非常有代表性:
1. Windows DLL 诊断日志与 DllMain Loader Lock 陷阱
为了在不影响打字手感的前提下定位 TSF 时序问题,我们引入了一个默认关闭、零分配、有界环形缓冲的诊断日志器 BoundedTsfLogger:开启后由输入线程写入内存环形槽,再由后台线程批量落盘。
但 Windows DLL 的卸载规则极其苛刻:
- 模块保活与
DllCanUnloadNow:后台线程启动前必须通过GetModuleHandleExW增加 DLL 引用并计入DllAddRef(),线程退出时通过FreeLibraryAndExitThread释放自身引用,防止代码页被宿主提前卸载。 DllMain(DLL_PROCESS_DETACH)与静态析构严禁加锁或等待:Review 发现,哪怕在RequestShutdownAsync()里只是获取一次SRWLOCK共享锁去读取事件句柄再SetEvent,或者在~BoundedTsfLogger()里获取独占锁关闭句柄,一旦宿主进程退出过程中 OS 强行终止了另一个正持有该锁的线程,退出线程就会永久死锁。- 句柄生存期竞态:如果
RequestShutdownAsync()无锁原子读取HANDLE后再调用SetEvent,后台线程可能恰在此时退出并CloseHandle,造成向已关闭(甚至已被 OS 复用)的句柄发信号。
最终的极简无锁方案:
RequestShutdownAsync() 和 ~BoundedTsfLogger() 只做一件事——原子写入 m_shuttingDown = true。不获取任何锁、不等待线程、不触摸 HANDLE;后台线程最多在 200ms 轮询超时后观察到 m_shuttingDown 并由自己安全关闭 m_wakeEvent 退出。同时我们在 test_tsf_log_unload 中专门加入了多线程高频写日志持锁期间直接 ExitProcess(0) 的子进程超时验证,确保不会挂死。
2. WPF 管理端总开关的状态回退与“对账窗口期”
Manager 端的“打字流末尾表情联想”总开关看似只是一个 CheckBox,但在异步 IPC 下很容易写出状态撕裂:
- 断线或服务端拒绝保存时,如果只在界面弹一个错误提示而没有回退
IsAssociationEnabled,界面会显示“已关闭”,实际上服务端依然生效; - 用户快速连续点两下开关,如果直接 fire-and-forget 两个并发 IPC 请求,后发先至就会导致前后端状态永久错位;
- 更隐蔽的一个竞态是:第一次保存失败 → 进入异步向服务端重读对账(
GetEmojiMappingsAsync)并挂起 → 用户在挂起期间再次点击了开关。如果对账返回后无条件覆盖IsAssociationEnabled并退出保存循环,不仅会把用户的新操作覆盖掉,还会让新目标直接丢失。
我们的做法是在 EmojiMappingsViewModel 中把已确认状态 _confirmedAssociationEnabled 和待保存队列 _pendingAssociationTarget 彻底分开:
- 未连接时立刻静默回退到
_confirmedAssociationEnabled并提示警告; - 正常连接时通过单飞队列
DrainAssociationSaveQueueAsync()串行排空最新目标,避免并发 IPC; - 失败触发对账后,只有当
_pendingAssociationTarget == null时才允许用对账结果刷新 UI,且如果对账期间产生了新目标,while循环会继续把它排空提交。
四、测试与下载体验
截至 v1.1.4 发布,本轮涉及的核心重构与边界场景均已补齐自动化回归:
- 默认全量 C++ Release 构建(含
ime-core、ime-service、ime-cli、TSF DLL 及全部测试目标)0 错误通过; .NET管理端单测 77/77 通过(含断线回退、服务端拒绝回退、连续切换串行化及对账期间新切换保留);test_tsf_log_unload(15 轮反复加载卸载零句柄泄漏 + 3 轮并发日志ExitProcess超时验证)、test_composition_lifecycle、test_ime_core、test_admin_ipc以及test_stage3_integration(23/23 端到端用例,含延迟/失败词库预热、表情槽位翻页及旧快照失效刷新)全部通过。
如果你对轻量、可控、本地优先的 Windows 输入法感兴趣,欢迎下载 v1.1.4 安装体验或查看源码:
- Release 下载(v1.1.4):https://github.com/maifeipin/PersonalIME/releases/tag/v1.1.4
- GitHub 仓库:https://github.com/maifeipin/PersonalIME

