デバッグメニューの自動構築の内部動作について
前回の記事(その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")
という属性が付与されています。これによって、DebugContract
や DebugMenuItem
属性を利用する時に、いちいち #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
を更に改良していくためのいくつかの提案と、まとめです。