Unity
Zenject

【Unity】Sub-Containers And Facades 日本語訳【Zenject】

はじめに

なんだかすごいと噂の Zenject というアセットがあります。このアセットの"GameObjectContext"という機能に関して日本語の解説がどこにもなかったので、これについて説明しているドキュメントのページを日本語訳してみました。

Zenjectマスターに、俺はなる!


以下、 Sub-Containers And Facades の日本語訳

サブコンテナとFacades

同一のアプリケーション内で複数のコンテナを使うのが便利な場合があります。例えばあなたがワードプロセッサを作っているとしたら、別々のドキュメントを表すタブごとにそれぞれのサブコンテナを持つのが便利かもしれません。この場合、サブコンテナ内にAsSingle()なクラス群をバインドでき、それらはまるでシングルトンクラスのように容易に互いを参照し合うことが可能です。サブコンテナはそれぞれのドキュメントごとにインスタンス化可能で、ドキュメントを扱うための全てのクラスのユニークなインスタンスを持ちます。

別の例として、オープンスペースの宇宙船ゲームを設計している場合、各宇宙船ごとに独自のコンテナ(その宇宙船を動かすためのすべてのクラスインスタンスを含んでいる)を持たせることができます。

ここで、ProjectContextの実際の動作は次のようになっています。プロジェクト全体で一つのコンテナがあり、Unityのシーンが開始したときに各SceneContextProjectContextの下に生成されます。シーンのMonoInstallerに追加されたすべてのバインディングはSceneContextコンテナにバインドされます。これにより、サブコンテナは親(および祖先)コンテナ内のすべてのバインディングを自動的に継承するため、シーンの依存関係にProjectContextバインディングが自動的に挿入されます。

これはコード内で他のモジュールから利用されるときに、関連した依存関係のグループを高いレベルで抽象化するのに用いられます。これが関係してくるのは、アプリケーションでサブコンテナを定義するときに、サブコンテナ全体と相互作用するために使用されるFacadeクラスを定義することが非常に便利な場合が多いためです。したがって、上記の宇宙船の例に適用するには、"Start Engine","Take Damage","Fly to destination"などの宇宙船での非常に高いレベルの操作を表すSpaceshipFacadeクラスを持つかもしれません。そして、SpaceshipFacadeクラスはこれらのリクエスト全てに対して、内部的にその処理をサブコンテナ内に存在する依存関係に委譲することができます。

以下のセクションでいくつか例を挙げてみましょう。

例)サブコンテナ/Facadeを用いた Hello World

public class Greeter
{
    readonly string _message;

    public Greeter(string message)
    {
        _message = message;
    }

    public void DisplayGreeting()
    {
        Debug.Log(_message);
    }
}

public class GameController : IInitializable
{
    readonly Greeter _greeter;

    public GameController(Greeter greeter)
    {
        _greeter = greeter;
    }

    public void Initialize()
    {
        _greeter.DisplayGreeting();
    }
}

public class TestInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.BindInterfacesTo<GameController>().AsSingle();
        Container.Bind<Greeter>().FromSubContainerResolve().ByMethod(InstallGreeter).AsSingle();
    }

    void InstallGreeter(DiContainer subContainer)
    {
        subContainer.Bind<Greeter>().AsSingle();
        subContainer.BindInstance("Hello World!");
    }
}

ここで大事なのは、InstallGreeterメソッド内で追加されるすべてのバインディングはこのサブコンテナ内でだけアクセス可能であるということです。唯一の例外はFacadeクラス(この場合はGreeter)で、これはFromSubContainerResolve()バインディングを用いて親コンテナにバインドされています。言い換えれば、この例における"Hello World"という文字列はGreeterクラスにしか見えません。

FromSubContainerResolve()に与えられたクラスはサブコンテナ内のインストールメソッド内でバインドされなければならないことに注意してください。そうしないと例外が発生し、バリデーションは失敗します。

また、通常は上記の例のようにByMethod()を使用するよりも、ByInstaller()を使用したほうが良いことに注意してください。これはByMethod()を使用するとsubContainerではなくContainerを誤って使用する可能性が高くなるためです。また、ByInstaller()を使用するとインストーラ自体に引数を渡すこともできます。

サブコンテナ内での IInitializable / ITickable / IDisposable の使用

上記のHello Worldの例には問題があります。ITickableまたはIInitializableまたはIDisposableをサブコンテナに追加したい場合、それは機能しません。例えば次のような場合:

public class GoodbyeHandler : IDisposable
{
    public void Dispose()
    {
        Debug.Log("Goodbye World!");
    }
}

public class HelloHandler : IInitializable
{
    public void Initialize()
    {
        Debug.Log("Hello World!");
    }
}

public class Greeter
{
    public Greeter()
    {
        Debug.Log("Created Greeter!");
    }
}

public class TestInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<Greeter>().FromSubContainerResolve().ByMethod(InstallGreeter).AsSingle().NonLazy();
    }

    void InstallGreeter(DiContainer subContainer)
    {
        subContainer.Bind<Greeter>().AsSingle();

        subContainer.BindInterfacesTo<GoodbyeHandler>().AsSingle();
        subContainer.BindInterfacesTo<HelloHandler>().AsSingle();
    }
}

NonLazy()を使用しているためGreeterクラスが作成されて"Created Greeter!"と表示されますが、"Hello"と"Goodbye"は表示されません。これが動くようにするには次のように変更する必要があります。

public class Greeter : Kernel
{
    public Greeter()
    {
        Debug.Log("Created Greeter");
    }
}

public class TestInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.BindInterfacesAndSelfTo<Greeter>()
            .FromSubContainerResolve().ByMethod(InstallGreeter).AsSingle().NonLazy();
    }

    void InstallGreeter(DiContainer subContainer)
    {
        subContainer.Bind<Greeter>().AsSingle();

        subContainer.BindInterfacesTo<GoodbyeHandler>().AsSingle();
        subContainer.BindInterfacesTo<HelloHandler>().AsSingle();
    }
}

これで実行時に"Hello World!"が表示され、停止したときに"Goodbye World!"が表示されるようになりました。

これが動作する理由は、GreeterクラスをContainer.BindInterfacesAndSelfTo<Greeter>()を使用してルートコンテナのIInitializable,IDisposable,ITickableにバインドしたからです。GreeterKernelを継承しています。Kernelはこれらすべてのインターフェイスを継承し、これらの呼び出しをIInitializable,ITickable,IDisposableのサブコンテナに転送します。ここではAsSingle()を使用することに注意してください。そうしないと、すべてのインターフェイスごとに新しいサブコンテナが作成されてしまいます。

GameObjectContextを使用してGameObjectのサブコンテナを作成する

上記のサブコンテナを用いた"Hello World"サンプルのもう一つの問題点は、MonoBehaviourクラスではうまく動かないということです。FromCoponentInNewPrefab()FromNewComponentOnNewGameObjectなどを使用してMonoBehaviourバインディングをサブコンテナに追加することは可能ですが、そうするとこれらのGameObjectはシーンのヒエラルキーのrootに追加されてしまうため、FacadeがDestroy()されたときにこれらのGameObjectを自分で追跡してGameObject.Destroy()で破棄するなどの寿命管理を行う必要があります。また、サブコンテナに含まれかつシーンの開始時に存在するようなGameObjectは作れません。これらの問題はGameObjectContextを使用することで解決できます。

この例では、サブコンテナの紹介で説明したオープンワールドの宇宙船のようなものを実際に実装してみます。

  • 新しいシーンを作成する
  • プロジェクトに次のファイルを追加する
using Zenject;
using UnityEngine;

public class Ship : MonoBehaviour
{
    ShipHealthHandler _healthHandler;

    [Inject]
    public void Construct(ShipHealthHandler healthHandler)
    {
        _healthHandler = healthHandler;
    }

    public void TakeDamage(float damage)
    {
        _healthHandler.TakeDamage(damage);
    }
}
using UnityEngine;
using Zenject;

public class GameRunner : ITickable
{
    readonly Ship _ship;

    public GameRunner(Ship ship)
    {
        _ship = ship;
    }

    public void Tick()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            _ship.TakeDamage(10);
        }
    }
}
public class GameInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.BindInterfacesTo<GameRunner>().AsSingle();
    }
}
using Zenject;
using UnityEngine;

public class ShipHealthHandler : MonoBehaviour
{
    float _health = 100;

    public void OnGUI()
    {
        GUI.Label(new Rect(Screen.width / 2, Screen.height / 2, 200, 100), "Health: " + _health);
    }

    public void TakeDamage(float damage)
    {
        _health -= damage;
    }
}
using UnityEngine;
using System.Collections;

public class ShipInputHandler : MonoBehaviour
{
    [SerializeField]
    float _speed = 2;

    public void Update()
    {
        if (Input.GetKey(KeyCode.UpArrow))
        {
            this.transform.position += Vector3.forward * _speed * Time.deltaTime;
        }

        if (Input.GetKey(KeyCode.DownArrow))
        {
            this.transform.position -= Vector3.forward * _speed * Time.deltaTime;
        }
    }
}
  • ヒエラルキーを右クリックしてZenject -> Scene Contextを選択する
  • SceneContextGameObjectGameInstallerをドラッグする
  • SceneContextInstallersプロパティに新しい行を追加する
  • Installersの下の新しい行にGameInstallerコンポーネントをドラッグする
  • ヒエラルキーを再び右クリックしてZenject -> Game Object Contextを選択する
  • 新しくできたGameObjectであるGameObjectContextShipにリネームする
  • ShipというGameObjectShipというコンポーネントをドラッグする
    • このShipクラスは船を高レベルで抽象化したFacadeクラスとして他のクラスから使用される
  • ShipGameObjectShipInputHandlerコンポーネントをつける
  • ShipGameObjectを右クリックして3D Object -> Cubeを選択する(船の3Dモデルの代わり)
  • ShipGameObjectの下にHealthHandlerというGameObjectを作成し、ShipHealthHandlerコンポーネントをつける

これでシーンは次のようになります。

img

  • ここでのアイデアは、船のゲームオブジェクト下にあるすべてがそれ自身のサブコンテナ内にあるとみなされるということです。 作業が終わったら、ShipHealthHandler,ShipInputHandlerなど独自のコンポーネントを持つ複数の船をシーンに追加して、シングルトンとして扱うことができます。
  • CTRL+SHIFT+Vを押してシーンのバリデーションを行ってみてください。次のようなエラーが表示されます:Unable to resolve type 'ShipHealthHandler' while building object with type 'Ship'.
  • これはShipHealthHandlerコンポーネントがサブコンテナに追加されていないからです。次のように対処します:
    • HealthHandlerGameObjectをクリックしてZenjectBindingコンポーネントを追加
    • HealthHandlerコンポーネントをZenjectBindingのフィールドにドラッグ
  • CTRL+SHIFT+Vで再びバリデーションを行ってみてください。別のエラーが表示されるようになります:Unable to resolve type 'Ship' while building object with type 'GameRunner'.
  • Shipコンポーネントもコンテナに追加される必要があります。これも同じように対処します:
    • ShipGameObjectをクリックしてZenjectBindingコンポーネントを追加
    • ShipコンポーネントをZenjectBindingのフィールドにドラッグ
  • 再びバリデーションを行うと、同じエラーが出ることに注意しましょう。これはデフォルトでZenjectBindingは最も近いコンテナ(この場合はShip)にのみコンポーネントを追加するからです。これは求めていた動作ではありません。ShipをサブコンテナのFacadeとしてシーンコンテナに追加したかったのです。これに対処するにはZenjectBindingに「バインディングがどのコンテキストに適用されるべきか」を明示的に教える必要があります。これはSceneContextGameObjectZenjectBindingContextプロパティにドラッグすることで可能です。
  • これでバリデーションが成功するようになりました。
  • スクリーンの中央に体力が表示され、スペースキーを押すとダメージが適用され、上下キーで船が動くようになりました。

また、SceneContextにインストーラを追加する方法と同じやり方でサブコンテナにもインストーラを追加することができます。GameObjectContextInstallersプロパティにドラッグするだけです。この例ではすべてMonoBehaviourを使用していますが、ZenjectBindingを使用してMonoBehaviourの場合と同じように、ここで必要なプレーンなC#クラスをいくつも追加して、サブコンテナ内のあらゆる場所で使用できます。

GameObjectContextを動的に作成する

上記の船の例に引き続き、ゲームが開始された後で船を動的に作ってみます。

  • まず最初に、上記で作成したShipGameObjectをプレハブ化し、シーンから削除します。
  • 次に、以下の変更を加えてください:
public class GameRunner : ITickable
{
    readonly Ship.Factory _shipFactory;

    Vector3 lastShipPosition;

    public GameRunner(Ship.Factory shipFactory)
    {
        _shipFactory = shipFactory;
    }

    public void Tick()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            var ship = _shipFactory.Create();
            ship.transform.position = lastShipPosition;

            lastShipPosition += Vector3.forward * 2;
        }
    }
}
public class GameInstaller : MonoInstaller
{
    [SerializeField]
    GameObject ShipPrefab;

    public override void InstallBindings()
    {
        Container.BindInterfacesTo<GameRunner>().AsSingle();

        Container.BindFactory<Ship, Ship.Factory>().FromSubContainerResolve().ByNewPrefab(ShipPrefab);
    }
}
  • さらに、インスペクタのGameInstallerShipPrefabプロパティに、新しく作成したShipプレハブをドラッグ&ドロップしてください。
  • シーンを実行してスペースキーを押しと複数のShipをシーンに追加できます。

GameObjectContextをパラメータを伴って動的に作成する

ShipのFacadeにパラメータを渡し、もっと面白くしてみましょう。船の速度をGameRunnerクラス内から設定できるようにします。

クラスを次のように変更してください:

public class GameRunner : ITickable
{
    readonly Ship.Factory _shipFactory;

    Vector3 lastShipPosition;

    public GameRunner(Ship.Factory shipFactory)
    {
        _shipFactory = shipFactory;
    }

    public void Tick()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            var ship = _shipFactory.Create(Random.RandomRange(2, 20));
            ship.transform.position = lastShipPosition;

            lastShipPosition += Vector3.forward * 2;
        }
    }
}
public class GameInstaller : MonoInstaller
{
    [SerializeField]
    GameObject ShipPrefab;

    public override void InstallBindings()
    {
        Container.BindInterfacesTo<GameRunner>().AsSingle();

        Container.BindFactory<float, Ship, Ship.Factory>().FromSubContainerResolve().ByNewPrefab<ShipInstaller>(ShipPrefab);
    }
}
using Zenject;
using UnityEngine;

public class Ship : MonoBehaviour
{
    ShipHealthHandler _healthHandler;

    [Inject]
    public void Construct(ShipHealthHandler healthHandler)
    {
        _healthHandler = healthHandler;
    }

    public void TakeDamage(float damage)
    {
        _healthHandler.TakeDamage(damage);
    }

    public class Factory : Factory<float, Ship>
    {
    }
}
using UnityEngine;
using System.Collections;
using Zenject;

public class ShipInputHandler : MonoBehaviour
{
    [Inject]
    float _speed;

    public void Update()
    {
        if (Input.GetKey(KeyCode.UpArrow))
        {
            this.transform.position += Vector3.forward * _speed * Time.deltaTime;
        }

        if (Input.GetKey(KeyCode.DownArrow))
        {
            this.transform.position -= Vector3.forward * _speed * Time.deltaTime;
        }
    }
}

また、以下の新しいファイルを追加します:

using System;
using Zenject;

public class ShipInstaller : MonoInstaller
{
    [Inject]
    float _speed;

    public override void InstallBindings()
    {
        Container.BindInstance(_speed).WhenInjectedInto<ShipInputHandler>();
    }
}

コンパイル後、ShipプレハブにShipInstallerを追加してGameObjectContextInstallersフィールドにドラッグします。

変更点は以下のようになります:

  • ShipInputHandlerspeedはUnityのSerializeFieldを使う代わりにinjectされるようになった
  • Ship内のネストされたFactoryクラスはfloatのパラメータを持つようになった
  • GameInstaller内のFactoryのバインディングが異なる
  • GameRunner内でFactoryのcreateメソッドにfloatのパラメータを渡す必要がある

Factoryを使用してサブコンテナを作成する際の重要な違いの1つは、Factoryに提供するパラメータが必ずしもFacadeクラスに転送されるとは限らないということです。この例におけるパラメータは速度を表すfloat値で、Shipクラスの代わりにShipInputHandlerクラスに転送されます。そのため、これらのパラメータはサブコンテナのインストーラに常に転送されるため、インストール時にパラメータの処理方法を自分で決めることができます。このようにするもう一つの理由は、パラメータが異なるバインディングを選択するために使用される場合があるからです。

デザイン時にShipプレハブをシーンに設置したい場合があるかもしれません。Shipをシーンの開始から設置し、かつ動的に作成できるようにするにはShipInstallerを次のように変更します。

using System;
using Zenject;
using UnityEngine;

public class ShipInstaller : MonoInstaller
{
    [SerializeField]
    [InjectOptional]
    float _speed;

    public override void InstallBindings()
    {
        Container.BindInstance(_speed).WhenInjectedInto<ShipInputHandler>();
    }
}

こうすることでShipプレハブをシーンに置いてインスペクタで_speedを制御でき、また動的に作成してパラメータとしてfactoryに渡すこともできます。

より現実的な例として、GameObjectContextを大量に使用しているSpaceFighterサンプルプロジェクトを参照してください。