🙆

UnityのDI超ざっくり入門 3 - VContainerを使ってみる

2024/05/30に公開

はじめに

前回はUnityにおけるDI(Dependency Injection)のちょっと実践的な使用方法についての記事を書きました。

https://zenn.dev/qemel/articles/4a032e10b3629c

前回での気づきとして、UnityDIはこのままの手順でやるとちょっと面倒になる、というお話をしました。

他にも、UnityDIを手書きで書くデメリットはいくつかあります。

  • newなどの実行順が大事になってくるのが面倒
  • エントリポイントの設計がちょっと面倒
    • MonoBehaviourクラスのStart, UpdateなどのライフサイクルメソッドをPureC#で利用したい場合、GameEntryPointでそれらを呼び出す必要がある
  • 複数個のオブジェクトの依存の解決・動的な依存の解決が面倒
  • いちいちInit()メソッドを呼び出すのが面倒・保守性が微妙

…要するに、総じて「GameEntryPointを書くのが面倒!!難しい!!」ということです。

今回は、これらのデメリットを解消するために、UnityDIライブラリの一つであるVContainerを使ってみます。

VContainerとは

VContainerはUnity向けのDIライブラリです。とりあえずは

前回のGameEntryPointみたいなことを、もっと簡単な記述で行えるようにするライブラリ

と考えてもらえればいいかと思います。

前回のGameEntryPoint

前回の記事で作成したGameEntryPointは以下のような感じでした。

前回のGameEntryPoint
public class GameEntryPoint : MonoBehaviour
{
    [SerializeField] private Player _player;
    [SerializeField] private Enemy _enemy;
    [SerializeField] private List<Coin> _coins;
    [SerializeField] private GameOverUI _gameOverUI;
    [SerializeField] private ScoreUI _scoreUI;

    private GameLoopSystem _gameLoopSystem;

    private void Awake()
    {
        var score = new Score();
        var enemySpawner = new EnemySpawner(_enemy, score);

        _enemy.Init(score);

        foreach (var coin in _coins)
        {
            coin.Init(score);
        }

        _scoreUI.Init(score);

        _gameLoopSystem = new GameLoopSystem(_player, _gameOverUI, enemySpawner);
    }

    private void Update()
    {
        _gameLoopSystem.Update();
    }
}

これをVContainerを使って書き換えていきます。

VContainer流のGameEntryPoint

まずは、VContainerを使ってGameEntryPointを書き換えた結果を見てみましょう。

完成形
using UnityEngine;
using VContainer;
using VContainer.Unity;

public class GameLifetimeScope : LifetimeScope
{
    [SerializeField] private Player _player;
    [SerializeField] private Enemy _enemy;
    [SerializeField] private GameOverUI _gameOverUI;
    [SerializeField] private ScoreUI _scoreUI;

    protected override void Configure(IContainerBuilder builder)
    {
        // DIするクラス(コンストラクタの引数になってるクラス)を登録していく

        // PureC#の登録
        builder.Register<Score>(Lifetime.Singleton);
        builder.Register<EnemySpawner>(Lifetime.Singleton);
        builder.RegisterEntryPoint<GameLoopSystem>(Lifetime.Singleton);

        // MonoBehaviourの登録
        builder.RegisterComponent(_enemy);
        builder.RegisterComponent(_player);
        builder.RegisterComponent(_gameOverUI);
        builder.RegisterComponent(_scoreUI);
    }
}

どうでしょうか。かなり記法が違うように見えますね…

ですが、基本は前回のGameEntryPointと同じような挙動をします。

ここからは、VContainerの最も簡単な利用方法について説明していきます。

VContainerの最も簡単な使い方

VContainerの最も簡単な使い方は、VContainer.Unity.LifetimeScopeを継承したクラスにてConfigureメソッドをオーバーライドすることです。

using UnityEngine;
using VContainer; // VContainerの名前空間
using VContainer.Unity; // VContainer.Unityの名前空間

public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        ...
    }
}

このように記述することで、Configureメソッド内でDIの登録を行うことができます。

このConfigureメソッドはAwakeメソッドのようなもので、ゲームが始まった瞬間に呼び出されるものです。VContainerを使う際の基本文法の1つだと思ってください。

DIの登録(Register)

DIの登録とは、DIしたいクラスやDIの際のコンストラクタの引数になるクラスを登録することです。

これはIContainerBuilderのメソッドを使って、Configureメソッド内で行います。

色々な種類のRegisterメソッドがありますが、今はどれもとにかくクラスの登録を行うものと考えてください。

Registerのイメージ(このコードはあくまでイメージです、実際はエラー出ます)
builder.Register<Score>(); // ScoreクラスをDIの対象にする(Scoreがコンストラクタ持ってたらDIされるし、Scoreをコンストラクタに持つものがあればそれもDIされる)
builder.Register<ScoreKudasai>(); // ScoreKudasaiクラスをDIの対象にする

// -> 実行後、ScoreKudasaiに勝手にScoreがDIされる!

便利なのは、これを書きさえすればあとは基本的に自動でDIが行われるということです。

VContainerは、登録されたクラス内のコンストラクタをめぐって、依存しているクラスがあればそれを勝手に登録されているクラスから探して解決してくれます。

同じことを言うと、Configureメソッド内で「登録されたクラスのコンストラクタ」に使われている「登録されたクラス」があれば、それを自動でDIしてくれるということです。

この方法では、クラスを用意しておくことやコンストラクタを呼び出しながら依存関係を解決していく作業をまとめてクラスの「登録」という形で行うことができます。

いちいち自分からコンストラクタを呼び出す必要はもはやありません。

また、クラスの登録の順番は全く関係ありません。 Configure内で書く限りは結果は同じです。

Inject

...分かりやすさのため、先ほどは少しだけ嘘をつきました。というのも、DIは自動で「登録されたクラスのコンストラクタ」に使われている「登録されたクラス」を探してDIしてくれるのですが、その際に「登録されたクラス」を使っているクラスの疑似コンストラクタ(あるいはフィールド)に[Inject]属性をつけておく必要がある場合があります。

これは、UnityDIの性質によるものです。前回やった通り、PureC#ではコンストラクタを利用できる一方、MonoBehaviour参照クラスにDIする際、Init()を使うほかありませんでした。このInit()は作成者が勝手に作ったメソッドでしかないので、このままでは「これがコンストラクタだ」と認識しようがありません。

こういった場合、[Inject]属性をつけておくことで、VContainerが「これが疑似コンストラクタなんだな」と認識してくれるようになります。

public class GameOverUI : MonoBehaviour
{
    private Score _score; // このフィールドにScoreをDIしてほしい

    [Inject] // この属性をつけることで、VContainerが「これが疑似コンストラクタなんだな」と認識してくれる
    public void Construct(Score score) // この際はConstructと書くのが公式の書き方っぽい
    {
        _score = score;
    }
}

実際にVContainerを使ってみる

それでは、実際にVContainerを使ってみましょう。

VContainerの導入

こちらを参考にしてみてください。
Package Managerからインストールするのが一番簡単です。

https://vcontainer.hadashikick.jp/ja/getting-started/installation

GameEntryPointの書き換え

前回のGameEntryPointをVContainerを使って書き換えていきます。なお、Coinは後述しますので今は無視してください。

GameLifetimeScope
using UnityEngine;
using VContainer;
using VContainer.Unity;

public class GameLifetimeScope : LifetimeScope
{
    [SerializeField] private Player _player;
    [SerializeField] private Enemy _enemy;
    [SerializeField] private GameOverUI _gameOverUI;
    [SerializeField] private ScoreUI _scoreUI;

    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<Score>(Lifetime.Singleton);
        builder.Register<EnemySpawner>(Lifetime.Singleton);
        builder.RegisterEntryPoint<GameLoopSystem>(Lifetime.Singleton);

        builder.RegisterComponent(_enemy);
        builder.RegisterComponent(_player);
        builder.RegisterComponent(_gameOverUI);
        builder.RegisterComponent(_scoreUI);
    }
}

ここでの大まかな意味としては、先述の通りクラスを登録しているだけです。

もう少し正確に言うと、PureC#のクラスもしくはMonoBehaviourを継承したクラスのインスタンスを登録しています。

登録したクラス・インスタンス同士で依存関係があれば、それを自動で解決してくれます。

では、ここからはVContainerの細かい記法について見ていきます。

Register

Registerメソッドは、DIしたいクラスを登録するメソッドです。主に使うのは以下の3つです。

  • Register<T>(Lifetime) : PureC#クラスを登録する。
  • RegisterEntryPoint<T>(Lifetime) : PureC#かつUnityのStart()とかUpdate()みたいなのを使いたい欲張りなクラスを登録する。
  • RegisterComponent() : MonoBehaviourを継承したクラスのインスタンスを登録する。
builder.Register<Score>(Lifetime.Singleton);
builder.RegisterEntryPoint<GameLoopSystem>();
builder.RegisterComponent(_enemy);

Lifetime

Lifetimeは、クラスの登録方法についての指定で、以下の3つがあります。

  • Lifetime.Singleton - 一度生成したインスタンスをずっと使いまわす
  • Lifetime.Transient - コンストラクタにDIするたびに新しいインスタンスを生成する
  • Lifetime.Scoped - ちょっと複雑なので今回は触れませんが、LifetimeScopeを1クラスしか使わないような場合はLifetime.Singletonと同じだと考えてください
Singletonのイメージ
builder.Register<Foo>(Lifetime.Singleton);
builder.Register<Bar>(Lifetime.Singleton);
builder.Register<Baz>(Lifetime.Singleton);

// ≒

var foo = new Foo();
var bar = new Bar(foo);
var baz1 = new Baz(foo); // fooを使いまわす
Transientのイメージ
builder.Register<Foo>(Lifetime.Transient);
builder.Register<Bar>(Lifetime.Singleton);
builder.Register<Baz>(Lifetime.Singleton);

// ≒

var foo1 = new Foo();
var bar = new Bar(foo1);
var foo2 = new Foo(); // 毎回新しいインスタンスが生成される
var baz1 = new Baz(foo2);
public class Foo { }
public class Bar
{
    private Foo _foo;

    public Bar(Foo foo)
    {
        _foo = foo;
    }
}

public class Baz
{
    private Foo _foo;

    public Baz(Foo foo)
    {
        _foo = foo;
    }
}

基本的にはLifetime.Singleton(もしくはLifetime.Scoped)を使うことが多いですし、できるだけそうするべきだと思います(楽なので)。

RegisterEntryPoint

RegisterEntryPointを使うとPureC#なのにMonoBehaviourのライフサイクルメソッドを利用できます。

前回のGameLoopSystemのUpdate()みたいなことがこれでできるようになります。

EntryPointとして登録するクラスはインターフェースを介し、対応するUnityのライフサイクルメソッドのタイミングで実行されます。

よく使うものは以下の通りです。

Unityのライフサイクルメソッド VContainerのメソッド 必要なインターフェース
Awake() Initialize() IInitializable
Start() Start() IStartable
Update() Tick() ITickable
FixedUpdate() FixedTick() IFixedTickable
builder.RegisterEntryPoint<PureEntryPoint>(Lifetime.Singleton);
public class PureEntryPoint : IInitializable, ITickable // これらのインターフェースを実装する
{
    public void Initialize()
    {
        // Awake()のタイミングで呼ばれる
    }

    public void Tick()
    {
        // Update()のタイミングで呼ばれる
    }
}

これを利用すれば、前回のGameLoopSystemを以下のように書き換えることができます。

public class GameLoopSystem : ITickable
{
    private Player _player;
    private GameOverUI _gameOverUI;
    private EnemySpawner _enemySpawner;

    public GameLoopSystem(Player player, GameOverUI gameOverUI, EnemySpawner enemySpawner)
    {
        _player = player;
        _gameOverUI = gameOverUI;
        _enemySpawner = enemySpawner;
    }

    private bool _isGameOver;
    private float _spawnDuration = 3f;
    private float _spawnTimer;

    public void Tick()
    {
        if (_player.Hp <= 0)
        {
            if (!_isGameOver)
            {
                _gameOverUI.Show();
                _isGameOver = true;
            }
        }

        _spawnTimer += Time.deltaTime;
        if (_spawnTimer >= _spawnDuration)
        {
            _enemySpawner.Spawn(new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f)));
            _spawnTimer = 0;
        }
    }
}

これをRegisterEntryPointで登録することで、Tick()メソッドがUpdate()のタイミングで呼ばれるようになります。

GameLifetimeScope
builder.RegisterEntryPoint<GameLoopSystem>(Lifetime.Singleton);

RegisterComponent

RegisterComponentは、MonoBehaviourを継承したクラスなどのインスタンスを登録するメソッドです。基本LifetimeはLifetime.Singletonだと思ってください。

builder.RegisterComponent(_enemy);

先述の通り、MonoBehaviourを継承したクラスに[Inject]属性をつけておくことで、VContainerがDIしてくれるようになります。

public class GameOverUI : MonoBehaviour
{
    private Score _score;

    [Inject]
    public void Construct(Score score)
    {
        _score = score;
    }
}

もう一度完成形を見てみる

最終的に、GameEntryPointやその他クラスの変更点は以下の通りです。

GameEntryPoint
using UnityEngine;
using VContainer;
using VContainer.Unity;

public class GameLifetimeScope : LifetimeScope
{
    [SerializeField] private Player _player;
    [SerializeField] private Enemy _enemy;
    [SerializeField] private GameOverUI _gameOverUI;
    [SerializeField] private ScoreUI _scoreUI;

    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<Score>(Lifetime.Singleton);
        builder.Register<EnemySpawner>(Lifetime.Singleton);
        builder.RegisterEntryPoint<GameLoopSystem>(Lifetime.Singleton);

        builder.RegisterComponent(_enemy);
        builder.RegisterComponent(_player);
        builder.RegisterComponent(_gameOverUI);
        builder.RegisterComponent(_scoreUI);
    }
}
GameLoopSystem
-public class GameLoopSystem
+public class GameLoopSystem : ITickable
{
    private Player _player;
    private GameOverUI _gameOverUI;
    private EnemySpawner _enemySpawner;

    public GameLoopSystem(Player player, GameOverUI gameOverUI, EnemySpawner enemySpawner)
    {
        _player = player;
        _gameOverUI = gameOverUI;
        _enemySpawner = enemySpawner;
    }

    private bool _isGameOver;
    private float _spawnDuration = 3f;
    private float _spawnTimer;

-   public void Update()
+   public void Tick()
    {
        if (_player.Hp <= 0)
        {
            if (!_isGameOver)
            {
                _gameOverUI.Show();
                _isGameOver = true;
            }
        }

        _spawnTimer += Time.deltaTime;
        if (_spawnTimer >= _spawnDuration)
        {
            _enemySpawner.Spawn(new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f)));
            _spawnTimer = 0;
        }
    }
}
GameOverUI
public class GameOverUI : MonoBehaviour
{
    private Score _score;

+   [Inject]
-   public void Init(Score score)
+   public void Construct(Score score)
    {
        _score = score;
    }

    public void Show()
    {
        Debug.Log("Game Over! Score: " + _score.CurrentScore);
    }
}
Enemy
public class Enemy : MonoBehaviour
{
    private Score _score;

+   [Inject]
-   public void Init(Score score)
+   public void Construct(Score score)
    {
        _score = score;
    }

    private void OnCollisionEnter(Collision collision)
    {
        // Playerと衝突したら
        if (collision.gameObject.TryGetComponent<Player>(out var player))
        {
            _score.Subtract(5);
            player.AddDamage(1);
        }
    }
}

Coinは?

前回の最後ではCoinを複数用意し、foreachでそれぞれにInit()を呼び出していました。こういう場合、どうすればいいのでしょうか。

今回は一番手っ取り早い方法で解決してみましょう。

実はVContainerのLifetimeScopeクラスには「Auto Inject Game Objects」というフィールドがあります。

ここにMonoBehaviourを継承したクラスを登録することで、そのクラス(やその子オブジェクト全部)の[Inject]属性がついているフィールドに自動でDIしてくれるようになります。

この機能は[Inject]だけしたい(つまり、クラス依存を解決したいだけで、他のクラスから依存されることがない)MonoBehaviourを大量に登録する際に便利です。

これで前回と同様の挙動をVContainerで実現することができました!

VContainerのメリット

VContainerを使うことで、UnityDIを手書きで書く際のデメリットを解消することができます。

  • newなどの実行順が大事になってくるのが面倒
    • VContainerは登録されたクラスのコンストラクタをめぐって依存しているクラスがあればそれを勝手に登録されたクラスから探して解決してくれる
    • つまり、クラスの登録の順番は考える必要がなくなった!
  • エントリポイントの設計がちょっと面倒
    • RegisterEntryPointを使うことで、PureC#なのにMonoBehaviourのライフサイクルメソッドを利用できる!
  • 複数個のオブジェクトの依存の解決・動的な依存の解決が面倒
    • 「Auto Inject Game Objects」を使えば解決!
  • いちいちInit()メソッドを呼び出すのが面倒・保守性が微妙
    • [Inject]属性を使うことで、VContainerが勝手にDIしてくれるようになるので呼ぶ必要なし!

そして何よりも、記述量が減ります。これは保守性や可読性にもつながります。

まとめ

UnityDIを手書きで書くのは面倒だ…と感じた方は、ぜひVContainerを使ってみてください。

最初は慣れないかもしれませんが、慣れてしまえばかなり楽にDIを行うことができるようになります。

次回:

https://zenn.dev/qemel/articles/c4294566e02b8c

Discussion