Skip to content

La Rime 架構設計解讀

概念解讀

框架

輸入法工作於需要輸入文本的程序中,後者就叫他做輸入法的「客戶程序」吧。

如果輸入法同時在不同的程序中工作,每個客戶程序裏的輸入內容都是不同的。 所以需要爲每個客戶維護輸入法狀態,術語稱輸入法「上下文/Context」。

邏輯複雜的輸入法,常常有個獨立的程序來做運算,便於集中管理詞庫等資源。 Rime稱其爲「服務/Service」,形式爲一個無介面表現的後臺程序/Backend。

於是輸入法的「前端/Frontend」,即輸入法在客戶程序中的那部份,可依託於後臺的服務來運作,自身只需關注與操作系統交互,以及將消息向服務轉發。

Rime的Frontend/Backend模型,依照ibus、IMK等輸入法框架來設計:Frontend不含輸入邏輯,甚至不負責繪製輸入法介面。因此會比較容易適配現有的輸入法框架,不需要自己寫很多代碼。

在服務中,會爲每一個客戶建立一個輸入法「會話/Session」。從功能上講,會話是將一系列按鍵消息變(Convert)爲文字的問(Request)答(Response)過程。

技術上講,會話會負責搞定輸入法前端與服務之間的跨進程通信,同時在服務端爲前端所代表的客戶分配必要的資源。這資源主要是指一部有狀態的、懂得所有輸入轉換邏輯的輸入法引擎「Engine」。

現在所寫的Rime庫,不做那框架部份,專注於輸入引擎的實現。 名字也叫「中州韻輸入法引擎/RIME」嘛。

這部份定義的代碼是 rime/librime,under C++ namespace rime。 拋開實際的輸入法框架,俺先寫個控制臺程序 RimeConsole 來模擬輸入,觀察Engine的輸出,以驗證其功能是否符合設計。

引擎

輸入法的轉換邏輯自然是比較複雜,需要許多元件協同工作。要協同工作,比得組裝到一起,外面再加個殼。通常的機器都是如此,有外殼做封裝,看不到內部的元件,通過有限的幾處機關來操作,用起來比較省心。

Engine 對象,便是 Session 需要直接操作的介面。 除此之外,Rime庫還對外提供若干用於輸入輸出的數據對象。 包括他所持有的代表輸入法狀態的「上下文/Context」對象、 描述一份「輸入方案/Schema」的配置對象、 代表輸入按鍵的 KeyEvent 對象等。

開發者對 Engine 的期望,一是將來可不斷通過添加內部組件的方式增益其所不能,二是有能力動態地調整組件的調度,以在會話中切換到不同的輸入方式。

爲了避免知道得太多,這引擎的內部構造必須精巧,他存在的意義在於接合內部的各種組件、並對外提供可靠的接口:Engine所表達的邏輯僅限於此。

標準化Engine內部的「組件/Component」,明確與關鍵組件之間的對接方式,就可以滿足可擴展、可配置這兩點期望。

設計與實現

組件

爲有好的擴展能力,定義一個接口來表示具有某種能力的一類組件; 爲了達到可在運行時動態配置的目的,採取抽象工廠的設計模式。

boost::factory要求較高版本的Boost庫,所以我還是用了自家釀造的一套設施來做組件的包裝。

原則是,Rime中完成特定功能的對象,若只有一種實現方法,就寫個C++類/class;若有多種實現方法,就寫個「Rime類」/rime::Class。示意:

class Processor : public Class<Processor, Engine*> {
 public:
  Processor(Engine *engine) : engine_(engine) {}
  virtual ~Processor() {}
  // Processor的功能是處理按鍵消息
  virtual bool ProcessKeyEvent(const KeyEvent &ke);
 private:
  Engine *engine_;
};

然後,大家就可以寫各式Processor的實現啦…… 當然,還要把每一種實現註冊爲具名的「組件」。

class ToUpperCase : public Processor {
 public:
  ToUpperCase(Engine *engine);
  virtual bool ProcessKeyEvent(const KeyEvent &ke) {
    char ch = ke.ToAscii();
    if (islower(ch)) {
      engine_->CommitChar(ch - 'a' + 'A');
      return true;
    }
    return false;
  }
};

void RegisterRimeComponents() {
  Registry.instance().Register("upper", new Component<ToUpperCase>);
  // 註冊各種組件到Registry...
}

用法:

void UsingAProcessor() {
  // Class<>模板提供了簡便的方法,按名稱取得組件
  // Class<T>::Require() 從Registry中取得T::Component的指針
  // 而Component<T>繼承自T::Component,並實現了其中的純虛函數Create()
  Processor::Component* component = Processor::Require("upper");
  // 利用組件生成所需的對象
  KeyEvent key;   // 輸入
  Engine engine;  // 上屏文字由此輸出
  Processor* processor = component->Create(&engine);
  bool taken = processor->ProcessKeyEvent(key);
  delete processor;
}

實際的代碼中,會將這個例子改造成Engine持有Processor對象,並轉發輸入按鍵給Processor。

框架級組件和基礎組件

以Engine的視角,可以把各種組件分爲框架級組件和基礎組件。前者會由Engine創建並直接調用,故需要爲每一類組件定義明確的接口。後者作爲實現具體功能的積木,由框架級組件的實現類使用。

框架級組件

目前設計中規劃了三類框架級組件:

  • Processor,處理按鍵,編輯輸入串
  • Segmentor,解釋輸入串「是什麼」,將輸入串分段,標記輸入內容的可能類型
  • Translator,翻譯一段輸入,給出一組備選結果

對於每一類組件,Engine將根據Schema中的配置,創建若干實例,並按一定規則調度,從而將複雜的輸入法邏輯分解到組件的各種實現裏,按輸入方案的需求組合。

當Engine接到前端傳來的按鍵消息,會順次調用一組Processor的ProcessKeyEvent()方法。 在這方法裏,每個Processor可決定是接受並處理該按鍵,放棄該按鍵還給系統做默認處理,還是留給其他的Processor來決定。

Processor的處理結果表現爲對Context的修改。當某個Processor修改了Context的編碼串時,Engine獲得信號通知,開始切分和翻譯的流程。

首先按次序由各Segmentor嘗試對編碼串分段。通過N個回合,將編碼串分爲N個編碼段。每一回合中,各Segmentor給出從編碼串指定位置開始可識別的最長編碼序列,及對應的編碼類型標籤。回合中最長的分段將被採納,將該編碼段的始末位置及一組編碼類型標籤記入Context中的分段信息。優先級較高的Segmentor也可以中止當前回合從而跳過優先級較低的Segmentor。

接下來,對每一個編碼段,調用各Translator做翻譯。Translator可憑藉編碼類型標籤來判斷本Translator能否完成該編碼段的翻譯。

每個Translator針對一個編碼段的翻譯結果爲Translation對象,是用來取得一組候選結果的迭代器。同一編碼段由不同Translator給出的Translations,存入Menu對象。爲了在有大量候選結果的情況下保持效率,Menu僅在需要時,譬如向後翻頁時,才會從Translation中取得一定數目的候選結果。 Translation有和其他Translation比較的方法,用於根據下一個候選結果的內容、或某種預定的策略來決定候選結果的排序。

Context中的Composition,匯總了分段信息、使用者在各代碼段對應的Menu中所選結果等信息。經過使用者手動確認結果、且未發生編輯動作的編碼段,將不再做重新分段的處理。

基礎組件

TODO(lotem): 補完文檔

基礎組件:

  • Dictionary, 詞典,由編碼序列檢索候選結果
  • UserDictionary, 動態的用戶詞典
  • Prism, 音節拼寫至詞典編碼的映射
  • Algebra, 拼寫運算規則
  • Syllablifier, 音節切分算法

Released under the MIT License.