Cocoa 使いが wxWidgets を使ってみる (2)

− ドキュメントベースのアプリケーション −

(2008/11/02 記)

 wxWidgets アプリを Xcode 上で開発できるめどが立ったので、Cocoa で作った「ドキュメントベースのアプリケーション」を wxWidgets に移植してみる。

1. wxWidgets でのドキュメントベースのアプリケーション

 Cocoa では、複数のドキュメントを開いたり編集したり保存したり、という作業を含むアプリケーションは NSDocument を使った「ドキュメントベースのアプリケーション」として実装する。wxWidgets でもこれに対応するものがあって、中心となるクラスは wxDocument である。

 参考:Document/View overview

 もう少し詳しくクラスを対応づけると、下の表のようになる。

Cocoa wxWidgets 機能
NSDocument wxDocument ドキュメントとファイルを対応付ける。ファイルの読み込み・保存。Undo/Redo 機能とファイル保存の連携 ("dirty flag")。
NSWindowController wxView ドキュメントとウィンドウを橋渡しする。
NSWindow wxFrame 画面上のいわゆる「ウィンドウ」。
NSView wxWindow 「ウィンドウ」内の GUI 部品。

 コーディングを始める前に、アプリケーションのスタイルを決めておかないといけない。というのも、wxWidgets でのドキュメントベースのアプリケーションには3つのスタイルがあるからだ。上記の Document/View overview によれば、次のようになってますが…

スタイル 説明
MDI 1つの親ウィンドウがあって、その中にすべてのドキュメントのサブウィンドウが含まれている形式。Windows でのドキュメントベースのアプリケーションはこの形式が多い。
SDI 各ドキュメントが別々のウィンドウで開く形式。Mac のアプリケーションは基本的にはこの形式。
single-window 一度に1つのドキュメントだけが開ける形式。別のドキュメントを開くときは、現在開いているドキュメントを先に閉じる。

 SDI ってこれでいいのかな? ここで "single-window" といっているやつが SDI で、「各ドキュメントが別々のウィンドウで開く形式」って "MTI" (Multiple Top-Level Interface) と呼ぶんじゃないのかな。Windows の用語には慣れてないのでよくわからないんだけど。

 wxWidgets ドキュメントの別のページには「MDI は、Microsoft によって『時代遅れのインターフェイスである』と警告されているが、未だに広く使われている」と書いてある。なあんだ、Microsoft もあれはイマイチだと思ってたんかい、じゃああんたのオススメは何なんだよ、と問い詰めたくなるところだ。ただ、僕が今まで使った Windows のアプリケーションはほとんど MDI だったことだし、Windows のユーザーは MDI に慣れていると仮定して、一応今回は MDI を採用してみることにする。

 ところで、クロスプラットフォームのアプリではありがちなことだが、Windows ユーザーにとって慣れ親しんだ流儀で作っても、Mac ユーザーにとって使いにくくなってしまってはよろしくない(もちろんその逆もよくない)。それぞれのプラットフォームでなるべく自然に使えるように仕上げるのがプログラマの仕事だろう。このあたりは、#if...#endif を駆使して作り込むことになる。

2. サンプルプログラム docwxmdi を読み解く

 wxWidgets 付属サンプルの docvwmdi ("Document view MDI" の意味だろう)を読み解きながら、wxDocument の使い方を理解していく。このサンプルは、Drawing と Text の2種類のドキュメントを作成するという仕様になっている。下は Mac と Windows XP でのスクリーンショット。Windows では2つのドキュメントを取り囲む枠("DocView Demo" というタイトルのもの)があり、これが MDI の「親ウィンドウ」になる。Mac ではこのウィンドウは非表示になっている(が、Dock のアイコンをクリックしてアプリを全面に出すと突然表示されたりして、やや中途半端)。

docvwmdi screen shot (Mac) docvwmdi screen shot (Win)

 プログラムは3つのファイル doc.cpp, view.cpp, docview.cpp (とそれぞれのヘッダファイル)から成っていて、次のようなクラスが定義されている。

クラス名 親クラス 役割 定義ファイル
MyApp wxApp アプリケーション本体。 docview.h, docview.cpp
MyFrame wxDocMDIParentFrame MDI のトップレベルウィンドウ。 docview.h, docview.cpp
DrawingView wxView Drawing 用のウィンドウを制御。 view.h, view.cpp
TextEditView wxView Text 用のウィンドウを制御。 view.h, view.cpp
MyCanvas wxScrolledWindow Drawing で実際の描画を担当する GUI 部品。 view.h, view.cpp
MyTextWindow wxTextCtrl Text で実際の描画を担当する GUI 部品。 view.h, view.cpp
DrawingDocument wxDocument Drawing のドキュメントクラス。 doc.h, doc.cpp
DoodleLine wxObject 線の始点・終点を表す構造体。 doc.h
DoodleSegment wxObject ひとつながりの折れ線。 doc.h, doc.cpp
DrawingCommand wxCommand 折れ線を描くコマンド。Undo/Redo に対応。 doc.h, doc.cpp
TextEditDocument wxDocument Text のドキュメントクラス。 doc.h, doc.cpp

 上の表で、「ウィンドウ」「GUI 部品」と呼んでいるのはそれぞれ wxFrame, wxWindow のサブクラスなので、wxWidgets 流には「フレーム」「ウィンドウ」と呼ぶのが正しい。しかし、Cocoa に慣れていると、「ウィンドウ」は NSWindow (≒ wxFrame), 「フレーム」は NSWindow とか NSView (≒ wxWindow) の frame メソッドを思い出させるので、どうも混乱する。このため、不一致は承知で上の表記にしてある。

 wxWidgets のドキュメントの取り扱いを理解するため、wxDocument, wxView のサブクラスから調べていく。テキスト・ドローの2種類のドキュメントに対応する2種類のドキュメントクラスがあるが、TextEditDocument は、テキスト編集の GUI 部品 (wxTextCtrl) の機能に依存しているところが大きいので、DrawingDocument を見てみる。このクラスは思いのほか単純で、定義されているメソッドは以下の5つだけ。

 コンストラクタは空、デストラクタは内部データである DoodleSegment の配列を破棄するだけで、特別なことは何もしていない。GetDoodleSegments はこの配列へのアクセスメソッド。SaveObject, LoadObject はファイルの読み書きで、Cocoa でいうところの dataOfType:error:, readFromData:ofType:error: あたりに対応する。結局このクラスは、データの保持とファイルとのやりとりだけを担当していることになる。

 次に DrawingView。Cocoa でいう NSWindowController のサブクラスに相当するわけだが、これもそれほど複雑ではない。定義されているメソッドは7つ。

 コンストラクタはメンバを NULL で初期化するだけ、デストラクタは空。OnCreate, OnDraw, OnUpdate, OnClosewxView メソッドをオーバーライドしている。wxView overview によると、この4つのメソッドのオーバーライドは必須となっている。OnCut は独自のメソッドで、 "Cut" コマンドが(メニューやキーボードショートカットで)指示されたときに呼び出される。

 OnCreate には次のようなコードがあって、要するにウィンドウ (wxFrame ですな)を初期化している。Cocoa 流なら、initWithWindowNibName: で nib ファイルをロードするところ。

frame = wxGetApp().CreateChildFrame(doc, this, true); frame->SetTitle(_T("DrawingView")); canvas = GetMainFrame()->CreateCanvas(this, frame);

 CreateChildFrame は、MDI のドキュメントウィンドウ(外側の親ウィンドウの中に含まれる1つのサブウィンドウ)となる wxDocMDIChildFrame 型のウィンドウを作成するメソッド。このサンプルでは、TextEditView からも呼ばれるために MyApp のメソッドとして実装されているが、そうでなければ DrawingView のクラスメソッドとして実装してもよかったところ。

 1行おいて次の CreateCanvas は、実際にドキュメントの中身を描画する MyCanvas をドキュメントウィンドウ内に作成するもので、これは DrawingView のメソッドとして実装した方が見やすかっただろう(このサンプルでは MDI の親ウィンドウである MyFrame のメソッドとして実装されているけど、MyFrameMyCanvas って、実は直接関係ないじゃん?)

 OnDraw, OnUpdate は、描画・画面更新を行う。OnDraw は、docvwmdi のコードの中では MyCanvas::OnDraw から呼び出されているが、印刷機能を実装したときには wxWidgets フレームワークの内部からも呼ばれるようだ。OnUpdatewxDocument::UpdateAllViews の中から呼ばれることは確認したが、ほかにも呼ばれることがあるかどうかはわからない。

 OnClose はけっこう込み入っている。

bool DrawingView::OnClose(bool deleteWindow) { if (!GetDocument()->Close()) return false; // Clear the canvas in case we're in single-window mode, // and the canvas stays. canvas->ClearBackground(); canvas->view = (wxView *) NULL; canvas = (MyCanvas *) NULL; wxString s(wxTheApp->GetAppName()); if (frame) frame->SetTitle(s); SetFrame((wxFrame*)NULL); Activate(false); if (deleteWindow) { delete frame; return true; } return true; }

 対応する wxDocumentClose メソッドを呼び出し、失敗すれば何もせず false を返す。あとは、deleteWindowtrue ならウィンドウを破棄するが、false ならウィンドウとの対応を解消するだけでウィンドウ自体には手をつけない。このあたりのコードは「決まり文句」として覚えておく必要があるかもしれないな。

 OnCut はなかなか面白い。これは、最後に描画した線を削除するメソッドで、実装は次の通り。

void DrawingView::OnCut(wxCommandEvent& WXUNUSED(event) ) { DrawingDocument *doc = (DrawingDocument *)GetDocument(); doc->GetCommandProcessor()->Submit(new DrawingCommand(_T("Cut Last Segment"), DOODLE_CUT, doc, (DoodleSegment *) NULL)); }

 DrawingCommand というのがポイント。これは wxCommand のサブクラスで、Do, Undo というメソッドを持っている。上の呼び出しで作られた DrawingCommand のインスタンスの場合、Do は(注文通り)最後に描画した線を削除するが、Undo は逆にその線を追加する。つまり、"Do" ("Redo" も同じ)/"Undo" の動作を1つのオブジェクトにパッケージ化したようなものである。

 この wxCommand(のサブクラス)のインスタンスを管理するのが wxCommandProcessor というクラス。wxCommandProcessor は、何もしなくても wxDocument ごとに自動的に作られており、wxDocument::GetCommandProcessor() メソッドでアクセスすることができる。これに Submit メソッドで wxCommand のインスタンスを送ると、そのコマンドの Do メソッドが実行され、同時に undo のためにコマンドが内部に保持される。"Undo" が(ユーザーによるメニュー選択またはキーボードショートカットで)指示されると、最後に実行された wxCommand のインスタンスが取り出されて Undo メソッドが実行され、同じインスタンスが今度は redo のために保持される。このメカニズムは、ドキュメントが変更されたかどうかの追跡にも使える(つまり、undo スタックが空だったらドキュメントは変更されていない、ということになる)。

 Mac プログラマでも、これに似た undo/redo のメカニズムを PowerPlant (おお懐かしい)あたりで使ったことがあるかもしれない。しかし、Cocoa の NSUndoManager のメカニズムとはだいぶ違っている。Cocoa では、Do/Undo の動作をプログラマが自分で集めてパッケージ化する必要はなく、ただある動作に対して "Undo" するためのメソッド呼び出しを registerUndoWithTarget:selector:object: とか prepareWithInvocationTarget: を使って登録するだけで、あとは NSUndoManager がよろしくやってくれる。Cocoa から wxWidgets にアプリケーションを移植するとき、この違いはかなり大きな障害になるだろう。