Open5

Zenject(Extenject)導入メモ

ailail

準備

Zenjectの後継がExtenjectなのでそちらを利用する。(Zenjectは自体はgithubにある)
Unityのアセットストアに販売してるので購入(無料)→Unty Editorを開き、Window > Package ManagerからMy Assetsなどを見てもらって、download->import

Keyword

  • キーワード:Context、Installer、Bindings
    • Context:依存関係を管理するコンテナ。インスタンスのライフサイクルを管理するスコープも担う
      • SceneContext:Scene単位で、Sceneに存在するObjectへの定義を行う用途で使用するContext
        • 形式はコンポーネント(=csファイル)のため、Hierarchy上で右クリ>Zenject>SceneContextでGameObjectを生成し、Hierarchy上に保持するか、それをPrefabに変換し、Projectウィンドウ上で管理→何かしらのタイミングでInstantiateするかで利用する
        • ProjectContext:Project全体単位で定義を行う用途。Resourceの名称を持つフォルダ内でしか生成/編集ができない制約がある。「SceneContextがヒエラルキーに置いてあるシーンであれば、 どのシーンから開始しても勝手に作られるシングルトンなPrefab」のことらしい
        • VirtualContext:特定の条件下で発動することが想定されているContext
    • Installer:Contextにどのオブジェクトを注入するかのバインディングを定義するクラス
      • MonoInstaller:MonoBehaviourを継承したInstaller。シーンにアタッチすることでContextを作成
      • ScriptableObjectInstaller:ScriptableObjectを継承したInstaller。プロジェクト設定からContextを作成

導入の流れ

  1. Installerの作成
    MonoInstallerを継承したクラスを作成: SceneContextに紐づけるためにMonoInstallerを継承したクラスを作成する。(MonoInstallerはMonoBehaviourを継承しているため、コンポーネントとして利用可能)
    InstallBindingsメソッドをoverride実装: 前述のクラスの内部にInstallBindingsメソッドを設け、どのクラスをどのクラスで注入するかをこの内部で定義する
using UnityEngine;
using Zenject;

public class MyInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<IFoo>().To<Foo>().AsSingle();
    }
}

Bindラッパー:注入したいインターフェースや抽象クラスIFooを指定
Toラッパー: IFoo型のデータに注入するインターフェースや抽象クラスIFooの具体的な実装クラスFooを指定
AsSingleメソッド: インスタンスをシングルトンとして管理する・・・というオプション(後述)。

  1. Contextの作成
    SceneContextの作成:シーンのHierarchyにSceneContextのゲームオブジェクトを追加します。
    Installerの指定:SceneContextのInspectorで、作成したInstallerの参照を渡します。
    →この2つはゲーム開始時のHierarchyに必ず存在してなくてもよく、SceneContextとInstallerをPrefab化し、ゲーム開始時(Awakeのタイミングなど)に一括でInstantiateするだけでも動作可能。(正確には、SceneContextをInstantiate後、そのSceneContextと紐付けたいinstallerへの参照が渡せれば可能)
    →installerなどを動的に後から呼ぶメリットとしては、「ゲームプレイ中に状況に応じて異なる依存関係を設定できる」という点か。難易度によって敵の強さを変えたり、特定のイベントが発生したときに特別な機能を追加したりなどなど。

  2. 依存性の注入
    次の3種がメジャーな注入方法

  • プロパティ/フィールド注入
  • コンストラクタ注入
  • メソッド注入

プロパティ注入、フィールド注入(メンバー注入?): 任意のクラスで「注入したいインターフェースやクラス」型を持つフィールドやプロパティに[Inject]属性を付与する。

[Inject]
private IFoo _foo;
[Inject]
public IFoo Foo { get; set; }

コンストラクタ注入:コンストラクタの引数に注入したいオブジェクトを指定する。

public class MyClass
{
    private readonly IFoo _foo;

    public MyClass(IFoo foo)
    {
        _foo = foo;
    }
}

メソッド注入:以下のサンプル参照。メソッド注入は、メソッド自体を注入するのではなく、メソッドの実行を委譲するための仕組み。

public interface ICalculator
{
    int Calculate(int a, int b);
}

public class CalculatorA : ICalculator
{
    public int Calculate(int a, int b)
    {
        return a + b;
    }
}

public class CalculatorB : ICalculator
{
    public int Calculate(int a, int b)
    {
        return a * b;
    }
}

public class MyService
{
    public void DoCalculation(Action<int, int> calculation)
    {
        int result = calculation(10, 5);
        Console.WriteLine("Result: " + result);
    }
}

public class MyInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<MyService>().AsSingle();
        // A/BテストでどちらのCalculatorを使うか動的に決定するロジック
        if (Random.Range(0, 2) == 0)
        {
            Container.Bind<ICalculator>().To<CalculatorA>();
        }
        else
        {
            Container.Bind<ICalculator>().To<CalculatorB>();
        }
    }
}

public class SomeOtherClass
{
    [Inject]
    private MyService myService;

    public void UseMyService()
    {
        myService.DoCalculation((a, b) =>
        {
            var calculator = Container.Resolve<ICalculator>();
            return calculator.Calculate(a, b);
        });
    }
}
ailail

オプション

Installer内部で注入内容を決める際の記述には、さまざまなオプションが利活用できる

AsCached

Container.Bind<IFoo>().To<Foo>().AsCached();
  • インスタンスをキャッシュし、同じ型のインスタンスが要求された場合に、キャッシュされたインスタンスを返す
  • シングルトンパターンと似ていますが、より柔軟にスコープを管理可能
  • AsSingle()との違いは?

AsTransient

  • 毎回新しいインスタンスを生成
  • 短寿命のオブジェクト、頻繁に生成・破棄されるオブジェクト向けに使用する

FromInstance

var foo = new Foo();
Container.Bind<IFoo>().FromInstance(foo);
  • 外部からインスタンスを作成し、それをコンテナに登録可能
  • 細かくカスタマイズ可能な初期化が必要な場合などに便利

FromComponentInHierarchy

Container.Bind<IFoo>().FromComponentInHierarchy();
Container.Bind<IFoo>().FromComponentInHierarchy().AsChached(); //組み合わせて利用すると再検索が行われなくなるため、負荷軽減になる
  • ヒエラルキー内で指定された型(IFooがインターフェースであれば、その実装である型)のコンポーネントを探し、最初に見つかったインスタンスを注入します
  • Scene内で既に存在するコンポーネントを再利用する
  • 以下のような仲間も存在する
    • FromComponentInChildren: InstallerがアタッチされているGameObjectの子オブジェクトから検索
    • FromComponentInParents: InstallerがアタッチされているGameObjectの親オブジェクトから検索
    • FromComponentOnRoot: シーンのルートオブジェクト?から検索

FromNewComponentOnNewGameObject

Container.Bind<IFoo>().To<Foo>.FromNewComponentOnNewGameObject();
// ただし、上記と似た挙動を以下のように記述することも可能(Gooはコンポーネント)
Container.Bind<IFoo>().FromMethod( ctx => new Goo() )
  • 新しいGameObjectを動的に作成し、そのGameObjectに指定された型のコンポーネントを追加して、そのインスタンスを注入します。通常はInstallerがアタッチされているGameObjectの直下に生成される。
  • 記述行の実行と同時に生成される
  • Scene内にオブジェクトが生成されます
ailail

応用例

Container.BindInterfacesTo<IFoo>().FromNewComponentOnNewGameObject().AsCached();

IFooインターフェースを実装するすべてのクラスを新しいGameObjectにアタッチし、そのインスタンスをキャッシュする という動作

ailail

Extenjectを利用したテスト

以下はサンプル

// テスト対象のクラス
public class MyClass
{
    private readonly IDataService _dataService;

    public MyClass(IDataService dataService)
    {
        _dataService = dataService;
    }

    public int GetData()
    {
        return _dataService.GetData();
    }
}

// モックオブジェクト
public interface IDataService
{
    int GetData();
}

// テストケース
public class MyClassTest
{
    [Test] //NUnit、xUnit.netなどのテストフレームワークがこれ検知、メソッドを自動的に検出し実行する
    public void GetData_ReturnsExpectedValue()
    {
        // Arrange
        var mockDataService = new Mock<IDataService>(); //MockはMoqライブラリなどで用意されたもの
        mockDataService.Setup(x => x.GetData()).Returns(42);

        var container = new DiContainer();
        container.Bind<IDataService>().FromInstance(mockDataService.Object);
        container.Bind<MyClass>().AsSingle();

        var myClass = container.Resolve<MyClass>();

        // Act
        var result = myClass.GetData();

        // Assert
        Assert.AreEqual(42, result);
    }
}