Unityな日々(Unity Geek)

Unityで可視化アプリを開発するための試行錯誤の覚書

UnityEventコールバック関数の設定方法について

C#/.NETに限らず、いわゆるオブジェクト指向言語では、クラスの独立性を高めるため、Eventが使われる。

Eventを使うには、イベント・リスナーを設定する必要がある。

たとえば、クラスA(ClassA)の初期化が終わってから、クラスB(ClassB)のInit()メソッドを呼びたい、という例を考えてみる。

これをEventを使って実装すると、クラスAの初期化処理が終わったら、ClassAがUnityEvent OnInitDoneイベントを発行し、これをClassBで受け取ってClassBのInit()メソッドを実行する、というような処理になる。

ClassA、ClassBの基本形は次の通りとする。

Class Aの基本形

using UnityEngine;
using UnityEngine.Events;

public class ClassA : MonoBehaviour {

    private UnityEvent OnInitDone;

    private void Init()
    {
        OnInitDone = new UnityEvent(); //イベント・インスタンスの作成

        Debug.Log("初期化処理...");

        OnInitDone.Invoke();    //イベント発行
    }

    void Start()
    {
        Init();
    }
}

ClassBの基本形

using UnityEngine;

public class ClassB : MonoBehaviour {

    private void Init()
    {
        Debug.Log("ClassBの初期化処理...");
    }
}

このままでは、ClassAのOnInitDoneイベントと、ClassBのInit()メソッドは関連付けられていない。何らかの形でこれらを関連付けてやらねばならない。

すなわち、ClassAのOnInitDoneイベントのコールバック関数にClassB.Init()メソッドを指定する必要がある。

実際のコードはどうなるだろうか?

Unityで、(ClassA、ClassBのカプセル化をできるだけ保ちながら、)イベントとコールバック関数を関連付けるには、次の2つのアプローチがある(と思う)。

方法1. ClassAにリスナー設定・解除メソッドを定義する

ClassA

ClassAに次の、リスナー登録・解除メソッドを追加する。引数のUnityAction actionがコールバック関数。 AddListenerとともに、RemoveListnerメソッドも必ず定義する。

    public void AddListenerToInitDone(UnityAction action)
    {
        if (OnInitDone == null) OnInitDone = new UnityEvent();
        {
            OnInitDone.AddListener(action);
        }
    }
    public void RemoveListenerFromInitDone(UnityAction action)
    {
        if (OnInitDone != null) OnInitDone.RemoveListener(action);
    }

ClassB

ClassBには、ClassAのOnInitDoneイベントのコールバック関数を設定する。

リスナー削除は、OnDisable()に書く。

‘‘‘ [SerializeField] ClassA classA;

private void Awake()
{
    classA.AddListenerToInitDone(Init);
}

private void OnDisable()
{
    classA.RemoveListenerFromInitDone(Init);
}

‘‘‘

以上により、

  • ClassAのStart()で、ClassAのInit()がコールされる +OnInitDoneイベントが発行される(Invoke
  • ClassBがOnInitDoneイベントを受信し、コールバック関数として設定したClassBの`Init()``が実行される
  • ClassBのDisable時に、コールバック関数の登録が抹消される。

という処理が行われる。実行するとコンソールに次のように表示される。思惑通り、ClassAの初期化処理の後、ClassBの初期化処理が走る。

ClassAの初期化処理...
ClassBの初期化処理...

方法1の良い点

この方法は、イベントとコールバック関数の関連付けをスクリプトで定義するので、Unity独自の機能であるインスペクタを好まない「オーソドックス派」は受け入れやすいと思う。

また、イベントや各メソッドはPrivateで定義できるので、クラスのカプセル化面でも好ましいだろう。(イベント変数はPrivateでいい、というのは、ちょっと意外な気もするが)。

方法1の問題点

一方で、この方法の問題点のひとつは、上で書いたように、もしClassBのStart()内でClassAのイベントリスナーを設定した場合、ClassAのStart()でイベント発行した際に、リスナー定義が終わっているか保証されないことがある。

上の事例では、コールバック関数のリスナー登録をClassBの‘‘Awake()‘‘で定義することで実行順を保証したが、イベントや、実行順序を指定したいコールバック関数の数が多くなってくると煩雑で、想定した実行順序が保たれずに「オブジェクトの未定義エラー」に悩まされる恐れが大きくなる。(メソッドの実行順に関わるエラーは、Unityで共通の悩みではないだろうか)。

ClassA、ClassBの実行順を明示的に設定することもできるが(実行の順番:OnEnable, Awake, OnLevelWasLoaded, Start - Unityな日々(Unity Geek))、このような処置は実行順を設定していることに気づきにくく、視認性や保守性がよくない。

この方法のもうひとつの問題点は、ClassBのパブリック変数にClassAを指定していることだ。

ClassBは、ClassAのOnInitDoneイベントを受け取りたいだけなのに、ClassA全体を連結してしまっている。そもそものイベントのメリットであるクラスの独立性が得られない。

方法2. UnityEventをインスペクタで関連付ける.

方法1の問題に対する改善策として、イベントをPublic変数にして、インスペクタ上でコールバック関数を設定する方法がある。

ClassARev

この場合のClassAは次のようになる。UnityEvent OnInitDoneは、public、または、[SerializeField]で定義する。

AddListener...RemoveListener... メソッドは不要になり、ほぼ基本形と同じだ(UnityEventがPublicになっただけ)。

using UnityEngine;
using UnityEngine.Events;

public class ClassARev : MonoBehaviour {

    [SerializeField] UnityEvent OnInitDone;

    private void Init()
    {
        if(OnInitDone == null) OnInitDone = new UnityEvent(); //イベント・インスタンスの作成

        Debug.Log("ClassArevの初期化処理...");

        OnInitDone.Invoke();    //イベント発行
    }

    void Start()
    {
        Init();
    }
}

ClassBrev

ClassBは次のようになる。コールバック関数Init()はpublicにする。こちらもスクリプトはまったくシンプルになった。

using UnityEngine;

public class ClassBrev : MonoBehaviour {

    public void Init()
    {
        Debug.Log("ClassBの初期化処理...");
    }

}

イベントとコールバック関数の関連付けはClassAのインスペクタ上で行う。

ClassArevのインスペクタに、UnityEvent OnInitDone の設定パネルが表示されている。UnityUIで見慣れたインターフェースだと思う。

f:id:yasuda0404:20171116095413p:plain

'+'ボタンをおして、このイベントのコールバック関数を設定していく。今回の例だと、次のようになる。

f:id:yasuda0404:20171116095510p:plain

以上で、先の事例と同じく、ClassArevのInit()が実行> OnInitDoneがInvoke> ClassBrevのinit()が実行、という一連の処理が行われる。

方法2の良い点

まず、インスペクタでイベントとコールバック関数を関連付けるため、リスナー設定の実行順の問題がなくなる。

また、ClassAとClassBのやりとりはイベントとコールバック関数だけになるため、ClassBから、ClassA全体が見えてしまう、という問題もなくなった。

これらは方法1の問題点を解決する利点だ。

さらに、UnityEventをインスペクタに表示することで、インスペクタ上でイベントのコールバック関数が一目で確認できるメリットもある。(アンチ・インスペクタ派にはメリットではないかもしれないが…)

方法2の問題点

問題点としては、スクリプトを見ただけではコールバック関数がどこからコールされているのかがわからなくなることがある。これはハードリンクを避けたことで、相反する結果として出てくる問題点といえる。

結局、どちらがいいのか?

方法1がいいのか、方法2がいいのかは、コードの保守性や再利用性、拡張性、そして視認性についての考え方によると思う。方法1はよりオーソドックス、方法2は「Unity的」実装だと思う。

ちなみに僕の場合、以前は方法1で実装していたが、最近は保守性、再利用性、視認性を総体的に見て、方法2の方が良いのではないか、と考えている。

どうでしょうか?