TSF を使う (3) - WPF での UILess Mode
今回は少し趣向を変えて .NET アプリケーション,特に TSF にネイティブ対応した WPF 内から TSF を使うサンプルを紹介してみます.
.NET Framework 3.0 の WPF は,Windows XP SP2 もサポートする関係もあってか,Windows Vista で導入された TSF の新機能に対応していません.今回はその新機能の一つである UILess Mode を WPF で使用する方法について取り上げます.
Introduction (長いので読み飛ばし推奨)
前回も紹介した通り Text Store の実装は比較的面倒な作業で,できれば避けて通りたいところです.幸い WPF は TSF や InputScope に関する処理を綺麗に隠蔽してくれています.
例えば ITextStoreACP や ITfThreadMgr といった TSF の COM インターフェイス定義が WindowsBase.dll の中で internal 扱いにて大量に存在しますし *1,PresentationFramework.dll には System.Windows.Documents.TextStore という Text Store のマネージ実装クラス (やはりこれも internal) が存在します.
私も同じ作業をやったことがあるだけに,TSF のマネージラッパーの作成作業が恐ろしく退屈かつ非生産的で,しかも非常に煩雑であることをよく知っています.これが恐らく C# で実装されているであろうことを割り引いても,私なら Managed DirectX と同等かそれ以上のややこしさを覚悟します.さらに WPF は IMM32 API もサポートしており,ATOK 2007 使用時にも On-the-spot 入力が可能です.よくもまあこんな大規模実装をやり遂げたものだと感心しつつ,Text Store に関してはありがたくこのマネージラッパーのお世話になることにしましょう.
さて,TSF や IMM32 絡みの処理が internal であることからも分かるとおり,WPF を使用するだけであればこの背後の大迷宮について一切気にする必要はありません.
ウィンドウに TextBox を貼り付け,その TextBox に TSF TIP (Text Input Processor) で文字を入力すると何事もなく文字入力・漢字変換ができているように見えますが,その背後で TSF が有効化され先ほど述べた System.Windows.Documents.TextStore を経由して入力が行われたり,あるいは IMM32 API 経由で取得したプリエディット文字列を管理したりといったことが行われていても,それは全て WPF が隠蔽してくれています.
となるとそもそも「TSF の使い方」シリーズで WPF を取り上げるべき話がなくなりそうですが,冒頭で書いたように,実は .NET Framework 3.0 の WPF は,Windows Vista で導入された TSF の新機能に対応しておりません.というわけで今回はそこをネタにしてみることにしました.まあ WindowsBase.dll の気合いの入りようを見るにつけ,恐らく次回かその次のリリースではサポートされるんじゃないかとは思いますが,
TSF Interfaces のインポート
上で述べたように System.Windows.Documents.TextStore の同等物を実装する必要はないのですが,WindowsBase.dll に存在する TSF 絡みのインターフェイス定義はやはり必要になってしまいます.残念ながら Microsoft はこれらのインターフェイスを internal 扱いにしているため,本記事では私が自作した同等物を使用します.本当はこんな不毛な作業,誰かが一回やればあとはコピペでいいと思うんですがね.
https://github.com/NyaRuRu/TSF-TypeLib
https://www.nuget.org/packages/TSF.TypeLib
ちなみに作り方ですが,こんな感じです.
- TSF に関する IDL が Windows SDK に存在するので,これをいったん TLB にコンパイル.
- ctffunc.idl
- ctfspui.idl
- ctfutb.idl
- InputScope.idl
- MSAAText.idl
- msctf.idl
- TextStor.idl
- できた TLB から tlbimp.exe でマーシャリング属性付きの interface を含んだ .NET アセンブリ (DLL) を生成.
- できた DLL を .NET Reflector で C# ソースに変換し,マーシャリング属性を微調整.
- IDL ファイルを眺めて,cpp_quote 内に書かれている定数や Guid を丹念に C# コードに移植.全くもって cpp_quote は堕落だと思います.
さて,こうして作った TSF.dll とそのソースコードですが,個人的にはこんなものただのデータの羅列だと思っているので,加工して再利用するなりソースツリーに取り込むなり,とにかくご自由にお使いいただいて結構です.ただしソースコード中のドキュメントコメントは MSDN の解説のものをそのまま使用しているので,取り扱いはご注意ください.
使い方としては,プロジェクトごとソースコードを参照に加えるか,同梱の TSF.dll のみ参照に加えるかお好きな方をどうぞ.
なお今回の記事では TSF の一部のインターフェイスのみを使用しますが,TSF.dll は Vista 版の TSF で使用できる全てのインターフェイスをポーティングしています.将来の WPF は同じものを内部に internal で持つかと思うとげんなりです.
TSF スレッドマネージャの取得
TSF のスレッドモデルは,スレッドごとにスレッドマネージャ (ITfThreadMgr) が 1 つだけ存在し,そのスレッドマネージャを経由してあれこれ処理を行うというものです.スレッドマネージャの実装 coclass (CLSID_TF_ThreadMgr) は STA コンポーネントとなっています.
スレッドマネージャの作成は CoCreateInstance で CLSID_TF_ThreadMgr*2 を指定するというものですが,WPF アプリケーションのように WPF が内部で既にスレッドマネージャを作成している場合も考えられます.このような時のために msctf.dll が TF_GetThreadMgr という関数をエクスポートしています.この関数は既にそのスレッドでスレッドマネージャが初期化されていればそのオブジェクトを返し,そうでなければ NULL を返します.
従って,まず TF_GetThreadMgr を呼び出し,NULL であれば CoCreateInstance でスレッドマネージャを作成するという手順で,スレッドごとに singleton なスレッドマネージャを得ることができます.
ITfThreadMgr mgr; TextFrameworkFunctions.TF_GetThreadMgr(out mgr); if (mgr == null) { Type clsid = Type.GetTypeFromCLSID(TextFrameworkDeclarations.CLSID_TF_ThreadMgr); mgr = Activator.CreateInstance(clsid) as ITfThreadMgr; }
とはいえ実際に Windows Vista で実験してみたところ,CoCreateInstance を 2 回呼んでも同じスレッドマネージャが返されましたし,TF_GetThreadMgr を織り交ぜても結果は同じでした.つまるところ,少なくとも Vista では,同じスレッドに複数のスレッドマネージャを「作れない」ように見えます.
TSF スレッドマネージャの利用
スレッドマネージャは ITfThreadMgr 以外にも様々なインターフェイスを実装しています.これらのインターフェイスは QueryInterface を通じて取得します.CLR の RCW (Runtime Callable Wrapper) のおかげで,C# からはキャストを通じて QueryInterface を行うことができます.
スレッドマネージャがサポートするインターフェイスの一部を示しましょう.
- ITfThreadMgr
- ITfThreadMgrEx (Vista 以降でのみサポート)
- ITfSource
- ITfSourceSingle
- ITfUIElementMgr
- ITfCompartmentMgr
今回作成した TSF.dll にはネイティブ TSF のインターフェイスと定数の定義ばっかりで,便利なマネージラッパーの 1 つも含んでいません.そこで,ここに改めてスレッドマネージャをラップする .NET クラスを用意しましょう.今回は,以下のようにスレッドマネージャ取得直後に各インターフェイスを得ておくことにしました.
private ITfThreadMgr _ThreadMgr; private ITfThreadMgrEx _ThreadMgrEx; private ITfSource _Source; private ITfSourceSingle _SourceSingle; private ITfUIElementMgr _UIElementMgr; private ITfCompartmentMgr _CompartmentMgr; private CTfThreadMgr(ITfThreadMgr threadMgr) { this._ThreadMgr = threadMgr; this._ThreadMgrEx = threadMgr as ITfThreadMgrEx; this._Source = threadMgr as ITfSource; this._SourceSingle = threadMgr as ITfSourceSingle; this._UIElementMgr = threadMgr as ITfUIElementMgr; this._CompartmentMgr = ThreadMgr as ITfCompartmentMgr; }
なお,RCW に対するキャストで得られたインターフェイスに Marshal.ReleaseComObject を呼ぶ必要はありません.そのため,終了処理時の Marshal.ReleaseComObject メソッドの呼び出しは一度だけになります.ちゃんと調べた上で,もうちょっと詳細に書いた方が良い気がしてきた.参考資料*3
public void Dispose() { if (_ThreadMgr != null) { // RCW に対するキャストで得られたインターフェイスに // ReleaseComObject を呼ぶ必要はない Marshal.ReleaseComObject(_ThreadMgr); _ThreadMgr = null; _ThreadMgrEx = null; _Source = null; _SourceSingle = null; _UIElementMgr = null; _CompartmentMgr = null; } }
もう一点ややこしいのは,スレッドマネージャがスレッド単位でシングルトンということです.Marshal.ReleaseComObject のドキュメントで次のような一文を見たことがある方もおられるかもしれませんが,スレッドマネージャに対してこの手は使えません.
メモ
ランタイム呼び出し可能ラッパーと元の COM オブジェクトが確実に解放されるようにするには、ループを作成し、返される参照カウントが 0 になるまで、ループの中でこのメソッドを呼び出します。
同じ COM オブジェクトに対する RCW は AppDomain 内で共有されます.そのため,何度 CoCreateInstance を呼んでも同じ COM オブジェクトを返すようなシングルトンコンポーネントの場合,予想外の箇所で誰かが同じ COM オブジェクトを取得し,RCW の参照カウントを増やしているかもしれません.
そんなときに,Marshal.ReleaseComObject の戻り値が 0 になるまで呼んでしまうと,別の場所でまだ使用中の RCW を壊してしまうことになります.
今回のケースであれば,WPF が内部で保持しているスレッドマネージャによって,RCW の参照カウントが増加していることがあるため,Marshal.ReleaseComObject を呼びすぎるとまずいことになるというわけです.
同じ問題は,以下のドキュメントなどでも指摘されています.
Warning If you call ReleaseComObject in a loop, the RCW will be unusable by any code in the AppDomain from that point on. Any attempt to use the released RCW will result in an InvalidComObjectException being thrown.
This risk is compounded when the COM component that is being used is a singleton because CoCreateInstance (which is how the CLR activates COM components under the covers) returns the same interface pointer every time it is called for singleton COM components. So separate and independent pieces of managed code in an AppDomain can be using the same RCW for a singleton COM component, and if either one calls ReleaseComObject on the COM component, the other will be broken.
ITfUIElementSink の登録
COM の世界でのイベントは,Source と Sink という概念で表されます.イベントソース (Source) はイベントの発信元,イベントシンク (Sink) はイベントの受け取り側です.
今回であれば UIElement に関するイベントをスレッドマネージャから受け取りたいので,まず .NET クラスに ITfUIElementSink インターフェイスを実装します.
次にスレッドマネージャから ITfSouce インターフェイスを取得 (QueryInterface) し,ITfSouce.Advise メソッドを利用してシンクを登録します.このようにシンクの登録はアドバイズ (Advise) と呼ばれます.逆に登録解除はアンアドバイズ (Unadvise) と呼ばれます.
複数のシンクを登録可能なソースにシンクを登録する場合は,それぞれのシンクを識別するためのクッキーが返されるので,登録解除時にはこれを渡してあげます.
イベントシンクの登録は必然的にほとんどの場合循環参照をもたらします.この循環は .NET の箱庭内で閉じないため,明示的に Unadvise が行われない限り .NET の Garbage Collector も動けません.
ウィンドウクラス (System.Windows.Window 派生クラス) に ITfUIElementSink を実装させるとして,初期化と解放処理は次のような形でよいでしょう.
/// <summary> /// スレッドマネージャの Wrapper Class /// </summary> CTfThreadMgr _threadMgr; /// <summary> /// UIElementSink のクッキー /// </summary> uint _uiElementSinkCookie = 0; protected override void OnInitialized(EventArgs e) { base.OnInitialized(e); // TSF ThreadMgr を取得する // 呼び出した回数だけ Dispose が必要 _threadMgr = CTfThreadMgr.GetThreadMgr(); // UIElement Sink (UIElement に関するコールバック) を登録 // Vista 以前または GUID_TFCAT_TIPCAP_UIELEMENTENABLED に属さない TIP では // 行うべきではない Guid guid = typeof(ITfUIElementSink).GUID; _threadMgr.Source.AdviseSink(ref guid, this, out _uiElementSinkCookie); this.Closed += delegate { if (_threadMgr != null) { if (_uiElementSinkCookie != 0) { _threadMgr.Source.UnadviseSink(_uiElementSinkCookie); } _threadMgr.Dispose(); _threadMgr = null; } }; }
ITfUIElementSink の実装
ITfUIElementSink の実装は以下の 3 つのメソッドを記述することになります.
- BeginUIElement
- UpdateUIElement
- EndUIElement
TIP は複数の UIElement を持つことができるので,区別は ID によって行われます.ITfUIElementSink は,ある ID の UIElement が作成・更新・終了したときにそれぞれ呼び出されます.各 UIElement を操作するための ITfUIElement ポインタは UIElementId をキーとして ITfUIElementMgr::GetUIElement メソッドで取得できます.
UIElement を非表示にするには,UIElement 作成時に呼び出される BeginUIElement メソッドで show 引数に false を返すか,ITfUIElement::Show メソッドに false を渡します.
呼び出しシーケンスや微妙なケースの振る舞いについては『UILess Mode Overview』で詳しく解説されているので,詳細についてはそちらを参照してください.
今回は ListBox を用意してそちらに変換候補を表示させてみることにします.まず 3 つのメソッドの実装です.
public void BeginUIElement(uint UIElementId, ref bool show) { // show = false とすることで UIElement が非表示になり // プログラムから変換候補を取得・選択できるようになる show = false; OnUIElement(UIElementId, true); } public void UpdateUIElement(uint UIElementId) { OnUIElement(UIElementId, false); } public void EndUIElement(uint UIElementId) { this.listBox1.Items.Clear(); }
つぎに,実際の OnUIElement の実装はこのようになっています.
void OnUIElement(uint UIElementId, bool onStart) { ITfUIElement uiElement = null; try { _threadMgr.UIElementMgr.GetUIElement(UIElementId, out uiElement); // RCW に対するキャストで得られたインターフェイスに // ReleaseComObject を呼ぶ必要はない ITfCandidateListUIElementBehavior candList = uiElement as ITfCandidateListUIElementBehavior; if (candList != null) { uint count = 0; string desc; candList.GetDescription(out desc); candList.GetCount(out count); string[] candidates = new string[count]; for (uint index = 0; index < count; ++index) { candList.GetString(index, out candidates[index]); } this.listBox1.ItemsSource = candidates; // 現在選択中の候補をリストボックスでも選択する uint currentIndex = 0; candList.GetSelection(out currentIndex); this.listBox1.SelectedIndex = (int)currentIndex; //以下のコードで特定の候補を選択できる //candList.SetSelection(index); //candList.Finalize(); } } finally { if (uiElement != null) { Marshal.ReleaseComObject(uiElement); } } }
out 経由の呼び出しが多くて見た目は煩雑ですが,ネイティブの COM の場合は必要な BSTR の処理などを標準マーシャラが肩代わりしてくれるので,楽になっている面も多々あります.
サンプル全ソースとスクリーンショット
*1:他にも WindowsBase.dll は Win32 に関する Interop 定義が大量に放り込んであります.例えば Imm32 API 関係も網羅.一度眺めてみると,このアセンブリのクラス群が public に公開されていないことを恨みたくなるかもしれません.
*2:"529a9e6b-6587-4f23-ab9e-9c7d683e3c50"
*3:[http://social.msdn.microsoft.com/Forums/en-US/clr/thread/d91f3553-0981-4587-a800-83ac6fd4ed8a/:title=COM interop RCW not working as intended. - MSDN Forums], [http://blogs.msdn.com/vcblog/archive/2006/09/20/762884.aspx:title=Mixing deterministic and non-deterministic cleanup - Mixing deterministic and non-deterministic cleanup], [http://social.msdn.microsoft.com/Forums/en-US/clr/thread/98c0afac-43ee-4e5b-82ea-a6c80bc6107c/:title=questions on Marshal.ReleaseComObject and Marshal.Release - MSDN Forums]