imog

主にUnityとかの事を書いています

Zenjectを使うときに気を付けていること

このエントリはUnity Advent Calendar 20194日目のやつです。

Zenjectは便利ですが、実際に扱っている人の知見やtipsがまだまだ世に出回ってない印象です。APIの使用方法も欲しいですが実際に使ってる人たちがどういうお気持ちで使っているのかというのはどんどん出回って欲しいので、今回は僕がZenjectを使う際にチョットだけ気を付けていることを列挙します。 そんなにテクニカルなことではなく心持ちみたいなのが多めです。

これが正しい!というのは中々決められないと思うので、1つの考え方として参考になれば幸いです。

基本的にコンストラクタインジェクションを行う

Zenjectでインジェクションを行う場合、4つの選択肢があります

  • フィールドインジェクション
  • プロパティインジェクション
  • メソッドインジェクション
  • コンストラクタインジェクション
public class Foo {
    [Inject]
    private Bar bar;
    [Inject]
    public Hoge hoge { get; private set; }
    [Inject]
    public void Construct(Fuga fuga) { }

    public Foo(Piyo piyo) { }
}

この中でコンストラクタは1度しか呼ばれないので、コンストラクタインジェクションするということは、あとから外部から変更される可能性を無くすことができます。 また、オブジェクト間で循環参照が発生したとき、コンストラクタインジェクションはエラーを吐きますがそれ以外の三つは循環参照を許容します。循環参照は複雑性が高くなり、明確な目的がない限りは避けていきたいところなのでバリデーションしてもらえるコンストラクタインジェクションはを使うように気を付けています。しかしMonoBehaviourはコンストラクタが呼べないので泣く泣くメソッドインジェクションを行っています。

public class FooBehaviour : MonoBehaviour {
    [Inject]
    public void Construct(Fuga fuga) { }
}

名前はコンストラクタの代わりなのでConstructと付けるのが好きです。

intefaceをBindする

基本ですが、クラスはBindせずinterfaceだけを渡すようにします

public interface IFoo {}
public class Foo : IFoo {}

Container.BindInterfaces<Foo>().AsCached(); // IFooで登録するがFooは登録しない
//Container.Bind<Foo>().AsCached(); // 直接クラスは登録しない

これを実現するために、Injectされる側もinterfaceでメンバ変数を持つような実装になります。あと、今回のIFooのような1つにしか実装されないようなものも毎回インターフェースで渡すことになります。これは基本的に依存関係逆転の原則(DIP)のルールに従うためです。もう一つとしてはDIを行われる側から詳細(実際のクラス)を減らしたいお気持ちがあるからです。

理想を言うのであれば、詳細の情報はinstallerから漏れ出ない状態が嬉しいです。Installerはクリーンアーキテクチャ本で言うところの「Mainコンポーネント」の役割を持っていると考えています。Mainコンポーネントは最も下位レイヤーの処理であり、初期状態を作成したり設定を構築したりと大変泥臭い部分です。ここでどんなデータがあるのか、どんな詳細を持っているのか、どう渡すのかという部分が詰め込まれることで、Mainコンポーネントを変えるだけでテスト、開発、本番環境などを切り替えれるようになります。この動きをInstallerで行うためには、実際のクラス情報はInstallerだけが知っている設計にする必要があり、そうなると各依存関係はintefaceでのやり取りにしたいよね・・という感じです。

とはいえ、Factoryとかはよく実体を返しがちなので徹底するならちゃんとカスタムファクトリ作らないとね~ってなる。あくまで理想としてです。

primitiveな型をBindしない

整数型や文字列などをそのままInstallerにBindするのは控えてます。

public class MainInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<string>().FromInstance("name").AsCached();
        Container.Bind<int>().FromInstance(100).AsCached();
    }
}

public class Player : MonoBehaviour {
    [Inject]
    public void Construct(int life, string name) {}
}

Containerは型で全てを判断するのでBind<int> Bind<string>と言ったプリミティブ型はInstallerを見直したときに何を表すのか非常にわかりにくいです。また、同一コンテナ内で競合を起こしやすいです。上記の例では別のクラスが攻撃力としてintを求めていたとしてもぶつかってしまいます。WithIdオプションで差別化は可能ですが、意味のある値ならば素直にクラスを作ってあげる方が良いでしょう。

ListをBindしない

Container内部でIEnumerable<T>が見つからなかったとき、Zenjectは空のリストをInjectする特性があります。

public class MainInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<Foo>().AsCached();
    }
}

public class Foo {
    private List<Bar> _barList;
    public class Foo(List<Bar> barList){
        _barList = barList;
    }
}

上記の場合、List<Bar>がないのでエラーが出そうに見えますが_barListには空のリストが入り、バリデーションエラーも発生しません。これはうっかりBind忘れを起こしていた場合にも正常に動作してしまうので非常に問題に気づきにくいです。これもやはりちゃんとクラス作ってあげましょう。

手動でもDIできるようにする

実際に手動でやることはあんまりないですが、DiContainerが無いと動かせない状況は避けています。具体的にはprivateなメソッドへのInjectは避けようという考えです。

public class FooBehaviour : MonoBehaviour {
    [Inject]
    private void Construct(Fuga fuga) { }
}

上記のコードはZenjectの機能を用いないと初期化処理が難しい状況になっています。メソッドを不用意に公開しないというのは有効な手ではありますが、この初期化処理はZenjectに自動で行ってもらっているだけで外部公開してはいけないものではないと考えています。ここをPublicメソッドにしてもらえれば、軽い動作確認を行うときにInstallerを用意する必要はなくなるし、テストコードを書くときにContainerを用意せずに済むので少し楽になります。 手動で行うべきところをZenjectに自動でやってもらってるくらいの気持ちで[Inject]を付けるといいかもしれません。

Validateを使う

ZenjectにはValidate機能が備わってて、シーン内の依存関係であるならPlayModeで実行せずにShift+Alt+vで自動チェックしてくれます。

image

毎回PlayMode実行してAssert Hit!と怒られるよりは速いので癖をつけておくのはおススメです。 ただ、Factoryが生成してくれるかみたいな動的な部分は対応していないのでご注意ください。

DiContainerを持ち歩かない

割と基本ではありますがContainerの存在を極力隠します。目安としてはInstallerのコード以外でContainerを呼ばないように心がけています。Installer以外、つまりアプリケーションにContainerが入り込んでくるのはコンテナを用いたサービスロケータパターンのような振る舞いをしている恐れがあります。せっかくContainerが何も意識させることなくInjectしてくれてるのに自らConainerに依存するのは勿体ないです。動的な生成を行いたい場合はDiContainer.Instantiateではなく必ずFactoryを生成するようにしましょう。

adarapata.hatenablog.com

例外として、ファクトリにContainerを持たせるのはアリかなと思う派です。理由としては依存関係のバリデーション機能が提供されているから使いたい・・というところです。

github.com

コードの部分を抜粋すると次の通り

public class CustomEnemyFactory : IFactory<IEnemy>, IValidatable
{
    DiContainer _container;
    DifficultyManager _difficultyManager;

    public CustomEnemyFactory(DiContainer container, DifficultyManager difficultyManager)
    {
        _container = container;
        _difficultyManager = difficultyManager;
    }

    public IEnemy Create()
    {
        if (_difficultyManager.Difficulty == Difficulties.Hard)
        {
            return _container.Instantiate<Demon>();
        }

        return _container.Instantiate<Dog>();
    }

    public void Validate()
    {
        _container.Instantiate<Dog>();
        _container.Instantiate<Demon>();
    }
}

public class TestInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.BindFactory<IEnemy, EnemyFactory>().FromFactory<CustomEnemyFactory>();
    }
}

Validate内部での処理は実際には生成されないいわゆるDry-runです。上記の場合、DogDemonの生成が正しく行えるか依存関係を遡ってチェックしてくれます。

積極的にSubContainerを使う

SceneContextのInstallerに全部書いていくと非常にFatなInstallerになってしまうので、隙あらば積極的にSubContainerに移し替えていきましょう。

speakerdeck.com

個人的にはなんらかのスクリプトがアタッチされているPrefabには全部GameObjectContextを付けてSubContainer運用にしていいくらいまであります。Installerの数がかなり増えるので管理だけは気を付けて・・。

もし、様々な事情が絡み合いSubContainerが使えないという場合は、役割を明確に分離してInstallerを複数に切り分けるのも手です。後にContainerを分割する際に手がかかりにくいです。とはいえ、1つのContainerにめっちゃInstallされるので競合問題は起きうるので気を付けましょう。

Initializeで購読する

初期化をどのタイミングで行うか問題はやや面倒ですが、ZenjectだとIInitializableを実装しておくとまとめて初期化処理を呼んでくれるので使うことが多いです。

public interface IFooUseCase {
    void DoFoo();
}

public class FooUseCase : IFooUseCase, IInitializable {
    public void Initialize() { /* なんか初期化 */ }
    public void DoFoo() {}
}

どうせ初期化処理の順番管理するクラスはいずれ必要になるので、Zenjectに任せてしまえるのは楽です。Container.BindInitializableExecutionOrder<FooUseCase>(1) とかでInitializableの中でもクラスごとに順序は制御できます。

また、ゲームはイベント駆動で動きがちなので何らかのイベントや、UniRxを使っているならストリームを購読することが多いです。Initializeではそれら外部のイベントを購読する処理だけを書くことが多いです。

public class TimeOverUseCase : ITimeOverUseCase, IInitializable {
    private IGameTimer _timer;

    public TimeOverUseCase(IGameTimer timer) {
        _timer = timer;
    }

    public void Initialize() {
        _timer.TimeOverObservable.Subscribe(_ => DoGameEndSequence());
    }
    public void DoGameEndSequence() { /* */ }
}

ユニットテストを書くときには上記のように購読とビジネスロジックが明確に分かれていると楽です。このコードの例だと、プロダクションではイベント駆動で勝手に動きますがテストを書くときはInitializeを呼ばずに直接DoGameEndSequenceを呼べるのでユニットテストで手を抜けます。

デバッグ機能はSceneDecoratorContextで切り分ける

例えば、現在の状態を表示したいなあと思ったときに画面にデバッグ用のUIを仕込みたいことが度々あります。 画像で言うPlayerCharacterTurnStartがそれ。 image

この辺りは該当のシーンのSceneDecoratorContextを作ると楽です。今回だとBattleシーンの細かいログとか取りたいのでBattleDebugシーンを作ってそこにSceneDecoratorContextを貼ってあげます。

image

そこに使い捨てでもいいので雑なデバッグクラスを差し込んであげる。

public class DebugInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.BindInterfacesTo<StateChangeNotifyUseCase>().AsSingle();
        Container.BindInterfacesTo<StatePresenter>().AsCached();
    }
}

Zenjectをいい感じに使えているなら、デバッグ時に欲しい情報はDecorateされるシーン側のContainerにBindされているはずなので、サクサクっと画面表示などはできます。 もしインスタンスそのものに手を加えたいとかの場合(例えばデバッグ時だけは実行時間をロギングするとか)は、DecoratorBindingも組み合わせてみると綺麗にやれるのでおススメです。

adarapata.hatenablog.com

直接コード書いていくより手間はかかりますが、プロダクションに入れたくないものが絶対に混入しないという安心感は心の平穏を保ってくれます。

おわりに

君だけのZenjectテクニックを教えてくれよな!