はじめに
なんだかすごいと噂の Zenject というアセットがあります。このアセットの"GameObjectContext"という機能に関して日本語の解説がどこにもなかったので、これについて説明しているドキュメントのページを日本語訳してみました。
Zenjectマスターに、俺はなる!
以下、 Sub-Containers And Facades の日本語訳
サブコンテナとFacades
同一のアプリケーション内で複数のコンテナを使うのが便利な場合があります。例えばあなたがワードプロセッサを作っているとしたら、別々のドキュメントを表すタブごとにそれぞれのサブコンテナを持つのが便利かもしれません。この場合、サブコンテナ内にAsSingle()
なクラス群をバインドでき、それらはまるでシングルトンクラスのように容易に互いを参照し合うことが可能です。サブコンテナはそれぞれのドキュメントごとにインスタンス化可能で、ドキュメントを扱うための全てのクラスのユニークなインスタンスを持ちます。
別の例として、オープンスペースの宇宙船ゲームを設計している場合、各宇宙船ごとに独自のコンテナ(その宇宙船を動かすためのすべてのクラスインスタンスを含んでいる)を持たせることができます。
ここで、ProjectContext
の実際の動作は次のようになっています。プロジェクト全体で一つのコンテナがあり、Unityのシーンが開始したときに各SceneContext
はProjectContext
の下に生成されます。シーンの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
にバインドしたからです。Greeter
はKernel
を継承しています。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
を選択する -
SceneContext
のGameObject
にGameInstaller
をドラッグする -
SceneContext
のInstallers
プロパティに新しい行を追加する -
Installers
の下の新しい行にGameInstaller
コンポーネントをドラッグする - ヒエラルキーを再び右クリックして
Zenject -> Game Object Context
を選択する - 新しくできた
GameObject
であるGameObjectContext
をShip
にリネームする -
Ship
というGameObject
にShip
というコンポーネントをドラッグする- この
Ship
クラスは船を高レベルで抽象化したFacadeクラスとして他のクラスから使用される
- この
-
Ship
のGameObject
にShipInputHandler
コンポーネントをつける -
Ship
のGameObject
を右クリックして3D Object -> Cube
を選択する(船の3Dモデルの代わり) -
Ship
のGameObject
の下にHealthHandler
というGameObject
を作成し、ShipHealthHandler
コンポーネントをつける
これでシーンは次のようになります。
- ここでのアイデアは、船のゲームオブジェクト下にあるすべてがそれ自身のサブコンテナ内にあるとみなされるということです。 作業が終わったら、
ShipHealthHandler
,ShipInputHandler
など独自のコンポーネントを持つ複数の船をシーンに追加して、シングルトンとして扱うことができます。 - CTRL+SHIFT+Vを押してシーンのバリデーションを行ってみてください。次のようなエラーが表示されます:
Unable to resolve type 'ShipHealthHandler' while building object with type 'Ship'.
- これは
ShipHealthHandler
コンポーネントがサブコンテナに追加されていないからです。次のように対処します:-
HealthHandler
のGameObject
をクリックしてZenjectBinding
コンポーネントを追加 -
HealthHandler
コンポーネントをZenjectBinding
のフィールドにドラッグ
-
- CTRL+SHIFT+Vで再びバリデーションを行ってみてください。別のエラーが表示されるようになります:
Unable to resolve type 'Ship' while building object with type 'GameRunner'.
-
Ship
コンポーネントもコンテナに追加される必要があります。これも同じように対処します:-
Ship
のGameObject
をクリックしてZenjectBinding
コンポーネントを追加 -
Ship
コンポーネントをZenjectBinding
のフィールドにドラッグ
-
- 再びバリデーションを行うと、同じエラーが出ることに注意しましょう。これはデフォルトで
ZenjectBinding
は最も近いコンテナ(この場合はShip
)にのみコンポーネントを追加するからです。これは求めていた動作ではありません。Ship
をサブコンテナのFacadeとしてシーンコンテナに追加したかったのです。これに対処するにはZenjectBinding
に「バインディングがどのコンテキストに適用されるべきか」を明示的に教える必要があります。これはSceneContext
のGameObject
をZenjectBinding
のContext
プロパティにドラッグすることで可能です。 - これでバリデーションが成功するようになりました。
- スクリーンの中央に体力が表示され、スペースキーを押すとダメージが適用され、上下キーで船が動くようになりました。
また、SceneContext
にインストーラを追加する方法と同じやり方でサブコンテナにもインストーラを追加することができます。GameObjectContext
のInstallers
プロパティにドラッグするだけです。この例ではすべてMonoBehaviour
を使用していますが、ZenjectBinding
を使用してMonoBehaviour
の場合と同じように、ここで必要なプレーンなC#クラスをいくつも追加して、サブコンテナ内のあらゆる場所で使用できます。
GameObjectContext
を動的に作成する
上記の船の例に引き続き、ゲームが開始された後で船を動的に作ってみます。
- まず最初に、上記で作成した
Ship
のGameObject
をプレハブ化し、シーンから削除します。 - 次に、以下の変更を加えてください:
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);
}
}
- さらに、インスペクタの
GameInstaller
のShipPrefab
プロパティに、新しく作成した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
を追加してGameObjectContext
のInstallers
フィールドにドラッグします。
変更点は以下のようになります:
-
ShipInputHandler
のspeed
は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サンプルプロジェクトを参照してください。