リフレクションを駆使してデバッグメニューを自動構築してみる(その2)

デバッグメニューの自動構築の内部動作について

前回の記事(その1)では、リフレクションを駆使すればデバッグメニューはこんなに簡単に構築できる!というイメージだけを紹介しました。DebugMenuPageBuilder というクラスを実装し、そのクラスの Build() メソッドに任意のインスタンスを与えることで、自動的にデバッグメニューページが構築されるというものでした。

今回はもっと具体的に、DebugMenuPageBuilder.Build() が内部でどのようにしてページの構築を実現しているのかを説明します。前提知識として、C# についての基礎的な知識と、リフレクションや属性に関する知識が必要となります。

型情報とメンバ情報の取得

DebugMenuPageBuilder.Build() メソッドの内部では、引数で与えられたインスタンスの型情報を GetType() で取得します。そして、その型に DebugContract 属性が付与されているかどうかを調べておきます。

[Conditional("DEBUG")]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct,
                AllowMultiple = false, Inherited = true)]
public class DebugContractAttribute : Attribute
{
}

DebugContract 属性は前回説明した通り、DebugMenuItem 属性が付与されたメンバに限定してデバッグメニュー項目を生成させるように指示するための属性です。DebugContract 属性が付与されている場合、その型の持つ(非公開メンバや基底クラスで定義されたメンバも含めた)全てのインスタンスメンバの内、DebugMenuItem 属性が付与されたものからメニュー項目を生成しようとします。

[Conditional("DEBUG")]
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Method,
                AllowMultiple = false, Inherited = true)]
public class DebugMenuItemAttribute : Attribute
{
    public string caption { get; set; }
}

デバッグメニューに表示したい項目には DebugMenuItem 属性を付与すればいいわけですが、外部のライブラリを使用しているケースでは対象のクラスのソースコードを自由に編集できず、DebugMenuItem 属性をメンバに付与できないケースもあります。

そのため、DebugContract 属性が付与されていないクラスのインスタンスからページを自動構築する場合、すべての public なインスタンスメンバからメニュー項目を生成しようとします(ただし、object 型で定義されるメンバは無視する)。この場合、キャプションにはメンバ名がそのまま利用されます。

ちなみに、これらの属性には Conditional("DEBUG") という属性が付与されています。これによって、DebugContractDebugMenuItem 属性を利用する時に、いちいち #if DEBUG#endif で囲まなくても済むようになっています。これについての詳細は、System.Diagnostics.ConditionalAttribute クラスの説明をご覧ください。

メンバ情報からのメニュー項目の生成

C# のメンバには種類が色々ありますが、DebugMenuPageBuilder が扱うのはフィールド、プロパティ、メソッドの3つです。この内、フィールドとプロパティはほぼ同じように扱えますので、以降の処理はメンバがフィールド(orプロパティ)の時と、メソッドの時に分けて考えます。

更に、フィールドは「単純な型」のフィールドと「単純でない型」のフィールドに分けて考え、メソッドは「引数の無い」メソッドと「引数のある」メソッドに分けて考えます。ここで言う「単純な型」とは、簡単に言うと「型に対応するGUIが用意されている型」のことです。詳細は次節で説明します。

単純な型のフィールドからのメニュー項目の生成

bool, int, float, string などのプリミティブ型のフィールドに対しては、それぞれに対応する適切なGUIでメニュー項目を生成します。例えば bool 型なら CheckBox を、int 型なら IntEditBox を、string 型なら TextEditBox を…という具合です。

列挙型(enum)の場合は、列挙子を選択できるような ListBox を生成します。その列挙型が Flags 属性を持っているかどうかも調べ、Flags 属性を持っている場合は複数選択が可能な ListBox を生成します。

他にも、型に対して対応するGUIが定義できるならば、「単純な型」として扱います。

例えば DateTime 型に対して DateTimeEditBox のようなGUIが用意されているのならば、DateTime 型も「単純な型」として扱います。

また、単純な型の Nullable 型(bool?, int?, float? など)についても、各GUIを拡張して null を設定できるようなオプションを用意してやれば、「単純な型」の枠に組み入れられます。

単純でない型のフィールドからのメニュー項目の生成

単純でない型に対しては対応するGUIが用意されていないため、そのままではメニュー項目に置き換えることはできません。しかし、単純でない型は複合型であるはずなので、その型のメンバごとに項目を生成することはできそうです。

フィールドの型が単純でない型の場合は、Button を生成します。このボタンが押されたら、フィールドの値を DebugMenuPageBuilder 自身に食わせて新たなデバッグメニューページを構築し、デバッグメニューに表示させます。

これぞ真骨頂!!

例えばゲームの一番の大元になるようなクラス(例えば Application クラスとか)のインスタンスからデバッグメニューページを構築してやれば、そこから辿れるありとあらゆるインスタンスについて、デバッグメニューページの構築処理を追加で記述する手間をかけることなく、デバッグメニューからアクセスできるようになるのです。

上のイメージはテスト用のアプリケーションの Application クラスのインスタンスから自動構築されたデバッグメニューです。Application クラスを介してゲーム内の様々な情報にアクセスできるようにしておくことで、デバッグメニューからもゲーム内の様々な情報にアクセスできるようになります。それでありながら、デバッグメニューの実装の手間をほとんどかけずに済むのです。

ちなみに、僕の場合は構造体型の時は Button じゃなくて折り畳み展開可能なGUIを生成するようにしましたが、その辺はまぁお好みでどうぞ。

引数の無いメソッドからのメニュー項目の生成

メソッドが引数を持たない場合、Button を生成します。このボタンが押されたら、メソッドを呼び出します。

戻り値がある場合、その値をログ出力してあげると便利かもしれませんね。「単純な型」でない戻り値の場合に、戻り値から自動構築したページを表示してあげてもいいかもしれません。また、メソッドが非同期メソッド(戻り値が Task 型とか)の場合、非同期処理の実行状況を示すインジケータを表示したり、非同期処理の完了を待ってから結果を表示できたらより便利かもしれませんね。

ボタンが意図せずうっかり押された時にすぐに実行されてしまうのが心配な場合は、NeedsConfirmation のような名前の属性を実装し、この属性が付与されたメソッドを呼び出す場合には必ず確認用のページを挿んでから呼び出すようにすると良いかと思います。

引数のあるメソッドからのメニュー項目の生成

Button を生成する点は引数なしの時と同じです。しかし、引数を指定する必要がありますので、ボタンが押されたら引数の値を指定するためのデバッグメニューページを構築して表示してやる必要があります。

メソッドが持つ引数それぞれについて、メンバからメニュー項目を生成するのと同様の方法でメニュー項目を生成します。引数がデフォルト値を持つ場合はデフォルトでその値を設定しておきます。

このページの最後に「実行」というキャプションを持った Button を追加しておき、このボタンが押されたら、実際にメソッドを呼び出します。

ジェネリックパラメータを必要とするようなメソッドの場合はちょっと実装にひと手間必要そうですね。サポート対象外としましょう(笑)

だいたい伝わった…?

これで DebugMenuPageBuilder がどのようにしてページの構築を行うのか、ざっくりと理解して頂けたでしょうか。しかし、まだ課題は残っています。

次回(最終回)は、DebugMenuPageBuilder を更に改良していくためのいくつかの提案と、まとめです。