imog

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

Zenject Factoryの話

はじめに

このエントリはUNIBOOK10で書いた記事を転載したものです。

unity-bu.booth.pm

UNIBOOK10、いい話がいっぱいあるから買ってね!

Zenject Factory


ZenjectはUnityで動作するDIライブラリです。煩わしい依存関係の解決を助ける非常に強力な武器となりますが、 強力かつ多機能であるためどこから学んでいくべきか悩むことも多いです。公式のドキュメントはとても充実していますが、前提となる知識が多かったり、基本的なBindingの話以外は日本語での資料が少ないというのも理由の1つでしょう。

この記事では、ZenjectがもつFactoryによる動的なオブジェクト生成について簡単な解説を行います。

github.com

動的に生成したインスタンスへのインジェクション

シーン読み込み時にInstallerを実行して、オブジェクトをバインドし依存関係を解決していくというのがZenjectのスタンダードな使い方でしょう。Containerはシーン開始時の1回のみインジェクションしてくれます。

しかし、実際の開発においてすべてのインスタンスが初期化時に存在するとは限りません。たとえば、アクションゲームで敵キャラが動的に生成されることは十分に考えられるでしょう。

次の例で考えてみましょう。

  1. Enemyは動的に生成される
  2. Enemyはプレイヤーに依存している
  3. PlayerはContainerにバインドされている
  4. 敵の種類は今後増える可能性がある

最低限のコードで書くとこうなります。

public class Player { }
public interface IEnemy { }
public class Enemy : IEnemy
{
    Player _player;
    public Enemy(Player player)
    {
        _player = player;
    }
}

public class MainGameInstaller : MonoInstaller<MainGameInstaller> {
    public override void InstallBinding()
    {
        Container.Bind<Player>().AsCached();
    }
}

「今後増える可能性がある」という前提があるのでIEnemyインタフェースを定義して複数に対応できるようにしています。 このEnemyを扱いたい場合どのように書けばよいでしょう。EnemyはPlayerに依存しているので、生成する側はコンストラクタで渡すためのPlayerを知っている必要があります。

Enemyを生成するクラスEnemySpawnerを素直に書くと次のようになるでしょう。

public class EnemySpawner
{
    [Inject]
    Player _player;
    List<IEnemy> _enemies = new List<IEnemy>();

    public void Spawn()
    {
        var enemy = new Enemy(_player);
        _enemies.Add(enemy);
    }
}

これにより、使う側はSpawnerさえ持っておけばPlayerのことは知らずに済みます。 しかしこの方針だと、Enemyの更新に伴いSpawnerのもつオブジェクトが増えていきます。 たとえばEnemyがPlayerだけでなくて難易度情報なども必要になってくるとどうなるでしょう。

public class Enemy : IEnemy
{
    Player _player;
    Difficulty _difficulty;

    public Enemy(Player player, Difficulty difficulty)
    {
        _player = player;
        _difficulty = difficulty;
    }
}

public class EnemySpawner {
    [Inject]
    Player _player;
    [Inject]
    Difficulty _difficulty;
    List<IEnemy> _enemies = new List<IEnemy>();
    public void Spawn(){
        var enemy = new Enemy(_player, _difficulty);
        _enemies.Add(enemy);
    }
}

Enemyが膨れ上がるにつれEnemySpanwerも修正が必要になり管理コストが上がっていきます。 EnemySpawnerはEnemyを生成したいだけあり、どうやって生成するかなどのEnemyの内部実装をそこまで知る必要はありません。Spanwer自体はプレイヤーのことも難易度のことも関心がないのです。 今後別のIEnemyが実装されたときにまた別の引数が必要になるかもしれません。それらをSpawnerが把握するのは大変です。

一応、DiContainerを渡せばどれだけ引数が増えても対応は可能です。

public class Enemy : IEnemy
{
    Player _player;
    Difficulty _difficulty;

    public Enemy(DiContainer container)
    {
        _player = container.Resolve<Player>();
        _difficulty = container.Resolve<Difficulty>();
    }
}

public class EnemySpawner {
    [Inject]
    DiCotainer _container;
    List<IEnemy> _enemies = new List<IEnemy>();
    public void Spawn(){
        var enemy = new Enemy(_container);
        _enemies.Add(enemy);
    }
}

しかしこれは、次の問題を生み出してしまいます。

  • Player,Difficultyが存在するのか実行時まで気づきにくい
  • DiContainerへの依存が発生し、結果的に依存先が1つ増えている

仮にDifficultyがバインドされていない場合、それに気づくことができるのはSpawnを呼び出したタイミングになってからです。Zenjectはシーン内の依存関係を解決するバリデーション機能がエディタ上で実行できて大変便利なのですが、それらの恩恵に預かれないのはもったいないです。

また、コード量は減りましたが「コンテナそのものに依存する」という新たな依存が発生しています。これはサービスロケータと呼ばれるデザインパターンでDIとはまた異なってきます。

これらの問題を解決するためには、Enemyを生成したいEnemySpawnerから、生成のために必要な関心事を切り出してあげたほうがよさそうです。このような場合Fatoryパターンが効果的です。

Factoryパターン

関心事を切り出すために、Enemyの生成とそれに必要な情報を集約したFactoryクラスを定義します。

public class Enemy : IEnemy
{
    Player _player;
    public Enemy(Player player)
    {
        _player = player;
    }

    public class Factory
    {
        readonly Player _player;
        public Factory(Player player)
        {
            _player = player;
        }

        public Enemy Create()
        {
            return new Enemy(_player);
        }
    }
}

public class EnemySpawner
{
    [Inject]
    Enemy.Factory _enemyFactory;
    List<IEnemy> _enemies = new List<IEnemy>();

    public void Spawn()
    {
        var enemy = _enemyFactory.Create();
        _enemies.Add(enemy);
        // 何かする
    }
}

public class MainGameInstaller : MonoInstaller<MainGameInstaller> {
    public override void InstallBinding()
    {
        Container.Bind<Player>().AsCached();
        Container.Bind<Enemy.Factory>().AsCached();
    }
}

先ほどとの違いは2点です。

  • Enemy.Factoryクラスが定義された
  • EnemySpawnerからEnemyに関連する要素がなくなった

Enemy.FactoryはEnemyインスタンスを作るだけのクラスです。インスタンスを作るために必要な要素をすべて持っており、外部から引数を渡さずともCreate()を呼ぶだけでEnemyを返してくれます。これを事前にバインドしています。

これにより、EnemySpawnerはEnemyではなくEnemy.Factoryに依存します。Enemyのコンストラクタを呼びだす必要がなくなったので不要なメンバ変数が消えてFactory.CreateするだけでEnemyを取得できるようになりました。 仮に今後IEnemyの種類が増えても、実態を知らずともそれぞれのFactoryを呼びだすだけで取得できるので差し替えが以前より簡単になりました。

Factoryパターンのメリットは依存度を下げられることにあります。直接実態を生成しようとするとどうしても依存関係を知る必要があるため、コードのあちこちにクラスの役割とは直接の関係がないコンストラクタ呼び出しのためのメンバ変数が増えてしまいます。これは避けられないので、ならば生成処理を一か所に集約することで依存関係を把握しなければいけないクラスを減らしてしまおうというやり方です。

なお、現状は特定のFactoryに依存しているのでIEnemyの実体を動的に切り替えということはできません。

BindFactory

先ほどは自前でFactoryパターンの実装を行いましたが、Zenjectでは、Factoryパターンを簡単に実装できるような仕組みがあります。 PlaceholderFactoryを実装してBindFactoryでバインドするだけです。

public class Enemy
{
    Player _player;
    public Enemy(Player player)
    {
        _player = player;
    }

    public class Factory :  PlaceholderFactory<Enemy>
    {
    }
}

public class MainGameInstaller : MonoInstaller<MainGameInstaller> {
    public override void InstallBinding()
    {
        Container.Bind<Player>().AsCached();
        Container.BindFactory<Enemy, Enemy.Factory>();
    }
}

挙動は先ほどと同じですが、かなりコードがシンプルになりました。変更したのは次の二点です。

  • Enemy.FactoryにPlaceholderFactoryを継承させた
  • 通常のBindをやめてContainer.BindFactory<Enemy, Enemy.Factory>()を呼んだ

元々作っていたfactoryの実装は「Enemyのコンストラクタに必要なインスタンスの保持」と「インスタンスの生成」でしたが、PlaceholderFactory<T>は先述した2つの機能を備えています。 なので単純な依存関係の管理と生成だけなら継承するだけで全く同じことができます。

BindFactory<Enemy, Enemy.Factory>() は、Enemyの生成を行うFactoryクラスをバインドします。FactoryはEnemyの依存関係を見て、コンテナから引数に該当するオブジェクトをよしなに渡してくれます。 今回の場合だとバインドされたPlayerをコンストラクタとして渡してくれます。

また、PlaceholderFactoryは動的にパラメータを渡すこともできます。たとえば、敵の移動速度だけ外部から流し込めるようにしたいときは次のとおりです。

public class Enemy : IEnemy
{
    readonly Player _player;
    readonly float _speed;

    public Enemy(float speed, Player player)
    {
        _player = player;
        _speed = speed;
    }

    public class Factory : PlaceholderFactory<float, Enemy>
    {
    }
}

public class EnemySpawner
{
    [Inject]
    Enemy.Factory _enemyFactory;
    List<IEnemy> _enemies = new List<IEnemy>();

    public void Spawn()
    {
        var enemy = _enemyFactory.Create(Random.Range(1F, 100F));
        _enemies.Add(enemy);
    }
}

public class MainGameInstaller : MonoInstaller<MainGameInstaller> {
    public override void InstallBinding()
    {
        Container.Bind<Player>().AsCached();
        Container.BindFactory<float, Enemy, Enemy.Factory>();
    }
}

PlaceholderFactory<float, Enemy> のように、先頭に渡したい型を指定します。 Bind時も同様にBindFactoryの先頭に型を指定するだけです。 これでEnemy.Factory.Createに引数を持たせれるので動的にパラメータを渡せます。

ちなみに、コンストラクタを呼べないMonoBehaviourの場合は次のとおりです。

public class Enemy : MonoBehaviour, IEnemy
{
    Player _player;
    [Inject]
    public void Construct(Player player)
    {
        _player = player;
    }

    public class Factory : PlaceholderFactory<Enemy>
    {
    }
}

public class MainGameInstaller : MonoInstaller
{
    public GameObject EnemyPrefab;

    public override void InstallBindings()
    {
        Container.Bind<Player>().AsSingle();
        Container.BindFactory<Enemy, Enemy.Factory>()
                        .FromComponentInNewPrefab(EnemyPrefab);
    }
}

コンストラクタがメソッドインジェクションに変わるだけでほとんど同じです。

先ほどの例だとIEnemyは一種類でしたが、数が増えてきたらどう扱うべきでしょうか。

public interface IEnemy {
}
public class Slime : IEnemy {
}
public class Orc : IEnemy {
}

SlimeとOrcのどちらが生成されるか、実行時に確定させたい場合どうしたらいいでしょうか。この場合はBindFactory時に型を指定するだけでよいです。

public class EnemyFactory : PlaceholderFactory<IEnemy>
{
}

public class EnemyInstaller : MonoInstaller
{
    public bool UseOrc;

    public override void InstallBindings()
    {
        if (UseOrc)
        {
            Container.BindFactory<IEnemy, EnemyFactory>().To<Orc>();
        }
        else
        {
            Container.BindFactory<IEnemy, EnemyFactory>().To<Slime>();
        }
    }
}

public class EnemySpawner {
    EnemyFactory _factory
    List<IEnemy> _enemies = new List<IEnemy>();
    public EnemySpawner(EnemyFactory factory) {
        _factory = factory;
    }

    public void Spawn() {
        var enemy = _factory.Create();
        _enemies.Add(enemy);
    }
}

Toで明示的に型を指定すれば任意のインスタンスを生成するEnemyFactoryになります。EnemySpawner側はもはや一切の実態を気にすることなくIEnemyの管理に集中できます。

カスタムファクトリ

上記の例ではInstallerでFactoryの種類を決めていました。しかし場合によっては実行中に動的にFactoryを切り替えたい場合があります。 その場合はどうやって切り替えるか、というロジックもFactoryに内包させられると使う側は楽になるでしょう。しかしPlaceholderFactoryにそのような機能はありません。

ここは、大元であるIFactoryインタフェースを実装した自前のカスタムファクトリを作る必要があります。

今回は「難易度で生成する敵を変更する」カスタムファクトリを作ります。

public enum Difficulty
{
    Noraml,
    Hard
}
public interface IEnemy { }

public class Orc : IEnemy {
    public class Factory : PlaceholderFactory<Orc> { }
}

public class Slime : IEnemy {
    public class Factory : PlaceholderFactory<Slime> { }
}

public class EnemyFactory : PlaceholderFactory<IEnemy> { }

public class CustomEnemyFactory : IFactory<IEnemy>
{
    Orc.Factory _orcFactory;
    Slime.Factory _slimeFactory;
    Difficulty _difficulty;

    public CustomEnemyFactory(Orc.Factory orcFactory,
                              Slime.Factory slimeFactory,
                              Difficulty difficulty)
    {
        _orcFactory = orcFactory;
        _slimeFactory = slimeFactory;
        _difficulty = difficulty;
    }

    public IEnemy Create()
    {
        if(_difficulty == Difficulty.Hard)
        {
            return _orcFactory.Create();
        }
        return _slimeFactory.Create();
    }
}

public class EnemySpawner
{
    EnemyFactory _enemyFactory;
    List<IEnemy> _enemies = new List<IEnemy>();
    public EnemySpawner(EnemyFactory enemyFactory)
    {
        _enemyFactory = enemyFactory;
    }

    public void Spawn()
    {
        var enemy = _enemyFactory.Create();
        _enemies.Add(enemy);
    }

}

public class EnemyInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<Difficulty>().FromInstance(Difficulty.Noraml).AsCached();
        Container.BindFactory<Orc, Orc.Factory>();
        Container.BindFactory<Slime, Slime.Factory>();
        Container.BindFactory<IEnemy, EnemyFactory>()
                                            .FromFactory<CustomEnemyFactory>();
    }
}

ロジックはすべてIFactoryを実装したCustomEnemyFactoryに集約されています。このクラスはそれぞれ実際にインスタンス生成を行うFactoryを所持しており条件に応じて切り替えています。 FromFactoryメソッドを指定することで、EnemyFactoryのCreateが呼ばれたときに処理を委譲できます。 EnemyFactoryへの影響は全くなく、ロジックの差し替えも容易です。

閑話:空のFactoryは必要か

毎回PlaceholderFactory<T>を継承しただけのFactoryクラスを定義していますが、厳密には定義しなくてもバインドはできます。 次のようにPlaceholderFactoryを直接バインドすることも可能です。

Container.BindFactory<Enemy, PlaceholderFactory<Enemy>>();

メンバ変数で持つときも同様に直接PlaceholderFactoryを持ってしまえばFactoryクラスを作る必要はありません。コードを書く手間が省けるのでよさそうに思えますね。

しかし、ドキュメントではこれを非推奨としています。理由は次の二点です。

  • パラメータの変更があった場合にコンパイルエラーが発生しないので気づくのが遅くなる
  • PlaceholderFactry<T> より T.Factory の方が可読性が高いと考えている。

たとえば、PlaceholderFactoryをそのまま使って、あとからパラメータを1つ追加したとしましょう。 次のコードのようになります。

public class Enemy : IEnemy
{
    readonly Player _player;
    readonly float _speed;

    public Enemy(float speed, Player player)
    {
        _player = player;
        _speed = speed;
    }
}

public class EnemySpawner
{
    [Inject]
    PlaceholderFactory<float, Enemy> _enemyFactory;
    List<IEnemy> _enemies = new List<IEnemy>();

    public void Spawn()
    {
        var enemy = _enemyFactory.Create(Random.Range(1F, 100F));
        _enemies.Add(enemy);
    }
}

public class MainGameInstaller : MonoInstaller<MainGameInstaller> {
    public override void InstallBinding()
    {
        Container.Bind<Player>().AsCached();
        Container.BindFactory<Enemy, PlaceholderFactory<Enemy>>();
    }
}

このコードはコンパイルは通りますが、実行時にエラーを出してしまいます。理由は単純で、バインド処理のところにfloat型を追加するのを忘れているからです。

PlaceholderFactoryはバインドされているがEnemySpawnerが参照しているPlaceholderFactory<float, Enemy>がInjectできずにエラーが発生します。些細なことですがよくありがちなエラーです。これがFactoryクラスを定義しているならば変更時点でコンパイルエラーが教えてくれたはずです。 また、パラメータが増えるほどPlaceholderFactoryという名前が何のFactoryなのか難しくなってきます。PlaceholderFactory<Player, Difficulty, Enemy>という名前のFactoryは理解に時間がかかってしまうでしょう。たった数行のコードを減らすことで得られるメリットはあまり大きくありません。

上記の理由から、基本的にはEnemy.FactoryのようにネストしたFactoryを作ることが推奨されています。 特に強い理由がなければその都度定義した方がよいでしょう。

終わりに

Zenjectは強力ですがその分難易度も高いと言われています。特に動的なインスタンスのInjectはやり方をしっかり考えないとContainerを各所に引きずり回してインジェクションなどをやってしまいがちです。これはコードをより難解なものにしてしまうので、Factoryを用いた設計を覚えてシンプルな仕組みに書き直していくのがよいでしょう。