C#
Unity

[C#][Unity] Reflectionを使ってインスペクタ上でメソッドを選択する

More than 1 year has passed since last update.

やりたかったことは、UIのclickイベントが発生した際に、アタッチしたオブジェクトに定義されているメソッド一覧から任意のメソッドを実行する、というのに似せた仕組みです。

UI Button Event Listener Sample
↑これ

使用するクラスとメソッド

今回やりたかったことを実現するのに利用したもの。主に Reflection ですね。

※ ちなみにカスタムエディタ部分はひとつ前の記事([Unity] カスタムエディタを使ってインスペクタをリッチにする)にまとめたのでそちらを参照してください。

基本的なフローは以下のようになります。

  1. カスタムエディタで特定クラスのインスペクタのGUIをカスタムする
  2. 設定されたオブジェクトからアタッチされているコンポーネントを取得(MonoBehaviour を継承したもの)
  3. 取得したコンポーネント(クラス)が実装しているメソッドリストを取得
  4. 特定の条件でフィルタをかけて、メソッド名を文字列配列化
  5. インスペクタ上で選択されたメソッド名を使って SendMessage

という感じの流れです。
(自作しなくてもなんかありそうですが、見つからなかったので作りました( ;´Д`))

ソースコード

まずはざっとコード全体を載せます。

using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Linq;

[CustomEditor(typeof(TargetClass))]
public class TargetClassEditor : Editor {

    TargetClass m_Target = null;

    GameObject m_PreviousObject = null;

    string[] m_Methods = new string[]{};

    /// <summary>
    /// 収集したメソッドをクリア
    /// </summary>
    void ClearMethods()
    {
        m_Methods = new string[] { };
    }

    /// <summary>
    /// 登録されたオブジェクトのPublicメソッドを収集
    /// </summary>
    void CollectMethods()
    {
        if (m_Target == null) {
            ClearMethods();
            return;
        }

        if (m_Target.GimmickTarget == null) {
            ClearMethods();
            return;
        }

        if (m_PreviousObject == m_Target.TargetObject) {
            return;
        }
        m_PreviousObject = m_Target.TargetObject;

        MonoBehaviour[] components = m_Target.TargetObject.GetComponents<MonoBehaviour>();

        ArrayList result = new ArrayList();
        result.Add("None");
        foreach (var component in components) {
            string[] methodsName = component.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public)
                                    .Where(x => x.DeclaringType == component.GetType())
                                    .Where(x => x.GetParameters().Length == 0)
                                    .Select(x => x.Name)
                                    .ToArray();
            result.AddRange(methodsName);
        }

        m_Methods = (string[])result.ToArray(typeof(string));
    }

    void OnEnable()
    {
        m_Target = target as TargetClass;
        CollectMethods();
    }

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        CollectMethods();

        if (m_Target == null) {
            return;
        }

        if (m_Methods.Length == 0) {
            return;
        }

        int index = m_Methods
                        .Select((Name, Index) => new { Name, Index })
                        .First(x => x.Name == m_Target.CallbackName)
                        .Index;

        using (new EditorGUILayout.HorizontalScope()) {
            EditorGUILayout.LabelField("Trigger methods");
            m_Target.CallbackName = m_Methods[EditorGUILayout.Popup(index, m_Methods)];
        }
    }
}

ポイント

今回の最大のポイントは、 Reflection を用いてランタイムにメソッド名などを取得している点です。
具体的には以下の部分がそれに該当します。

CollectMethod
MonoBehaviour[] components = m_Target.TargetObject.GetComponents<MonoBehaviour>();

ArrayList result = new ArrayList();
result.Add("None");
foreach (var component in components) {
    string[] methodsName = component.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public)
                            .Where(x => x.DeclaringType == component.GetType())
                            .Where(x => x.GetParameters().Length == 0)
                            .Select(x => x.Name)
                            .ToArray();
    result.AddRange(methodsName);
}

m_Methods = (string[])result.ToArray(typeof(string));

メソッド実行対象となるオブジェクトがアタッチされている場合に、アタッチされたオブジェクトに紐づくメソッドリストを取得する部分です。

GetComponents メソッドにて、アタッチされている MonoBehaviour クラスを継承したものを抜き出しています。
そしてそれを foreach で回しつつ、コンポーネント内で定義されているメソッド一覧を抜き出します。

今回は仕様として「インスタンスメソッド」「publicメソッド」「引数0」という条件で抜き出しました。
(もちろん、用途によってここは変更可能です)

GetMethods メソッドにフラグを渡してやることで、public / private などを選択してメソッド一覧を取得することができます。
戻り値は「MethodInfo[]」ですが、そこからさらにLINQを使ってメソッド名の文字列の配列としてフィルタしているのが該当の処理です。

まぁコードを見ればなんとなく分かると思いますw

あとは、それでフィルタした文字列による配列をマージして、最終的にメソッド名一覧として利用します。

インスペクタに表示

インスペクタへの表示についてはカスタムエディタを利用して処理しています。
詳細については前の記事を参照してもらいたいですが、ポップアップ部分について補足しておきます。

public override void OnInspectorGUI()
{
    base.OnInspectorGUI();

    CollectMethods();

    if (m_Target == null) {
        return;
    }

    if (m_Methods.Length == 0) {
        return;
    }

    int index = m_Methods
                    .Select((Name, Index) => new { Name, Index })
                    .First(x => x.Name == m_Target.CallbackName)
                    .Index;

    using (new EditorGUILayout.HorizontalScope()) {
        EditorGUILayout.LabelField("Trigger methods");
        m_Target.CallbackName = m_Methods[EditorGUILayout.Popup(index, m_Methods)];
    }
}

OnInspectorGUI メソッドをオーバーライドすることで実現します。
いくつかのNullExceptionに対する対応を入れていますが、見るべき部分は収集したメソッドリストである m_Methods の使い方です。

m_Methods からLINQを用いて、現在選択されているindexを得ます。

(ちなみに m_Target.CallbackName は、カスタムエディタの対象となるクラスのインスタンス変数です。当然、設定された値を利用するのはコンポーネント側なので当然ですね)

さて、indexを取得したら、それを選択されたindexとして利用しつつ、カスタムエディタのインスペクタ上のGUIをレンダリングします。
それが以下の部分です。

using (new EditorGUILayout.HorizontalScope()) {
    EditorGUILayout.LabelField("Trigger methods");
    m_Target.CallbackName = m_Methods[EditorGUILayout.Popup(index, m_Methods)];
}

横方向にグルーピングする処理を入れていますが、主な部分は EditorGUILayout.Popup の部分です。
第一引数は選択されたインデックス。なのでその直前で取得したindexですね。
そして第二引数が実際に表示すべきポップアップのリストとなる配列です。
ここに、収集したメソッドリストを設定します。

こうすることで、 Reflection によって取得したメソッドリストをポップアップで表示することができます。

ハマった点

ハマった、というほどハマってはいませんが、最初実装した際、 CallbackName がプレイ時に初期化される、という現象がありました。
気づけばなんのことはないですが、 CallbackName のAttributeが SerializeField になっていなかったためにプレイ時に初期化されていたようです。
なので、変数宣言時にAttributeをつけるのを忘れないようにしないと選択したものが実行されずに混乱します。

参考記事

今回のものは、以下の記事をベースに作成しました。

その他、作った時に知ったことメモ

C#はまだまだ知らないことが多いですね;

匿名型とは

ということで、LINQで利用した「匿名型」について。
(ちなみにこちらを参考にしました)

具体的にはこんな感じのやつです。

string[] anyList = new string[] { "hoge", "fuga", "foo", "bar" };

int index = anyList.Select((name, index) => new { Name = name, Index = index });

// さらに短くするとこう書ける
// ローカルで宣言した変数を使うとその名前と値がそのまま利用される
// int index = anyList.Select((Name, Index) => new { Name, Index });

普通に使う場合はこんな感じになります。

var person = new { Name = "edo", Age = 20 };
Console.WriteLine(person.Name);

以上のように使えるオブジェクトを生成します。
JavaScriptでいうところの Object に近いでしょうか。
LINQと併用するとだいぶ便利ですね。