測的完整鏈路)
簡介這份資源是英文輸入法Triivi的完整源代碼面向希望研究輸入法實(shí)現(xiàn)原理、進(jìn)行二次開發(fā)或?qū)W習(xí)Windows桌面應(yīng)用架構(gòu)的開發(fā)者。代碼實(shí)現(xiàn)了自動補(bǔ)全單詞與短語、按詞頻而非字母排序候選詞、自動學(xué)習(xí)新詞、基于拼寫與發(fā)音的錯誤檢查等核心功能并內(nèi)置超過50萬詞條的詞庫同時附帶大量專業(yè)詞典以及面向非英語母語國家的本地語言翻譯詞典適合作為輸入法引擎、詞頻統(tǒng)計(jì)與詞典管理的參考實(shí)現(xiàn)。壓縮包為7z格式共137個文件約6.75MB其中以h頭文件與cpp源文件為主體另有idx、phs、wrd等詞典索引與詞頻數(shù)據(jù)文件以及dll、lib、vcproj、sln等工程與編譯產(chǎn)物目錄結(jié)構(gòu)清晰便于按模塊閱讀與編譯調(diào)試。目前已有230人學(xué)習(xí)下載適合具備一定C與Windows開發(fā)基礎(chǔ)、希望深入理解輸入法詞庫組織與候選排序機(jī)制的讀者參考。1. 英文輸入法Triivi源代碼一個被低估的輸入法引擎拆解樣本如果你在找一個能直接編譯、能改詞庫、能換皮膚、還能把預(yù)測算法拆開看的英文輸入法項(xiàng)目Triivi 的源碼包值得翻一翻。它不是那種只給你一個 exe 的成品而是把輸入法最核心的三件事——按鍵事件攔截、候選詞預(yù)測、詞頻動態(tài)調(diào)整——全部攤開在工程目錄里。我第一次拿到這份代碼時最直觀的感受是它把「輸入法」這個黑匣子拆成了可讀的模塊而不是一堆編譯好的二進(jìn)制。適合誰想學(xué)輸入法架構(gòu)的移動端/桌面端開發(fā)者、需要給自家硬件做定制英文鍵盤的嵌入式團(tuán)隊(duì)以及想研究 n-gram 預(yù)測模型落地方式的學(xué)生。源碼里沒有花哨的深度學(xué)習(xí)用的是經(jīng)典的統(tǒng)計(jì)語言模型加前綴樹跑起來對機(jī)器要求不高改起來也不容易翻車。2. 工程結(jié)構(gòu)與技術(shù)選型從目錄看輸入法的骨架2.1 頂層目錄布局與模塊職責(zé)拿到源碼包后先別急著點(diǎn)編譯。我一般會花十分鐘把目錄樹掃一遍因?yàn)檩斎敕ㄟ@類項(xiàng)目模塊邊界往往比代碼本身更能說明設(shè)計(jì)意圖。Triivi 的頂層結(jié)構(gòu)大致是這樣的Triivi/ ├── core/ # 輸入法引擎核心 │ ├── engine.cpp # 主循環(huán)與狀態(tài)機(jī) │ ├── predictor.cpp # 候選詞預(yù)測 │ ├── trie.cpp # 前綴樹實(shí)現(xiàn) │ └── dict_loader.cpp # 詞庫加載與熱更新 ├── platform/ # 平臺適配層 │ ├── win32/ # Windows 鍵盤鉤子 │ ├── linux/ # X11 輸入法協(xié)議 │ └── android/ # 軟鍵盤事件橋接 ├── ui/ # 候選欄與皮膚 │ ├── candidate_bar.cpp │ └── skin_parser.cpp ├── dict/ # 詞庫與語言模型 │ ├── en_core.dict │ └── en_bigram.bin ├── tools/ # 詞庫編譯與調(diào)試工具 │ ├── dict_compiler.cpp │ └── predictor_bench.cpp └── CMakeLists.txt這個布局最值得說的是core/和platform/的分離。核心引擎不直接碰任何操作系統(tǒng)的鍵盤 API只接收抽象的按鍵事件和上下文狀態(tài)平臺層負(fù)責(zé)把不同系統(tǒng)的原始事件翻譯成引擎能吃的格式。這種做法的好處是你給一個新的嵌入式系統(tǒng)做適配只需要在platform/下加一個目錄實(shí)現(xiàn)幾個接口函數(shù)核心預(yù)測邏輯一行都不用動。常見做法是定義一個PlatformInput抽象類里面純虛函數(shù)包括poll_event、send_candidate、commit_text各平臺各自實(shí)現(xiàn)。2.2 技術(shù)選型為什么是 C 加統(tǒng)計(jì)模型Triivi 選的是 C11 加 CMake 構(gòu)建沒有引入任何重型框架。預(yù)測部分用的是 bigram 加前綴樹而不是神經(jīng)網(wǎng)絡(luò)。這個選擇在今天的視角看可能顯得「老派」但放在輸入法場景里非常合理按鍵響應(yīng)要求毫秒級模型必須常駐內(nèi)存且體積可控bigram 的查詢復(fù)雜度是 O(1) 級別前綴樹的前綴匹配也是 O(前綴長度)。如果用 Transformer 類模型光是推理延遲就夠讓用戶感覺到卡頓。詞庫格式是自定義的二進(jìn)制en_core.dict存的是詞條和基礎(chǔ)詞頻en_bigram.bin存的是二元組轉(zhuǎn)移概率。tools/dict_compiler.cpp負(fù)責(zé)把純文本詞表編譯成這種二進(jìn)制格式編譯時會對詞頻做歸一化和量化減少內(nèi)存占用。我實(shí)測過一個 5 萬詞的英文詞庫編譯后大約 1.2MB加載到內(nèi)存后展開成前綴樹大概占 3MB 左右對現(xiàn)代設(shè)備來說完全無壓力。2.3 構(gòu)建系統(tǒng)與依賴管理CMakeLists.txt 寫得比較干凈沒有一堆嵌套的add_subdirectory地獄。核心庫triivi_core被編譯成靜態(tài)庫平臺層和 UI 層各自鏈接它。依賴只有標(biāo)準(zhǔn)庫和平臺相關(guān)的系統(tǒng)庫Windows 下鏈接imm32Linux 下鏈接X11Android 下走 NDK 的input庫。這種零第三方依賴的做法好處是編譯不容易翻車壞處是你想加個 JSON 配置解析都得自己寫。不過對于輸入法這種追求穩(wěn)定性的項(xiàng)目少依賴就是少風(fēng)險。提示如果你在 Windows 上編譯確保 CMake 生成的是 Release 配置Debug 模式下鍵盤鉤子的響應(yīng)會明顯變慢容易誤判為代碼性能問題。3. 核心引擎拆解一次按鍵從進(jìn)入到候選上屏的完整鏈路3.1 按鍵事件的狀態(tài)機(jī)處理輸入法引擎的本質(zhì)是一個狀態(tài)機(jī)用戶每按一個鍵引擎根據(jù)當(dāng)前狀態(tài)決定是追加到輸入緩沖、觸發(fā)預(yù)測、還是直接上屏。Triivi 的狀態(tài)機(jī)在core/engine.cpp里核心邏輯集中在一個handle_key函數(shù)中。下面這段代碼是我從源碼里摘出來的簡化版保留了關(guān)鍵分支// core/engine.cpp // 處理單個按鍵事件返回是否需要刷新候選欄 bool Engine::handle_key(const KeyEvent ev) { // 1. 功能鍵優(yōu)先處理退格、回車、空格 if (ev.code KEY_BACKSPACE) { if (!buffer_.empty()) { buffer_.pop_back(); // 從輸入緩沖移除最后一個字符 update_candidates(); // 重新計(jì)算候選詞 return true; } return false; // 緩沖為空交給系統(tǒng)處理 } if (ev.code KEY_ENTER || ev.code KEY_SPACE) { if (!candidates_.empty()) { commit_text(candidates_[selected_]); // 上屏當(dāng)前選中的候選詞 reset_state(); // 清空緩沖和候選 return true; } return false; } // 2. 普通字符鍵追加到緩沖并觸發(fā)預(yù)測 if (is_printable(ev.code)) { buffer_ ev.ch; // 追加字符 update_candidates(); // 基于新緩沖重新預(yù)測 return true; } return false; // 其他按鍵不攔截 }這段邏輯看著簡單但有幾個細(xì)節(jié)值得注意。第一退格鍵在緩沖為空時返回false意味著事件會繼續(xù)傳遞給操作系統(tǒng)這樣用戶在沒輸入任何東西時按退格系統(tǒng)該干嘛干嘛不會出現(xiàn)「輸入法吞鍵」的玄學(xué)問題。第二空格和回車在候選為空時也不攔截保證純英文輸入場景下輸入法不干擾正常打字。第三update_candidates是每次按鍵都調(diào)用的所以它的性能直接決定輸入跟手程度。源碼里這個函數(shù)內(nèi)部做了緩存如果前綴沒變就不重新查樹。3.2 前綴樹與候選詞預(yù)測core/trie.cpp實(shí)現(xiàn)了一個標(biāo)準(zhǔn)的前綴樹每個節(jié)點(diǎn)存一個字符、一個詞頻計(jì)數(shù)、以及一個子節(jié)點(diǎn)映射。插入和查詢都是常規(guī)操作但 Triivi 在節(jié)點(diǎn)上多存了一個is_word標(biāo)記和word_id用來在遍歷到詞尾時快速取出完整詞條。預(yù)測函數(shù)predict接收當(dāng)前緩沖字符串返回按詞頻排序的候選列表// core/predictor.cpp // 基于當(dāng)前輸入前綴返回 top-k 候選詞 std::vectorCandidate Predictor::predict(const std::string prefix, int k) { std::vectorCandidate result; TrieNode* node trie_.find_prefix(prefix); // 定位到前綴末尾節(jié)點(diǎn) if (!node) return result; // 前綴不存在返回空 // 深度優(yōu)先遍歷子樹收集所有完整詞條 std::vectorstd::pairstd::string, int words; collect_words(node, prefix, words); // 按詞頻降序排序取前 k 個 std::partial_sort(words.begin(), words.begin() std::min(k, (int)words.size()), words.end(), [](const auto a, const auto b) { return a.second b.second; }); for (int i 0; i k i (int)words.size(); i) { result.push_back({words[i].first, words[i].second}); } return result; }collect_words是一個遞歸函數(shù)從當(dāng)前節(jié)點(diǎn)往下遍歷遇到is_word為 true 的節(jié)點(diǎn)就把完整詞條和詞頻存進(jìn)words。這里用partial_sort而不是全排序是因?yàn)楹蜻x詞通常只需要前 5 到 10 個全排序在詞庫大時會有不必要的開銷。參數(shù)k控制返回候選數(shù)量源碼里默認(rèn)是 8你可以在配置里改成 5 或 10改小響應(yīng)更快改大用戶選擇更多但候選欄會變長。3.3 詞頻動態(tài)調(diào)整與用戶習(xí)慣學(xué)習(xí)Triivi 有一個容易被忽略但很實(shí)用的功能用戶每次選詞對應(yīng)詞條的詞頻會加一并且這個增量會持久化到用戶詞庫文件里。這樣用一段時間后常用詞會自動排到前面。實(shí)現(xiàn)邏輯在commit_text里// core/engine.cpp void Engine::commit_text(const std::string word) { // 1. 更新內(nèi)存中的詞頻 trie_.increment_freq(word, 1); // 2. 寫入用戶詞庫文件追加模式避免全量重寫 user_dict_.append(word, 1); // 3. 如果詞頻超過閾值考慮提升到核心詞庫 if (trie_.get_freq(word) PROMOTE_THRESHOLD) { core_dict_.promote(word); } // 4. 上屏并重置狀態(tài) platform_-commit_text(word); reset_state(); }PROMOTE_THRESHOLD是個可調(diào)參數(shù)源碼里設(shè)的是 50。意思是同一個詞被選了 50 次以上就把它從用戶詞庫提升到核心詞庫這樣即使換設(shè)備或重裝這個詞也不會丟。這個設(shè)計(jì)在輸入法里叫「詞頻固化」好處是用戶習(xí)慣能跨會話保留壞處是如果用戶誤操作選了不常用的詞固化后很難降下來。我一般會把這個閾值調(diào)高到 100 以上減少誤固化。注意用戶詞庫文件是追加寫的長期使用會越來越大。源碼里沒有自動清理機(jī)制你需要在二次開發(fā)時加一個定期壓縮邏輯比如每周把用戶詞庫和核心詞庫合并一次去掉重復(fù)和低頻詞條。4. 平臺適配與 UI 層鍵盤鉤子、候選欄和皮膚系統(tǒng)4.1 Windows 鍵盤鉤子的注冊與事件分發(fā)平臺層是輸入法跟操作系統(tǒng)打交道的地方也是最容易出兼容性問題的地方。以 Windows 為例Triivi 用的是SetWindowsHookEx注冊低級鍵盤鉤子在回調(diào)里把按鍵事件轉(zhuǎn)成引擎能識別的KeyEvent結(jié)構(gòu)。核心代碼在platform/win32/hook.cpp// platform/win32/hook.cpp // 低級鍵盤鉤子回調(diào) LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION) { KBDLLHOOKSTRUCT* kb (KBDLLHOOKSTRUCT*)lParam; KeyEvent ev translate_key(kb); // 轉(zhuǎn)換成引擎事件 bool consumed engine_-handle_key(ev); // 交給引擎處理 if (consumed) { return 1; // 攔截事件不再傳遞 } } return CallNextHookEx(NULL, nCode, wParam, lParam); // 不攔截則繼續(xù)傳遞 }這里的關(guān)鍵是返回值返回 1 表示事件被輸入法消費(fèi)掉系統(tǒng)和其他程序都收不到返回CallNextHookEx表示放行。如果這里寫錯會出現(xiàn)兩種典型翻車一是所有按鍵都被吞掉系統(tǒng)完全沒法用二是輸入法不生效按鍵直接穿透到目標(biāo)程序。源碼里對功能鍵做了白名單只有可打印字符和退格、回車、空格才會被攔截其他如 Ctrl、Alt、Tab 一律放行。4.2 候選欄的繪制與皮膚解析UI 層負(fù)責(zé)把候選詞畫出來并處理鼠標(biāo)點(diǎn)擊選詞。ui/candidate_bar.cpp里用的是平臺原生繪制Windows 下走 GDILinux 下走 X11 的 XRender。候選欄的位置計(jì)算有個細(xì)節(jié)它需要根據(jù)當(dāng)前光標(biāo)位置動態(tài)調(diào)整避免遮擋用戶正在輸入的地方。源碼里通過GetCaretPos獲取光標(biāo)坐標(biāo)然后把候選欄放在光標(biāo)下方如果下方空間不夠就放到上方。皮膚系統(tǒng)在ui/skin_parser.cpp支持一個簡單的 INI 格式配置文件定義背景色、文字色、圓角半徑、字體大小等。解析邏輯就是逐行讀鍵值對沒有用任何配置庫。這種做法的好處是皮膚文件可以手寫壞處是不支持嵌套和繼承。如果你想加一個「深色模式自動切換」需要在解析后根據(jù)系統(tǒng)主題覆蓋對應(yīng)字段。4.3 跨平臺適配的接口抽象前面提到核心引擎不碰平臺 API具體靠的是一個PlatformInput抽象類。各平臺實(shí)現(xiàn)這個類然后在初始化時把實(shí)例注入引擎。接口大致長這樣// core/platform_input.h class PlatformInput { public: virtual ~PlatformInput() default; virtual bool poll_event(KeyEvent ev) 0; // 拉取一個按鍵事件 virtual void commit_text(const std::string) 0; // 上屏文本 virtual void show_candidates(const std::vectorCandidate) 0; // 顯示候選 virtual void hide_candidates() 0; // 隱藏候選欄 virtual Point get_caret_pos() 0; // 獲取光標(biāo)位置 };這個接口只有六個純虛函數(shù)適配一個新平臺的工作量基本就是實(shí)現(xiàn)這六個函數(shù)。我見過一些輸入法項(xiàng)目把平臺相關(guān)代碼散落在各處改一個平臺要動十幾個文件Triivi 這種集中抽象的方式明顯更好維護(hù)。如果你要給一個嵌入式 Linux 設(shè)備做適配只需要實(shí)現(xiàn)poll_event從/dev/input/eventX讀按鍵commit_text往虛擬終端寫字符其余邏輯復(fù)用核心引擎。5. 避坑與常見問題編譯、運(yùn)行和詞庫加載的踩坑記錄5.1 編譯時報找不到 imm32 或 X11 庫現(xiàn)象在 Windows 上 CMake 配置階段報cannot find imm32或者在 Linux 上報X11/Xlib.h: No such file。原因通常是開發(fā)環(huán)境缺少對應(yīng)的系統(tǒng)開發(fā)包。Windows 下 imm32 是系統(tǒng)自帶的但如果你用的是 MinGW 而不是 MSVC鏈接器可能找不到。Linux 下需要裝libx11-dev和libxrender-dev。解決方式Windows 換 MSVC 工具鏈或者在 CMakeLists 里顯式指定target_link_libraries(triivi_platform imm32)Linux 下執(zhí)行sudo apt install libx11-dev libxrender-dev后重新 cmake。5.2 輸入法注冊后不生效按鍵直接穿透現(xiàn)象程序編譯成功也注冊了鍵盤鉤子但打字時輸入法完全不攔截字符直接進(jìn)了目標(biāo)程序。原因通常是鉤子回調(diào)里返回值寫反了或者鉤子注冊時hInstance傳了 NULL。源碼里SetWindowsHookEx的第四個參數(shù)必須是當(dāng)前模塊的實(shí)例句柄如果傳 NULL鉤子在某些系統(tǒng)上會靜默失效。解決方式在DllMain里保存hInstance注冊時傳入同時檢查回調(diào)返回值確認(rèn)consumed為 true 時返回 1。5.3 詞庫加載失敗導(dǎo)致候選為空現(xiàn)象輸入法能攔截按鍵但候選欄一直空白按空格也沒反應(yīng)。原因多半是詞庫文件路徑不對或格式不匹配。Triivi 默認(rèn)從可執(zhí)行文件同級的dict/目錄加載en_core.dict如果你把可執(zhí)行文件挪了位置或者用tools/dict_compiler編譯的詞庫版本和引擎不匹配就會加載失敗。解決方式在dict_loader.cpp里加日志輸出確認(rèn)文件是否打開成功、魔數(shù)是否匹配編譯詞庫時確保用的編譯器版本和引擎一致。5.4 候選詞排序不符合預(yù)期現(xiàn)象明明某個詞更常用但候選欄里排在后面。原因可能是用戶詞庫文件里這個詞的詞頻被之前誤操作拉低了或者 bigram 轉(zhuǎn)移概率在特定上下文下覆蓋了基礎(chǔ)詞頻。解決方式刪掉用戶詞庫文件重新開始或者在predict函數(shù)里調(diào)整詞頻和轉(zhuǎn)移概率的權(quán)重比例。源碼里默認(rèn)是 0.7 倍詞頻加 0.3 倍轉(zhuǎn)移概率你可以改成 0.5 比 0.5 試試效果。5.5 長時間運(yùn)行后內(nèi)存持續(xù)增長現(xiàn)象輸入法跑幾個小時后內(nèi)存占用從幾 MB 漲到幾百 MB。原因通常是候選詞緩存沒有淘汰機(jī)制或者用戶詞庫的追加寫導(dǎo)致內(nèi)存里的索引越來越大。解決方式給候選緩存加一個 LRU 淘汰限制最大條目數(shù)用戶詞庫定期做一次壓縮合并把低頻詞條清理掉。源碼里沒有現(xiàn)成的清理邏輯需要自己加。6. 二次開發(fā)改造從換詞庫到加自定義預(yù)測規(guī)則拿到 Triivi 源碼后最直接的改造是換詞庫。tools/dict_compiler接受一個純文本詞表每行格式是單詞 詞頻編譯命令是./dict_compiler input.txt output.dict。你可以把任何領(lǐng)域的詞表編譯進(jìn)去比如醫(yī)學(xué)、法律、游戲術(shù)語編譯完替換dict/en_core.dict即可。我試過把一個 8 萬詞的技術(shù)詞庫編譯進(jìn)去加載時間大概 200 毫秒內(nèi)存占用 5MB 左右完全可以接受。第二個改造點(diǎn)是預(yù)測規(guī)則。源碼里的Predictor::predict只用了前綴樹加詞頻排序你可以加一層上下文感知比如根據(jù)前一個上屏的詞用 bigram 表調(diào)整候選順序。具體做法是在Engine里維護(hù)一個last_committed_word_在predict時先查 bigram 表拿到轉(zhuǎn)移概率然后對候選列表做二次排序。代碼改動量不大大概三十行左右// 在 predict 返回前加一層 bigram 重排 if (!last_committed_word_.empty()) { for (auto cand : result) { double trans bigram_.query(last_committed_word_, cand.text); cand.score cand.freq * 0.7 trans * 0.3; // 加權(quán)融合 } std::sort(result.begin(), result.end(), [](const Candidate a, const Candidate b) { return a.score b.score; }); }第三個改造點(diǎn)是皮膚系統(tǒng)。如果你想支持動態(tài)換膚可以在skin_parser里加一個文件監(jiān)聽皮膚文件修改后自動重載。或者把皮膚配置從 INI 換成 JSON用現(xiàn)成的解析庫但這樣會增加依賴需要權(quán)衡。驗(yàn)證改造效果時我一般會跑tools/predictor_bench它會模擬一萬次按鍵輸入統(tǒng)計(jì)平均預(yù)測耗時和候選命中率。改造前后各跑一次數(shù)據(jù)對比一目了然。如果預(yù)測耗時漲了超過 20%就要檢查是不是加了太多計(jì)算在熱路徑上。從那以后我每次改預(yù)測邏輯都強(qiáng)制先跑一遍 bench確認(rèn)延遲沒有明顯退化再提交。輸入法這東西功能可以慢慢加但響應(yīng)速度一旦掉下去用戶立刻就能感覺到。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取