はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】すごい早いという噂のDIコンテナ「VContainer」を始めて触ってみる

はじめに

今回は巷で噂のVContainerについての記事を書きたいと思います。

github.com

UnityのDIコンテナといえばZenjectであったり、Extenjectが有名でしたがそれらと比較して性能が全体的に良いようです。

GitHubを見ればこれでもかというほどZenjectとのパフォーマンス比較の説明がなされているので、興味がある方はそちらをチェックしてみてください。

今回は導入〜基本的な使い方までを紹介していきます。

導入

VContainermanifest.json経由でのインストールができるようなので、そちらを利用したいと思います。

作成したプロジェクトをエクスプローラーで開き、プロジェクト名 -> Packages -> manifest.jsonをテキストエディタで開き以下の行を追加します。

f:id:hanaaaaaachiru:20210603152304p:plain
manifest.json
"jp.hadashikick.vcontainer" : "https://github.com/hadashiA/VContainer.git?path=VContainer/Assets/VContainer#1.8.2" ,
f:id:hanaaaaaachiru:20210603152343p:plain
追加した様子

あとはいつも通りUnityでプロジェクトを起動すれば、自動でインストールしてくれます。

その前に

VContainerを調べるにあたって、公式ドキュメントがあるのでそちらを参照するのが一番確実です。

vcontainer.hadashikick.jp

ただ急にこれを見せられても「ムリ...」ってなってしまうことがほとんどだと思うので、あくまで取っ掛かりを掴むための記事だと思ってください。

また私自身DIコンテナを使いこなしているわけではなく、かなり暗中模索で突き進んでいるので間違ったことを言っている可能性も大いにあります。

そこらへんはあしからず。

何を学ぶかを知る

最初の触る際,基本的にコンストラクタインジェクション以外は学ぶ必要はないと思います。

vcontainer.hadashikick.jp

VContainerの作者の方はこのように記述しています。

Use Constructor Injection whenever possible. The constructor & readonly field idiom is:

  • The instantiated object has a compiler-level guarantee that the dependencies have been resolved.
  • No magic in the class code. Instantiate easily without DI container. (e.g. Unit testing)
  • If you look at the constructor, the dependency is clear.
  • If too many constructor arguments, it can be considered overly responsible.

ざっくり要約するとこんな感じでしょうか。

可能な限りコンストラクタインジェクションを使ってください。

  • インスタンス化されたオブジェクトは、依存関係が解決されていることをコンパイラレベルで保証します。
  • クラスコードにマジックはありません。DIコンテナがなくても簡単にインスタンスを作成できます。(例:ユニットテスト)
  • コンストラクタを見れば、その依存関係は明らかです。
  • コンストラクタの引数が多すぎると、責任が強すぎると判断できる


特に2番目のユニットテストについては最後に実際に行ってみたいと思います。(DIコンテナ導入のプラスイメージになれば!!)

他にメソッドインジェクションというものもありますが、これは基本的にMonoBaheviorを継承するクラスにて行われるもので[Inject]をするならメソッドの引数で状態を渡せと作者は述べています。

Consider whether injection to MonoBehaviour is necessary. In a code base where domain logic and presentation are well decoupled, MonoBehaviour should act as a View component.

In my opinion, View components should only be responsible for rendering and should be flexible.

Of course, In order for the View component to work, it needs to pass state at runtime. But the "state" of an object and its dependency of functionality of other objects are different.

It's enough to pass the state as arguments instead of [Inject].

その他にもいくつかありますが、それらはコンストラクタインジェクションを使いこなせるようになった後で全然良いと思います。

コンストラクタインジェクションを行う

コンストラクタインジェクションを行うには以下のステップを踏みます。

  1. LifetimeScopeを作成
  2. コンストラクタインジェクションしたいコンストラクタに[Inject]をつける
  3. DIコンテナにインスタンスを登録する

やることは意外と3つだけです。簡単に見えてきませんか??

LifetimeScopeを作成

using VContainer;
using VContainer.Unity;

public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        // ここでDIコンテナにどのインスタンスをどの型で保存しておくかを書く
    }
}

LifetimeScopeというクラスを継承したクラスを作成します。(理由は後ほどわかってきます)

親クラスのLifetimeScopeMonoBehaviourを継承していて、[DefaultExecutionOrder(-5000)]AwakeにてBuildを行っています。

日本語がやばいですが、Awake(実行順番が早くなるよう設定)にて今回実装した派生クラスのConfigure等のもろもろの操作が行われるというわけですね。

コンストラクタインジェクションしたいコンストラクタに[Inject]をつける

よくあるMVPパターンに対してコンストラクタインジェクションを適応してみたいと思います。

一応[Inject]を付けなくても動作することがあるようですが、動作しない場合もあるので必ずつけましょう。

// Viewクラス
public class SampleView : MonoBehaviour
{
    [SerializeField] private Text text;
    
    public void DrawText(string value)
    {
        text.text = value;
    }
}
// Presenterクラス
public class SamplePresenter : IStartable
{
    private readonly SampleView _view;
    private readonly ISampleModel _model;
    
    [Inject]
    public SamplePresenter(SampleView view,ISampleModel model)
    {
        _view = view;
        _model = model;
    }
    
    public void Start()
    {
        // MonoBehaviorのStartメソッドが呼ばれるタイミングで実行でされる(IStartableのおかげ)
        var text = _model.GetRandomText();
        _view.DrawText(text);
    }
}
// Modelクラス
public interface ISampleModel
{
    string GetRandomText();
}

public class SampleModel : ISampleModel
{
    public string GetRandomText()
        => Guid.NewGuid().ToString();
}

public class SampleModelMock : ISampleModel
{
    public string GetRandomText()
        => "Test";
}
f:id:hanaaaaaachiru:20210603164035p:plain
クラス図

今回はPresenterModelの間にインターフェイスを挟んでみました。必ずしもインターフェイスを挟むべきだとは思いませんが、将来的にランダムテキストの生成をAPIなどを使って取得してくるなどの変更の可能性があると判断したからです。

またSampleModelMockという具象クラスも作りましたが、今回はそこまで気にしないで良いです。最後の方でフラグ回収します。


コードの説明をするとSamplePresenterのコンストラクタにてViewModelのインスタンスを注入します。(この後その処理をLifetimeScopeに書きます)

加えてコンストラクタインジェクションをするクラス(厳密にはRegisterEntryPointにて登録するクラス)に対して特定のインターフェイスを実装させることでライフサイクルに合わせたメソッドの実行をさせることができます。
Plain C# Entry point | VContainer

使いそうなものをピックアップするとこの2つでしょうか。

MonoBehavior VContainer
MonoBehaviour.Start() IStartable.Start()
MonoBehaviour.Update() ITickable.Tick()

DIコンテナにインスタンスを登録

先程記述したViewModelのインスタンスをDIコンテナに登録して、自動でPresenterのコンストラクタにて注入してもらいます。

GameLifetimeScope の中身を書き換えます。

public class GameLifetimeScope : LifetimeScope
{
    [SerializeField] private SampleView sampleView;
    
    protected override void Configure(IContainerBuilder builder)
    {
        // インスタンスを注入するクラスを指定する
        builder.RegisterEntryPoint<SamplePresenter>(Lifetime.Singleton);
        
        // SampleModelのインスタンスをISampleModelの型でDIコンテナに登録する
        builder.Register<ISampleModel, SampleModel>(Lifetime.Singleton);
        
        // MonoBehaviorを継承しているクラスはこのようにDIコンテナに登録する
        // builder.RegisterComponentInHierarchy<SampleView>(); と記述するとヒエラルキーから探してきてくれる
        builder.RegisterComponent(sampleView);
    }
}

EntryPointがインスタンスの注入先で、RegisterRegisterComponentがDIコンテナにインスタンスを登録する側です。

一番困惑しそうなのがLifetimeで、以下の3種類あります。

Lifetime 意味
Lifetime.Singleton 常に同じインスタンスを返す
LifeTime.Transient 毎回新しいインスタンスを生成して返す
Lifetime.Scoped LifetimeScopeの親子関連で挙動が変化(今回は扱いません)

LifetimeScopeの親子関係は少し難しいので今回は扱いません。ここではインスタンスを使い回すか使い回さないかですね。

後一番勘違いされやすいですが、SingletonScopedもシーン遷移等によりLifetimeScopeが破棄されるとIDisposable.Disposeが呼ばれます。(つまりはインスタンスが破棄される)

Singletonはシーンを跨いでも壊れないというイメージがある方もいる(そもそもSingletonMonoBehaviorとかはOnDestroyでインスタンスを破棄すべきだと私は思います)ので注意してください。

LifetimeScopeをゲームオブジェクトにアタッチする

冒頭でも述べた通りLifetimeScopeMonoBehaviourを継承しているので、ゲームオブジェクトにアタッチする必要があります。

f:id:hanaaaaaachiru:20210603174339p:plain
アタッチする

全てデフォルトでOKですが、SapmleViewの参照をしておきましょう。

f:id:hanaaaaaachiru:20210603174555p:plain
実行した様子

単体テストを行ってみよう

MonoBehaviorを継承していないクラスをエントリポイントにできたり、ServiceLocatorと違って依存関係が見やすい(コンストラクタのシグニチャで判断できる)のは魅力的です。

またテストにおいても非常に強力です。

今回はその魅力を伝えるためにも以下のクラス達を用意してみました。(やや長いですが、構造はかなりシンプルです)

f:id:hanaaaaaachiru:20210603182854p:plain
クラス図
public interface ICharacterNameConsultant
{
    string Get(int id);
}

public class CharacterNameConsultant : ICharacterNameConsultant
{
    public string Get(int id)
    {
        // 例えばSQLiteなどを使ってデータベースからidに対応する名前を取り出してくる
        var name = "";
        return name;
    }
}

public class CharacterNameConsultantMock : ICharacterNameConsultant
{
    public string Get(int id)
    {
        // 必ず「テスト」という文字を返す
        return "テスト";
    }
}

public interface ICharacterRoleConsultant
{
    string Get(int id);
}

public class CharacterRoleConsultant : ICharacterRoleConsultant
{
    public string Get(int id)
    {
        // 例えばSQLiteなどを使ってデータベースからidに対応する役職を取り出してくる
        var role = "";
        return role;
    }
}

public class CharacterRoleConsultantMock : ICharacterRoleConsultant
{
    public string Get(int id)
    {
        // 必ず「テスト」という文字を返す
        return "テスト";
    }
}

public class CharacterLibrary
{
    private readonly ICharacterNameConsultant _nameConsultant;
    private readonly ICharacterRoleConsultant _roleConsultant;
    
    [Inject]
    public CharacterLibrary(ICharacterNameConsultant nameConsultant, ICharacterRoleConsultant roleConsultant)
    {
        _nameConsultant = nameConsultant;
        _roleConsultant = roleConsultant;
    }
    
    public string Get(int id)
    {
        // 名前を返す時は必ず「役職名 + 名前」の形とする
        var name = _nameConsultant.Get(id);
        var role = _roleConsultant.Get(id);

        return name + role;
    }
}

これらをテストしようと思った時、もじ以下のように記述していたら以下の3つのパターンのテストしかできません。

  • CharacterNameConsultant
  • CharacterRoleConsultant
  • CharacterLibrary + CharacterNameConsultant + CharacterRoleConsultant
public class CharacterLibrary
{
    private readonly ICharacterNameConsultant _nameConsultant = new CharacterNameConsultant();
    private readonly ICharacterRoleConsultant _roleConsultant = new CharacterRoleConsultant();
    
    public string Get(int id)
    {
        // 名前を返す時は必ず「役職名 + 名前」の形とする
        var name = _nameConsultant.Get(id);
        var role = _roleConsultant.Get(id);

        return name + role;
    }
}

しかし今回のように記述すれば以下の通りのテストができます。

  • CharacterNameConsultant
  • CharacterRoleConsultant
  • CharacterLibrary
  • CharacterLibrary + CharacterNameConsultant
  • CharacterLibrary + CharacterRoleConsultant
  • CharacterLibrary + CharacterNameConsultant + CharacterRoleConsultant

嘘をつくんじゃないよと思われるかもしれないので、Unity用のテストフレームワークであるTest Runnerを用いて実際にテストしてみます。

public class CharacterTest
{
    [Test]
    public void CharacterNameConsultantTest()
    {
        var name = new CharacterNameConsultant().Get(1);
        Assert.AreEqual(name, "");
    }
    
    [Test]
    public void CharacterRoleConsultantTest()
    {
        var name = new CharacterRoleConsultant().Get(1);
        Assert.AreEqual(name, "");
    }
    
    [Test]
    public void CharacterLibraryTest()
    {
        var library = new CharacterLibrary(new CharacterNameConsultantMock(), new CharacterRoleConsultantMock());
        var result = library.Get(1);
        
        Assert.AreEqual(result, "テストテスト");
    }
    
    [Test]
    public void CharacterLibraryAndCharacterNameConsultantTest()
    {
        var library = new CharacterLibrary(new CharacterNameConsultant(), new CharacterRoleConsultantMock());
        var result = library.Get(1);
        
        Assert.AreEqual(result, "テスト");
    }
    
    [Test]
    public void CharacterLibraryAndCharacterRoleConsultantTest()
    {
        var library = new CharacterLibrary(new CharacterNameConsultantMock(), new CharacterRoleConsultant());
        var result = library.Get(1);
        
        Assert.AreEqual(result, "テスト");
    }
    
    [Test]
    public void CharacterLibraryAndCharacterNameConsultantAndCharacterRoleConsultantTest()
    {
        var library = new CharacterLibrary(new CharacterNameConsultant(), new CharacterRoleConsultant());
        var result = library.Get(1);
        
        Assert.AreEqual(result, "");
    }
}

どこまできっちりテストをするかはプロジェクト次第ですが、これだけテストを行えば信頼性は担保できそうです。

さいごに

やり方は思ったよりも簡単だったではないでしょうか?

ただDIコンテナの一番難しいのはどこでこれを使うかであって、正直私も全く正解が分かっていません。

もし有用な記事やスライド等がありましたら、コメント等で教えてくださると嬉しいです。

ではまた。