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

− wxDocument サブクラスの実装 −

(2008/11/17 記)

 いよいよ手元の Cocoa アプリの移植作業に入った。このアプリは、いずれクロスプラットフォームにすることを最初から目論んでいたので、主要なロジックは Plain C で書き、Cocoa 依存の部分だけ Objective-C で書くようにしていた。以前に(2006年2月)日記に書いたクロスプラットフォーム開発に関する議論が頭にあったからこういう設計にしたのだと思う。コメントを見ると2006年3月に作り始めているし。実際移植作業をしてみると、この方針は間違ってなかったようで、Plain C で書いた部分はほんのわずかな修正だけで使い回しできている。

 ドキュメントベースのアプリケーションでは、ドキュメント周りの設計がやはり中心になるので、まずはここから手をつけることにした。

1. wxDocument の実装

 wxDocument では、ドキュメントの内部表現を実装し、ファイルの読み書き・ドキュメントウィンドウの管理・編集作業(undo サポートを含む)・ペーストボードの読み書きなどを受け持つ。

1-1. ファイルの読み書き

 Xcode で「ドキュメントベースのアプリケーション」テンプレートを開くと、NSDocument の サブクラスで loadDataRepresentation:ofType:dataRepresentationOfType:(10.4 以降ではそれぞれ readFromData:ofType:error:dataOfType:error:)をオーバーライドするようになっている。しかし、これらのメソッドは NSData との読み書きになっており、実際にファイルの読み書きを Plain C で書くには少々都合が悪い。僕のスタイルでは、代わりに readFromFile:ofType:, writeToFile:ofType: をオーバーライドして、こんな風に実装している。

- (BOOL)readFromFile:(NSString *)fileName ofType:(NSString *)docType { const char *fname; fname = [fileName fileSystemRepresentation]; /* molecule は MyDocument のインスタンス変数で、データの内部表現 */ molecule = MoleculeNewFromFile(fname); /* Plain C で書かれた関数を呼び出す */ if (molecule == NULL) return NO; else return YES; } - (BOOL)writeToFile:(NSString *)fileName ofType:(NSString *)docType { char buf[128]; const char *fname = [fileName fileSystemRepresentation]; int retval = MoleculeWriteToFile(molecule, fname); /* Plain C 関数 */ if (retval != 0) return NO; else return YES; }

 これを wxDocument で実装しようと思って、wxWidgets のドキュメント(ややこしいけど、これはリファレンスマニュアルのことね)をいろいろひっくり返して見てみたが、ちょうど readFromFile:ofType:, writeToFile:ofType: に対応するメソッドがなかなか見つからない。付属サンプルでは wxDocument::LoadObject, wxDocument::SaveObject をオーバーライドしているが、これは引数が wxInputStream, wxOutputStream なので、Plain C の関数とは相性が悪い。ドキュメントには wxDocument::OnOpenDocument, wxDocument::OnSaveDocument という関数も載っているけど、これをオーバーライドするとエラー処理も全部自前で実装しないといけない。この間のメソッドはないんかいな、と wxWidgets のソースを調べてみると、こんなのがありました。

virtual bool DoSaveDocument(const wxString& file); virtual bool DoOpenDocument(const wxString& file);

 ドキュメントされてないメソッドだけど、まあこのあたりの仕様が変化することはあまりなかろう、というわけで、これらをオーバーライドすることにする。上の Objective-C での実装をそのまま移植すると、だいたいこんな感じになる。

bool MyDocument::DoOpenDocument(const wxString& file) { const char *fname = file.mb_str(wxConvFile); /* molecule は MyDocument のインスタンス変数で、データの内部表現 */ molecule = MoleculeNewFromFile(fname); /* Plain C で書かれた関数を呼び出す */ if (molecule == NULL) return false; else return true; } bool MyDocument::DoSaveDocument(const wxString& file) { const char *fname = file.mb_str(wxConvFile); int retval = MoleculeWriteToFile(molecule, fname); /* Plain C 関数 */ if (retval != 0) return false; else return true; }

 wxString::mb_str は、wxMBConv を引数にとり、その引数が指定するエンコーディングで wxString をバイト列に変換する。NSString の関数と対比させると次のようになる。(ws, ns, cs はそれぞれ wxString, NSString *, const char * 型とする)

NSStringメソッド
(戻り値の型)
wxStringメソッド
(戻り値の型)
機能
[ns fileSystemRepresentation]
(const char *)
ws.mb_str(wxConvFile)
(const wxCharBuffer)
(システム依存の)ファイル名エンコーディングで C 文字列に変換する
[ns UTF8String]
(const char *)
ws.mb_str(wxConvUTF8)
(const wxCharBuffer)
UTF8 エンコーディングで C 文字列に変換する
[NSString stringWithUTF8String: cs]
(NSString *)
wxString ws(cs, wxConvUTF8)
(wxString)
UTF8 エンコーディングで C 文字列から変換する

 (const wxCharBuffer) 型は (const char *) 型にキャストして使うことができる。const char * 型の変数に代入したり、プロトタイプで引数型が const char * と指定されている関数に渡したりする時は暗黙の型変換が行われるが、たとえば printf の引数にするときは明示的にキャストが必要。

 実はメモリ管理がどうなっているのか今ひとつよく理解していない。wx.mb_str(wxConvUTF8) で得たポインタは、元の wxdelete されたらたぶん無効になってしまうんだろうから、必要に応じて strncpy などでコピーする。ソースを読んでちゃんと理解しておいた方がいいんだろうけど、C++ はどうも苦手でねぇ…

 デバッグ用に wxString をコンソールに出力しようとして、ちょっと苦労した。要するに NSLog("%@", ns) みたいなことをやりたくて、C++ だから cout でしょ、なんて思って cout << ws とか cout << ws.c_str() とかやってみたけど、単にポインタの値が表示されるだけでダメ。正解は printf("%s", (const char *)ws.mb_str(wxConvUTF8))。上に書いた通り、(const char *) への明示的なキャストは必須。cout << object みたいな簡便記法、あってもよさそうなんだけど…

1-2. ドキュメントウィンドウの管理

 NSDocument のサブクラスでは、ウィンドウ(wxWidgets 流に言うと「フレーム」のことね)を開くために windowNibNamemakeWindowControllers のどちらかをオーバーライドする。僕のスタイルでは、NSWindowController のサブクラスを作って、ウィンドウ内の GUI 部品の動作とドキュメントの内部データの橋渡しをほとんどすべてこのサブクラス中で記述している。この場合は、makeWindowControllers をオーバーライドして、例えば次のように書くことになる。

- (void)makeWindowControllers { id controller; controller = [[[MyWindowController allocWithZone:[self zone]] init] autorelease]; [self addWindowController:controller]; [[controller window] makeKeyAndOrderFront: self]; /* mainWindowController は MyDocument のインスタンス変数。 */ mainWindowController = controller; }

 mainWindowController に相当するポインタは [[self windowControllers] objectAtIndex: 0] で参照できるので、別にインスタンス変数として保持しておく必要はないのだが、わりと頻繁に参照するので保持しておくと便利。なお、リファレンスカウントを持っておく必要はないので、autorelease してある。一般的には autorelease したポインタをインスタンス変数に保持するとあとで痛い目にあうので、よいこはマネをしないように。

 wxWidgets のスタイルでは、このようなコードは wxDocument のサブクラスでは書かなくてよい。そのかわり、wxApp のサブクラスの OnInit() の中で、こんなコードを書く。

m_docManager = new wxDocManager; (void) new wxDocTemplate((wxDocManager *) m_docManager, _T("Molecule"), _T("*.*"), _T(""), _T(""), _T("Molecule Doc"), _T("Molecule View"), CLASSINFO(MyDocument), CLASSINFO(MoleculeView));

 wxDocManager は、Cocoa の NSDocumentController にだいたい対応している。wxDocTemplate は、ファイルタイプ (*.*)・ドキュメントタイプ (MyDocument)・ウィンドウタイプ (MoleculeView) を関連づけている。MoleculeView というのは wxView のサブクラスで、前にも書いたNSWindowController (のサブクラス)に対応する。

 上のように、アプリケーションの初期化の時に wxDocTemplate のインスタンスを作って wxDocManager に登録しておくと、ファイルオープンのダイアログでファイルタイプを選んで、そのタイプのドキュメントとして開くことができるようになる。

File Open Dialog

 まあ、なんだな、こういう「なんとか (*.*)」みたいな表示がファイルオープンダイアログに出てくると、「なんか Windows っぽいし感じ悪い」という雰囲気がただよってくるのが難点と言えば難点だけど、拡張子でファイルタイプを判別するのはもはや Mac の世界でも普通のことになってきているし、実用的ではある。

 1つのドキュメントで複数のウィンドウを開く、ということになると、wxDocTemplate だけでは対応しきれない(と思う)ので、wxDocument::AddView などを使う。今回はこの機能は使っていないので、この話題はまたの機会に。

1-3. 編集作業と undo サポート

 さて、こいつがちょっと厄介なんだ。ドキュメントを何か編集するとする。例えば、ドキュメントが Atom という型のオブジェクトの配列であるとして、新しい Atom を指定された位置に追加する、という作業を考えよう。Undo をサポートする場合は、この作業の「逆作業」である「指定された位置の Atom を削除する」作業を同時に定義して、NSUndoManager のインスタンスに「登録」することになる。コードとしてはこんな風になる。

- (int)addAtom: (Atom *)anAtom atIndex: (int)index { [[[self undoManager] prepareWithInvocationTarget: self] deleteAtomAtIndex: index]; [atoms addObject: anAtom]; } - (Atom *)deleteAtomAtIndex: (int)index { Atom *anAtom = [atoms objectAtIndex: index]; [[[self undoManager] prepareWithInvocationTarget: self] addAtom: anAtom atIndex: index]; [atoms removeObjectAtIndex: index]; }

 これを初めて見た時はびっくりしましたね。prepareWithInvocationTarget の後には、どんなメソッド呼び出しを書いてもいい。メソッド呼び出しと引数が自動的にまとめられて NSInvocation 型のオブジェクトが作られ、それが undo 用にスタックに保持される。Objective-C のランタイムがそういう機能を持っているからこそ実現できる技である。これは C++ では到底真似できるものではない。

 で、どうしたか。僕は一応 C++ ベースのアプリケーションフレームワークを使ってみた経験もあるので(まあお遊びレベルだけど…それを言うなら今書いているのだってお遊びレベルだし)、この undo メカニズムは C++ では実現困難だな、ということは認識していた。また、なるべくメインロジックは Plain C で書く、という方針でもあったので、1つ1つの編集作業に対して「アクションオブジェクト」を生成して、それを実行する際に「逆作業」のアクションオブジェクトを MyDocumentundoManager に登録する、という風に実装した。

/* アクションオブジェクトのパラメータの型 */ typedef struct MolActionArg { char type; union { int ival; double dval; struct Atom *aval; } u; } MolActionArg; typedef struct MolAction { int refCount; const char *name; int nargs; MolActionArg *args; } MolAction; int MolActionPerform(Molecule *mol, MolAction *action) { /* action の中身に応じて何か作業する */ MolAction *undoAction = ....; /* 「逆作業」オブジェクトを作る */ MolActionRegisterUndo(mol, undoAction); /* 登録する */ MolActionRelease(undoAction); }

 以上が Plain C で書いた部分。次に、Objective-C との橋渡し部分。

int MolActionRegisterUndo(Molecule *mol, MolAction *action) { MyDocument *doc = [MyDocument documentFromMolecule: mol]; if (doc != nil) { MyMolActionObject *obj = [[[MyMolActionObject alloc] initWithMolAction: action] autorelease]]; [[doc undoManager] registerUndoWithTarget: doc selector: @selector(performMolAction:) object: obj]; } } /* MolAction を Objective-C オブジェクトとして扱うためのラッパー */ @implementation MyMolActionObject - (id)initWithMolAction: (MolAction *)action { molAction = action; MolActionRetain(molAction); } - (void)dealloc { MolActionRelease(molAction); [super dealloc]; } - (MolAction *)molAction { return molAction; } @end @implementation MyDocument - (void)performMolAction: (MyMolActionObject *)anObject { MolActionPerform([self molecule], [anObject molAction]); } @end

 MolAction というのが「アクションオブジェクト」。Plain C で書いてあるので、明示的に refCount メンバを持ち、参照数カウントでメモリ管理ができるようにしてある(MolActionRetain()MolActionRelease(); コードは自明なので上には載せていない)。さらに、NSUndoManager がこのメモリ管理メカニズムを利用できるように、MyMolActionObject というラッパーオブジェクトを作っている。なお、undo アクションを登録するとき、メソッドが NSObject のサブクラス1つだけを引数に取る時は、registerUndoWithTarget:selector:object: メソッドを使うことができる。

 まあ、ここまでは Cocoa の話。次はこれをどうやって wxWidgets に持っていくかだ。

 前にも書いた通り、wxWidgets での undo メカニズムは、wxCommandwxCommandProcessor から成っている。wxCommand は「アクションオブジェクト」に相当するものなのだが、ちょっと面倒なのは、Do()Undo() の両方を実装しておかないといけないことだ。Cocoa スタイルの実装だと、Do() を始めた時点では Undo() の内容がわかっていなくてもあまり支障はない。たとえば、Atom を挿入する位置は実際挿入してみないとわからない、といった場合、Undo() に必要な情報「(挿入した)Atom を削除すべき位置」は Do() が終了して初めて確定する。このような場合、実際の作業の「前に」Do()Undo() の両方を確定させておかないといけない wxWidgets のメカニズムには直接は移行できない。

 もう1つ厄介なのは、複数のアクションが組合わさったものがユーザーから見て「1つの作業」になっている場合である。たとえば、「選択した Atom をすべて削除する」という作業を「1つの Atom を削除する」という作業の繰り返しで実装した場合、1つ1つのアクションが1つの redo/undo に対応していたとしたら、ユーザーが undo を実行したとき戸惑うだろう(削除した Atom が1つしか復活しない)。Cocoa の NSUndoManager は、beginUndoGrouping/endUndoGrouping というメカニズムを持っていて、イベントループの最初に beginUndoGrouping, 最後に endUndoGrouping を(自動的に)呼ぶことによって、「ユーザーから見て1つの作業」が自動的にグループ化されるようになっている。wxCommandProcessor には、どうもこの機能は備わっていないようで、自前でアクションをグループ化する必要がある。

 これを解決するため、こんな実装を考えた。wxCommand のサブクラスとして MyCommand を実装するが、これと MyDocument との連携で、Cocoa と同様の undo メカニズムを実現する。実際の処理の流れを Cocoa 版と wxWidgets 版とで対比させて説明する。

 言葉で説明するとえらく長くなるな。コードは下のようになっている。一部単純化してあるのでこれだけでは動かないが、大まかな流れの紹介ということで。まずは MyDocument から抜粋。

/* イベントループの終わりに作業するためのイベントハンドラ */ const wxEventType MyDocumentEvent = wxNewEventType(); BEGIN_EVENT_TABLE(MyDocument, wxDocument) EVT_COMMAND(MyDocumentEvent_willNeedCleanUndoStack, \ MyDocumentEvent, MyDocument::OnNeedCleanUndoStack) END_EVENT_TABLE() void MyDocument::PushUndoAction(MolAction *action) { /* 自前のスタックに action を積む */ if (countUndoStack == 0) undoStack = (MolAction **)malloc(sizeof(MolAction *) * 8); else undoStack = (MolAction **)realloc(undoStack, sizeof(MolAction *) * (countUndoStack + 1)); undoStack[countUndoStack++] = action; MolActionRetain(action); if (countUndoStack == 1) { /* あとで OnNeedCleanUndoStack() が呼ばれるようにイベントを投げておく */ wxCommandEvent myEvent(MyDocumentEvent, MyDocumentEvent_willNeedCleanUndoStack); wxPostEvent(this, myEvent); } } void MyDocument::CleanUndoStack(bool shouldRegister) { /* 積まれた action を「現在のコマンド」にセットする */ if (undoStack != NULL) { if (shouldRegister) { MyCommand *cmd = (MyCommand *)currentCommand; if (cmd == NULL) cmd = new MyCommand(mol); /* "Do" 中なら undoStack に、"Undo" 中なら redoStack に */ if (isUndoing) cmd->SetRedoActions(undoStack, countUndoStack); else cmd->SetUndoActions(undoStack, countUndoStack); if (currentCommand == NULL) { if (!GetCommandProcessor()->Submit(cmd)) delete cmd; } } else { int i; for (i = 0; i < countUndoStack; i++) MolActionRelease(undoStack[i]); free(undoStack); } } isUndoing = false; undoStack = NULL; countUndoStack = 0; currentCommand = NULL; } /* イベントハンドラ。イベントループの終わりに呼ばれる */ void MyDocument::OnNeedCleanUndoStack(wxCommandEvent& event) { CleanUndoStack(true); }

 次に MyCommandundoActionsredoActions は実は同時に保持する必要はないのだが、不要な方をその都度解放したりするとバグの温床になりそうな気がしたので、効率は二の次としてシンプルに実装した。

/* 複数のアクションで1つのコマンドを形成する */ MyCommand::MyCommand(Molecule *aMolecule, const wxString& name): wxCommand(true, name) { mol = MoleculeRetain(aMolecule); undoActions = redoActions = NULL; numUndoActions = numRedoActions = 0; } MyCommand::~MyCommand() { MoleculeRelease(mol); SetUndoActions(NULL, 0); SetRedoActions(NULL, 0); } void MyCommand::SetUndoActions(MolAction **actions, int count) { int i; if (undoActions != NULL) { for (i = 0; i < numUndoActions; i++) MolActionRelease(undoActions[i]); free(undoActions); } undoActions = actions; numUndoActions = count; } void MyCommand::SetRedoActions(MolAction **actions, int count) { int i; if (redoActions != NULL) { for (i = 0; i < numRedoActions; i++) MolActionRelease(redoActions[i]); free(redoActions); } redoActions = actions; numRedoActions = count; } bool MyCommand::Do() { MyDocument *doc = MyDocumentFromMolecule(mol); int i; if (doc != NULL) { bool retval = true; if (redoActions != NULL) { doc->SetCurrentCommand(this); for (i = numRedoActions - 1; i >= 0; i--) { if (MolActionPerform(mol, redoActions[i]) != 0) { retval = false; break; } } doc->CleanUndoStack(retval); } return retval; } return false; } bool MyCommand::Undo() { MyDocument *doc = MyDocumentFromMolecule(mol); int i; if (doc != NULL) { bool retval = true; if (undoActions != NULL) { doc->SetIsUndoing(true); doc->SetCurrentCommand(this); for (i = numUndoActions - 1; i >= 0; i--) { if (MolActionPerform(mol, undoActions[i]) != 0) { retval = false; break; } } doc->CleanUndoStack(retval); } return retval; } return false; }

 どうして wxWidgets 流にすなおに MyCommand 一本でいかないのかって? すでに Plain C で書いてある部分に手を入れたくなかったというのもあるが、「何か処理をする都度その『逆処理』を登録する」というスタイルは、コーディングが楽なんですよ。特に、スクリプト言語を内蔵して複数の処理を一度に実行することまで考えると、undo メカニズムのところで多少苦労したとしても、他のところが格段に書きやすくなる。これで難関一つ突破だ。