程式架構 - ShikiSuen/vChewing-macOS Wiki

威注音輸入法 macOS 版基於小麥注音引擎研發,整體程式碼數量大約在兩萬五千行以內(估算),僅使用 (Objective-)C++ 與 Swift 完成研發(macOS 版詞庫編譯對 Python 沒有任何依賴)。若需要理解該專案構成的話,不只這篇文章,恐怕您還需要一些面向對象/物件的!程式設計知識。

威注音輸入法一開始是為了滿足簡體中文母語者的注音輸入需求、也能同時滿足兩岸使用者對簡體中文與繁體中文的準確輸入需求。在此基礎上:

  • 威注音剔除了小麥注音的個別「實際上並不需要、或者說可能會有問題」的小功能與設計,且新增了一些功能,以便細化產品使用體驗。但這並沒有讓架構變得更複雜。
  • 為了確保威注音輸入法的中立性,威注音輸入法啟用了專門製作的原廠語彙庫。
  • 為了盡量徹底杜絕繁簡轉換可能帶來的失真,威注音輸入法針對簡體與繁體中文模式做了原廠語料庫分離處理。

小麥注音引擎自 2021 年底被 Zonble Yang 重新調整了整個架構,且 Lukhnos Liu 使用 C++17 重寫了 Gramambular 與 Mandarin 模組。加上威注音自行重寫了經過這次重構,目前小麥注音的主程式引擎部分所剩的 MJHsieh 的程式碼已所剩無幾。各大編程語言在威注音專案內的用途如下:

  • Swift:

    • 呼叫 InputMethodKit 等 macOS 系統 API;
    • 威注音特有的模組:
      • Apple 動態注音鍵盤支援模組;
      • 音效模組;
      • 康熙 / JIS 漢字轉換模組;
      • 基於 Swift 腳本的詞庫編譯模組(用來取代 Python 腳本)。
      • 使用者語彙編輯器。
    • 選字窗、飄雲通知窗等使用者介面呈現。
  • C++(部分內容因需要存取 Swift 內容、而更名為 Objective-C++ 格式):

    • Lukhnos Liu 寫的 Mandarin 模組,用來做注拼轉換、Syllable Composer、以及倚天忘形等複合注音鍵盤配列的支援,也具備對大千等非複合注音鍵盤的支援。
      • 威注音在此基礎上新增了對神通與偽精業鍵盤配列的支援。
    • Lukhnos Liu 寫的 Gramambular 模組套裝,包含了對諸如語言模型等核心語言處理型別的原始定義。
      • 在此基礎上完成的各種子語言模組型別。
    • 威注音特有的 LMConsolidator 模組,可以自动整理使用者語言檔案內的格式(包括對 EOF 格式的修復)。相比小麥注音而言,威注音可以做到簡單的啟發式格式整理。
    • 上述內容雖有採 Objective-C++ 副檔名者,但僅僅是為了存取使用者設定才這樣做。所以,這些模組仍舊視為 C++ 模組。
      • 非 PKG 格式的安裝程式當中也有模組需要依賴 C++、藉此實現某些與檔案系統有關的判定操作,所以也使用了 Objective-C++ 格式。
  • Objective-C++:

    • 目前 Swift 與 C++ 之間很難直接協作,需要由 Objective-C++ 模組來做中轉對接處理。這也是為什麼 KeyHandler、mgrLangModel 等模組採 Objective-C++ 格式之原因。

本文從這一行開始的內容的著作權歸屬是 Zonble Yang,但有做過改動、以描述威注音的情況。

InputMethodKit 輸入法架構

在 macOS 上的輸入法架構叫做 InputMethodKit,以下簡稱 IMK。輸入法本身也是一套標準的 macOS 應用程式,用來打字的應用程式與輸入法之間是 client/server 的關係,每一個可以打字的 app 叫做 input client,輸入法則是 input server。在電腦開機,切換到某個應用程式打字,切換到某個輸入法的時候,作業系統就會在背景把輸入法應用程式叫起來。

輸入法 App 中,需要建立一個輸入法 server,我們將這段寫在 main 當中,也就是這段

let kConnectionName = "vChewing_1_Connection"
...
guard let bundleID = Bundle.main.bundleIdentifier, let server = IMKServer(name: kConnectionName, bundleIdentifier: bundleID) else {
    NSLog("Fatal error: Cannot initialize input method server with connection \(kConnectionName).")
    exit(-1)
}

輸入法 server 與 client 之間需要透過一個特定名稱的連線,如果你另外建立了一個輸入法專案,請注意不要與其他的輸入法的連線名稱發生衝突。

輸入法 server 收到來自 client 的鍵盤事件後,會將事件轉發給 Input Controller,要使用哪個 class 當作 Input Controller,寫在 Info.plist 當中。蘋果對提供 Input Controller 三種不同的方式處理鍵盤事件,像是直接拿到事件原本的資料自行處理,或是讓蘋果先幫你過濾出一些行為,然後讓你只需要實做某幾個 template method,小麥所做的選擇是處理全部的事件,這一段寫在 /Source/Modules/IMEModules/ctlInputMethod.swift-handleEvent:client: 內。

在這個 method 中,除了可以透過傳入的 NSEvent,知道使用者觸發了哪些硬體事件,另外可以拿到一個 client 物件。你可以把 client 想成代表使用者正在拿來打字的 app,你可以從這個物件中取得一些基本訊息,像是那個 app 的 bundle ID,而接下來輸入法要怎麼回應給正在打字的 app,像是更新輸入組字區、送出字元、移動游標,也全都要透過這個 client 物件傳遞。在這個處理鍵盤事件的 method 中,同時會用到小麥的輸入引擎,以及輸入法自己的 UI。

輸入引擎

輸入引擎大概分成三部份:

  • Tekkon (已取代之前的 OV Mandarin) 注拼引擎:負責將鍵盤按鍵轉換成對應的注音。標準、倚天、許氏、倚天 26 鍵、漢語拼音、華羅拼音、通用拼音、國音二式、耶魯拼音、神通、偽精業等鍵盤佈局,就是由 Tekkon 處理。(OV Mandarin 不支援華羅拼音、通用拼音、國音二式、耶魯拼音的輸入。)
    • 威注音另支援 macOS 原生動態注音鍵盤、以支援 macOS 內建的螢幕鍵盤。啟用該功能的話,標準與倚天佈局的處理將會由 macOS 自行負責、且會藉由威注音的 AppleKeyboardConverter 模組來將輸入的按鍵資訊翻譯為 Mandarin 模組可以理解的形式。
  • Megrez (已取代之前的 Gramambular) 組字引擎:
    • Language Model:負責提供語言模型資料。語言模型是指在某個語言當中有哪些字詞,而每個字詞可能會出現的機率。小麥當中的語言模型是由放在 Engine 目錄下的多個 class 組成的,每個 class 負責一部分字詞,包括輸入法本身所提供的詞彙,使用者自行建立的詞彙等,而最後統一由最上層的 McBopomofoLM 這個 facade 提供詞彙資料。
    • Compositor (舊稱 Grid Builder / Block Reading Builder),負責從將多組注音符號對應的文字中,建立整段注音組合可以對應到的、機率最高的文字結果。

這幾個部份的關係大概是:

  • ctlInputMethod (Input Method Controller) 收到鍵盤事件,先去讓 KeyHandler 問 Tekkon 確認這套按鍵組合是否可以拼出一個符合廣義上的可能的普通話漢字的讀音。
  • 如果 Tekkon 在上述判斷中給出了肯定的判定結果的話,那麼 KeyHandler 就拿著這套注音組合向目前輸入模式的總語言模型物件提出詢問、令其調查有沒有符合該注音組合的字詞。威注音能用哪些注音組合輸入漢字,在這裡完全由語言模組所讀入的資料內容而定。
  • 如果有符合的字詞,總語言模型就回傳這些符合的字詞以及相關概率。這些資料會被當作一個節點、插入至 Megrez Compositor 當中。
  • Compositor 算出結果後,送回 KeyHandler,再藉由 ctlInputMethod 送回目前正在接受文字輸入的 App。

完整流程如下圖:

欲擴充威注音之功能者:

  • 欲新添注音鍵盤配置者,請對 Tekkon 下手。不過 Tekkon 已經差不多徹底支援了市面上所有可能的既存的靜態注音鍵盤佈局就是了。
  • 如果您想要改進輸入法目前使用的演算法(像是輸入法目前只支援 Unigram,而還沒有支援 Bigram 等 Ngram 的能力),就得要調整各種 Language Model 型別、以及 Megrez Compositor。

UserOverrideModel

在上述的流程之外,還有一個叫做 UserOverrideModel 的半衰模組、會影響自動選字的結果。UserOverrideModel 的用途是「記住使用者最近打過什麼字」,UserOverrideModel 當中有一個使用 LRU 演算法的 cache,當使用者做了選字行為之後,會記住使用者最近選過了什麼,然後臨時增加這些字詞的機率權重。每次點輸入法選單來執行精簡整理命令,都會淨空 UserOverrideModel 當中的 Unigram 半衰資料、而保留 Bigram 與 Trigram 的資料。如有需要移除這類資料的需求的話,請在使用者語彙目錄內移除對應的 dat 檔案(JSON 格式)。

標點與符號

如果 Tekkon 認為某組按鍵並不是合法的注音符號,KeyHandler 就會向總語言模型物件詢問是否是標點符號。用以直接輸入的標點符號的處理並不在 KeyHander 當中、而是在 總語言模型物件內處理,所以目前的輸入法引擎在直接輸入的標點符號的處理上相當有彈性,如果您在修改程式碼時,想要修改標點符號的配置,可以直接從語料庫下手修改即可;而使用者其實也可以透過手動的加詞與刪詞表格、來調整標點符號的位置。

ctlInputMethod (States/Key Handler/State Handler)

目前小麥引擎在應用程式上層的架構受到像 Redux 等架構的啟發,重視狀態管理以及單向的資料流,也就是這樣的流程:

Key Event -> Key Handler -> State -> State Handler -> UI & Output

或著,可以用這種方式表達:

  • New State = Key Handler (New Key Event, Current State)
  • UI & Output = State Handler (New State)

所謂的狀態是指「輸入法此時此刻到底在做什麼」,以及與做這件事情有關的所需變數。其實小麥引擎的狀態處理都很直觀:比方說,使用者正在打輸入文字、正在選字、正在使用 Shift 以及左右鍵加詞…都是我們所謂的狀態。使用者在打字的過程,也是輸入法狀態不斷變化的過程,使用者打了一段文字,就會經歷以下的變化:

  • 未啟用狀態,使用者還沒有切到輸入法 // InputStateDeactivated
  • 使用者切到輸入法之後,還沒輸入文字,輸入法現在是空白狀態 // InputStateEmpty
  • 使用者開始打了一些文字,就進入輸入狀態 // InputStateInputting
  • 使用者在組字區內摁 SHIFT 的同時摁了方向鍵,進入標記狀態 // InputStateMarking
  • 使用者決定要選字,更改其中一兩個字,現在進入選字狀態 // InputStateChoosingCandidate
  • 使用者選字完成,從選字狀態退出,並且進入新的輸入狀態 (InputStateEmptyIgnoringPreviousState)
  • 使用者送出文字,先經歷送出中狀態 (InputStateCommitting),然後退回空白狀態 (InputStateEmpty)

Input.State 狀態物件有以下特性:

  • 一個狀態只會包含跟他有關的變數,像是在空白狀態中,就不會有選字列表這種只在選字狀態存在的變數
  • 一個狀態可以被新的狀態替換,但是這個狀態本身的變數是不可修改的

程式邏輯上的流程則是:

  • ctlInputMethod 保存目前的狀態;
  • ctlInputMethod 收到新的鍵盤事件之後,將按鍵事件與目前的狀態,送給 KeyHandler;
  • KeyHandler 根據這兩項輸入,丟出新的狀態或是錯誤;
  • ctlInputMethod 遇到錯誤時就發出錯誤提示聲(或者放屁),有新的狀態就丟給 InputState (State Handler);
  • InputState 根據新的狀態,更新 UI,或是將文字送到使用者正在打字的 app 中。

我們避免讓按鍵處理的邏輯,直接可以呼叫到 UI 、輸入組字區以及文字輸出。這麼做的理由是:

  • 這樣按鍵處理的邏輯就會有個明確的輸出結果,而不是什麼都往 UI 或是 IO 輸出,這樣才有辦法對按鍵處理邏輯作單元測試;
  • 跟 UI 更新有關的部分,也可以明確知道是為了哪個狀態而更新 UI。

[威注音補記] 論及 KeyHandler 對所有功能鍵的管理,威注音統一使用 KeyCode 進行判定、統一使用 KeyHandlerInput 進行管理,以確保判定結果萬無一失。

其他

目前的小麥注音在應用程式上層中還有用到其他套件,像是簡繁轉換、選字窗等,這部份就與一般的 macOS 開發無異。不過,OpenVanilla 小麥團隊希望能達到功能的模組化,所以一些可以拆出來單獨重複應用的程式,我們會拆出來做成 Swift Package,並且使用 Swift Package Manager (SPM) 管理。

[威注音補記] 然而,威注音將這些模組化的元件全都打散、改為直接使用的狀態。一是為了輸入法功能的深度整合(比如讓選字視窗根據當前輸入模式判定用哪種高亮背景顏色顯示當前選中的候選字、判定是該用簡體版蘋方還是該用繁體版蘋方字型來顯示候選字,等),二是為了試圖規避 Xcode 的某些奇奇怪怪的問題(且確實有效)。威注音曾於一段時間內移除過 OpenCC 模組,但最近又再次引入該模組、方便使用者在使用 SHIFT+方向鍵 快速新增使用者語彙時能夠針對另一個簡繁模式同時新增對應的轉換結果(轉換準確率受 OpenCC 限制)。這樣一來,OpenCC 就成了唯一的登記在案的 Swift Package。

⚠️ **GitHub.com Fallback** ⚠️