Skip to content

程式架構

zonble edited this page Sep 25, 2024 · 14 revisions

小麥注音輸入法是一個相當小巧的 mac OS 平台專案,整體的程式碼不到兩萬行,使用 C++、Swift、Objective-C++ 以及 Python 等語言寫成,大抵上只需要一點時間,就可以了解整個專案的組成,並且開始調整或添加您想要的功能。

小麥注音是一套非平台官方,且非商用的開源自由軟體,目的就不在於與其他產品競爭,而是希望,在繁體中文圈當中的用戶如果有些平台或商業公司的方案無法照顧到的需要時,有一套可以輕易調整、修改的程式碼可滿足需要,所以,相較於包山包海的功能,我們致力於架構的極簡,以及程式碼的簡潔易讀,就是期待在小麥注音的基礎上,可以看到更多的應用。

在 2011 年小麥誕生初期,有相當大一部份是用 Objective-C 以及 Objective-C++ 撰寫,在 2021-2022 時,由 Objective-C 寫成的部份,則大幅用 Swift 改寫,更適宜新一代從 Swift 開始上手的 macOS/iOS 工程師參與開發。

  • C++:主要用來撰寫詞庫載入、智慧選字演算法等底層部份
  • Swift:主要用來撰寫呼叫 macOS 系統 API、選字窗 GUI 等上層部份
  • Objective-C++:由於目前 Swift 與 C++ 之間的 interop 還不成熟,Swift 與 C++ 之間,還是需要一層 Objective-C 對接,我們還是使用 Objective-C++ 撰寫同時會接觸到 Swift 與 C++ 的部份
  • Python:用在詞庫的相關處理

IMK 輸入法架構

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

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

let kConnectionName = "McBopomofo_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,小麥所做的選擇是處理全部的事件,這一段寫在 InputMethodController.swift-handleEvent:client: 內。

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

在這個處理鍵盤事件的 method 中,同時會用到小麥的輸入引擎,以及輸入法自己的 UI。我們使用 C++ 撰寫引擎的部份,而 UI 現在以 Swift 開發,由於目前(2022 年) C++ 還沒辦法直接與 Swift 對接,中間需要透過一層 Objective-C++,Input Controller 目前還是以 Objective-C++ 開發,但是當中一部分只使用到 Objective-C API 的部份,我們會盡量抽出來以 Swift 撰寫。

小麥的輸入引擎

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

  • libFormosa:小麥當中的 libFormosa 只有用到 Mandarin 這一塊,負責將鍵盤按鍵轉換成對應的注音(LibFormosa 另外還提供台語羅馬字的部份,不過就不是小麥這種注音輸入法會需要用到的)。小麥支援標準、倚天、許氏、倚天 26 鍵、漢語拼音等鍵盤,就是由 Mandarin 處理。
  • Language Model:負責提供語言模型資料。語言模型是指在某個語言當中有哪些字詞,而每個字詞可能會出現的機率。小麥當中的語言模型是由放在 Engine 目錄下的多個 class 組成的,每個 class 負責一部分字詞,包括輸入法本身所提供的詞彙,用戶自行建立的詞彙等,而最後統一由最上層的 McBopomofoLM 這個 facade 提供詞彙資料。
  • Gramambular:小麥注音中的 grid builder,負責從將多組注音符號對應的文字中,建立整段注音組合可以對應到的、機率最高的文字結果。

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

  • Input Controller 收到鍵盤事件,先去問 libFormosa 這個按鍵是否是合法的注音符號
  • 如果 libFormosa 可以將按鍵組成注音,Input Controller 再拿這組注音,詢問 Language Model 有沒有符合這組注音的字詞
  • 如果有符合的字詞,Language Model 就回傳這些符合的字詞以及相關機率,我們當作一個節點插入到 grid builder 中
  • Grid builder 算出結果後,送回 Input Controller,Input Controller 再送回打字的 app

完整流程如下圖:

mcbopomofo_flow

所以,如果您想要擴充小麥輸入法的功能:

  • 如果您想發行新的注音鍵盤配置,就是從 Mandarin 下手
  • 如果您想要改進小麥目前使用的演算法,像是,小麥目前只支援 Unigram,而還沒有支援 Bigram 等 Ngram 的能力,就得要調整 Language Model 以及 grid builder。

UserOverrideModel

在上述的流程之外,還有一個叫做 UserOverrideModel 的 class,會影響打字的結果。UserOverrideModel 的用途是「記住用戶最近打過什麼字」,UserOverrideModel 當中有一個使用 LRU 演算法的 cache,當用戶做了選字行為之後,會記住用戶最近選過了什麼,然後增加這些字詞的機率權重。

標點與符號

如果 libForomsa 認為某組按鍵並不是合法的注音符號,Input Controller 就會向 Language Model 詢問是否是標點符號。標點符號的處理我們並不是放在 Controller 而是在 Language Model 處理,所以小麥在標點符號的處理上相當有彈性,如果您在修改小麥的程式碼時,想要修改標點符號的配置,直接修改詞庫即可,而使用者其實也可以透過手動的加詞與刪詞表格,調整標點符號的位置。

Input Method Controller (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 以及左右鍵加詞…都是我們所謂的狀態。用戶在打字的過程,也是輸入法狀態不斷變化的過程,用戶打了一段文字,就會經歷以下的變化:

  • 未啟用狀態,用戶還沒有切到小麥輸入法
  • 用戶切到小麥之後,還沒輸入文字,輸入法現在是空白狀態
  • 用戶開始打了一些文字,就進入輸入狀態
  • 用戶決定要選字,更改其中一兩個字,現在進入選字狀態
  • 用戶選字完成,從選字狀態退出,並且進入新的輸入狀態
  • 用戶送出文字,先經歷送出中狀態,然後退回空白狀態

狀態物件有以下特性:

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

程式邏輯上的流程則是:

  • Input Method Controller 保存目前的狀態
  • Input Method Controller 收到新的鍵盤事件之後,將按鍵事件與目前的狀態,送給 Key Handler
  • Key Handler 根據這兩項輸入,丟出新的狀態或是錯誤
  • Input Controller 遇到錯誤時就發出錯誤提示聲,有新的狀態,就丟給 State Handler
  • State Handler 根據新的狀態,更新 UI,或是將文字送到用戶正在打字的 app 中

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

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

其他

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