Unity
Zenject

Zenject入門その1 疎結合とDI Container

はじめに

最近、Zenjectについて導入を検討する人が増えてきました。今回はそのZenjectがそもそも何のためのライブラリなのかを解説します。

Zenjectとは

Zenject Dependency Injection IOC」は依存性の注入のためのフレームワークと言われています。

よくある勘違い

Zenjectを導入すると、次のようなことができるようになると思っている人が多いですが、それは間違いです

  • Zenjectを入れると疎結合になる!
  • Zenjectを入れるとテストが書きやすくなる!
  • 何かよくわからないけど入れるとプログラムが書きやすくなる!

繰り返しますが、上記の認識は間違いです。

Zenjectの正しい説明

Zenjectは疎結合な設計やテストを書きやすくするためのライブラリではありません。
順序が逆で、疎結合やテストのことを考えて設計したときに発生してしまう問題を解決するものがZenjectです。
つまり、あらかじめ疎結合な設計やテストを考慮したプログラムになってないとZenjectは導入しても意味がないということになります。

そのため、Zenjectのメリットを説明するためにはどうしても「疎結合な設計」に対する理解が必要になります。
そういった点を考慮すると、Zenjectは初心者向けのライブラリではありません。ある程度プログラミングに慣れて、設計について意識し始めたときに導入を検討するくらいでちょうど良いくらいです。

「疎結合」と「依存性の注入」

さきほどから何度も「疎結合」というワードが登場しています。これについて詳しく説明しましょう。
まずは「疎結合」の反対の概念にあたる「密結合」から解説します。

密結合

密結合とは、名前のとおり「密な結合状態」を表します。
設計においては、「あるクラスが特定のクラスにべったり依存している」という状態を表します。

たとえばUnity開発において、よく発生する密結合な場面は「Input」周りでしょう。

密結合なInput

たとえば、次のようなコードは密結合な設計になります。

class Mover : MonoBehaviour
{
    void Update()
    {
        if (Input.GetButton("Jump"))
        {
            Jump();
        }

        var inputVector = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        var isDash = Input.GetButton("Dash");

        // 移動
        Move(inputVector, isDash);
    }

    /// <summary>
    /// ジャンプする
    /// </summary>
    void Jump()
    {
        /*
         * 省略 
         */
    }

    /// <summary>
    /// 移動する
    /// </summary>
    /// <param name="direction">移動方向</param>
    /// <param name="isDash">ダッシュするか</param>
    void Move(Vector3 direction, bool isDash)
    {
        /*
         * 省略 
         */
    }
}

何が密結合かと言うと、「UnityEngine.Input」を直接参照して利用しているという点です。

image.png

つまり、このスクリプトはUnityEngine.Input以外の要素から入力値を取得することができなくなっています。

「密結合なInput」では何がダメなのか?

これ以上の機能拡張やテストを書かないというのであればこれで問題ありません。
逆に言うと、機能拡張やテストを書きたくなったときに問題が出てきます。

たとえば、

  • UnityEngine.Inputではなく、RewirdのInputに差し替えたい
  • テストするときに任意のタイミングでInputイベントを差し込みたい

といったときにこのままでは対応ができません。

疎結合

疎結合とは、密結合の逆で、「特定のクラスに依存しない状態」になっていることを指します。
つまり、具体的なクラスには紐付かず、それを抽象化したインタフェースを参照する設計を指します。

密結合から疎結合へ

では、さきほどのInputの例をもとに、密結合から疎結合へと作り直してみます。
やるべき作業としては、「Moverが直接UnityEngine.Inputを触らない」ようにしてしまいます。
つまり、インタフェースを介してMoverはInputの状態を取得するという設計にします。

image.png

InputProvider
using UnityEngine;

namespace Player
{
    interface IInputProvider 
    {
        bool GetDash();
        bool GetJump();
        Vector3 GetMoveDirection();
    }
}
UnityEngine.Inputを使うInputProvider
namespace InputProviders
{
    public class UnityInputProvider : IInputProvider 
    {
        public bool GetDash()
        {
            return Input.GetButton("Dash");
        }

        public bool GetJump()
        {
            return Input.GetButton("Jump");
        }

        public Vector3 GetMoveDirection()
        {
            return new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        }
    }
}

疎結合化したMover
using UnityEngine;

namespace Player
{
    class Mover : MonoBehaviour
    {
        private IInputProvider inputProvider;

        public void SetInputProvider(IInputProvider input)
        {
            inputProvider = input;
        }

        void Update()
        {
            if (inputProvider.GetJump())
            {
                Jump();
            }

            var inputVector = inputProvider.GetMoveDirection();
            var isDash = inputProvider.GetDash();

            //移動
            Move(inputVector, isDash);

        }

        /// <summary>
        /// ジャンプする
        /// </summary>
        void Jump()
        {
            /*
             * 省略 
             */
        }

        /// <summary>
        /// 移動する
        /// </summary>
        /// <param name="direction">移動方向</param>
        /// <param name="isDash">ダッシュするか</param>
        void Move(Vector3 direction, bool isDash)
        {
            /*
             * 省略 
             */
        }
    }
}

こうすることで、Moverはいまどの実装を使っているのかを意識することなく入力状態を取得することができるようになりました。
これが疎結合な設計という状態です。

疎結合にすることのメリット

疎結合化することにより、モジュールの差し替えが簡単にできるようになります。
モジュールを差し替えることで、「最初はUnityEngine.Inputで実装して、途中でRewirdに差し替える」「テスト用のモジュールに差し替えてテストを実行する」といったことができるようになります。

たとえば、さきほどのMoverを例にテストを書いてみます。

Moverのテストを書く

ちゃんと移動するように作ったMover

さきほどまでのMoverは説明のために実装を省略していたので、ちゃんと移動するように実装を追加します。

using UnityEngine;

namespace Player
{
    public class Mover : MonoBehaviour
    {
        [SerializeField]
        private float jumpPower = 5f;

        [SerializeField]
        private float defaultMoveSpeed = 10f;

        private CharacterController characterController;

        private IInputProvider inputProvider;

        private Vector3 moveDirection;

        public void SetInputProvider(IInputProvider input)
        {
            inputProvider = input;
        }

        void Start()
        {
            characterController = GetComponent<CharacterController>();
        }

        void Update()
        {
            moveDirection = Vector3.zero;

            if (inputProvider == null) return;

            if (inputProvider.GetJump())
            {
                Jump();
            }

            var inputVector = inputProvider.GetMoveDirection();
            var isDash = inputProvider.GetDash();

            //移動
            Move(inputVector, isDash);

            //重力加速度
            moveDirection = new Vector3(
                moveDirection.x,
                moveDirection.y + Physics.gravity.y * Time.deltaTime + characterController.velocity.y,
                moveDirection.z);

            //移動
            characterController.Move(moveDirection * Time.deltaTime);
        }

        /// <summary>
        /// ジャンプする
        /// </summary>
        void Jump()
        {
            if (!characterController.isGrounded) return;
            moveDirection = new Vector3(moveDirection.x, moveDirection.y + jumpPower, moveDirection.z);
        }

        /// <summary>
        /// 移動する
        /// </summary>
        /// <param name="direction">移動方向</param>
        /// <param name="isDash">ダッシュするか</param>
        void Move(Vector3 direction, bool isDash)
        {
            var speed = isDash ? 3 : 1; //ダッシュすると3倍速い
            moveDirection += (direction * speed * defaultMoveSpeed);
        }
    }
}

そして、これに対応するテスト(PlayModeTest)がこちらです。

MoverTest.cs
using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;
using InputProviders;
using Player;
using UnityEngine.SceneManagement;

public class MoverTest
{
    private GameObject targetGameObject;
    private TestInputProvider testInputProvider;

    [SetUp]
    public void Init()
    {
        testInputProvider = new TestInputProvider();
        SceneManager.LoadScene("TestScene");
    }

    [TearDown]
    public void Finish()
    {
        testInputProvider.Reset();
        targetGameObject.transform.position = Vector3.zero;
    }

    private void InitLazy()
    {
        if (targetGameObject == null)
        {
            targetGameObject = GameObject.Find("Player");
            var mover = targetGameObject.GetComponent<Mover>();
            mover.SetInputProvider(testInputProvider);

            testInputProvider.Reset();
            targetGameObject.transform.position = Vector3.zero;
        }
    }

    [UnityTest]
    public IEnumerator 移動できる()
    {
        InitLazy();

        // 前に進む
        testInputProvider.MoveDirection = Vector3.forward;

        yield return new WaitForSeconds(1);

        // 前進している
        Assert.True(targetGameObject.transform.position.z > 0);
    }


    [UnityTest]
    public IEnumerator ダッシュできる()
    {
        InitLazy();

        // 前に進む
        testInputProvider.MoveDirection = Vector3.forward;

        yield return new WaitForSeconds(1);

        // 通常移動で進んだ距離
        var normal = targetGameObject.transform.position.z;

        testInputProvider.Reset();
        targetGameObject.transform.position = Vector3.zero;
        yield return null;

        // ダッシュする
        testInputProvider.IsDash = true;
        testInputProvider.MoveDirection = Vector3.forward;

        yield return new WaitForSeconds(1);

        // ダッシュした方が遠くに移動できている
        Assert.True(targetGameObject.transform.position.z > normal);
    }

    [UnityTest]
    public IEnumerator ジャンプできる()
    {
        InitLazy();

        // ジャンプする
        testInputProvider.IsJump = true;

        yield return new WaitForSeconds(1);

        // ジャンプできている
        Assert.True(targetGameObject.transform.position.y > 0);
    }
}

重要なのはInitLazy()内のこのあたりです。

targetGameObject = GameObject.Find("Player");
var mover = targetGameObject.GetComponent<Mover>();
mover.SetInputProvider(testInputProvider);

SetInputProvider()を使って、テスト用のInputProviderMoverに登録しています。
このように、通常時はUnityInputProviderを渡し、テスト時はTestInputProviderに差し替える、みたいなことが疎結合化していると簡単に行えるようになります。

疎結合化することの問題点

疎結合化することによるデメリットはいくつかあるのですが、もっとも大きな問題は「利用するインスタンスの決定をどうやって行うのか?」というものです。

さきほどのMoverの説明ではしれっと「SetInputProvider()UnityInputProviderを与えればよい」みたいな説明をしましたが、じゃあ誰がどのタイミングでSetInputProvider()を呼び出すのか?という問題が起きることを無視していました。

この問題を回避する方法として、手法として「ServiceLocatorパターン」というものがあります。
ただしこのServiceLocatorパターンはアンチパターンになるので、オススメはしません。
(さらにいえば「依存性の注入」ですらないです。一切「注入」という動作が登場しないため。)

ServiceLocator "アンチ" パターン

ServiceLocatorパターンは、簡単に言えば「シングルトンを使って依存性を解決する」という手法です。
たとえば次のような実装はServiceLocatorパターンになります。

InputProviderを提供するServiceLocator
using InputProviders;

namespace Player
{
    class InputProviderServiceLocator : SingletonMonoBehaviour<InputProviderServiceLocator>
    {
        private IInputProvider inputProvider;

        public IInputProvider Get { get { return inputProvider; } }

        private void Awake()
        {
            inputProvider = new UnityInputProvider();
        }
    }
}
Mover(InputProviderServiceLocatorを使って依存性を解決する)
namespace Player
{
    public class Mover : MonoBehaviour
    {
        //関係ない部分は省略


        private IInputProvider inputProvider;

        void Start()
        {
            // サービスロケータから利用するインスタンスを取得する
            inputProvider = InputProviderServiceLocator.Instance.Get;
        }

        void Update()
        {
           // 以下略
}

このようなシングルトンを経由する方法は、もとの密結合な状態とほどんど変わらない状況になってしまうため、疎結合化した意味がありません。
そういったわけで、ServiceLocatorパターンはアンチパターンとして扱われています。

依存性の注入とDI Container

あらためて、「依存性の注入(Dependency Injection)」について解説します。
というのも、Dependency Injectionもデザインパターンの1つであり、DI Containerと呼ばれるオブジェクトが登場します。

「依存性の注入」は日本語訳がわかりにくいのですが、意味としては「依存オブジェクトの注入」が近いです。
つまり、「状況に応じて適切なオブジェクトをインスタンスに設定していくパターン」みたいな意味です。

(依存性の注入、は長いので以降はDIと呼称します。)

DI Container

DIを実行するにあたり、DI Containerという概念が登場します。
DI Containerは簡単に言えば「環境にあらかじめ依存関係を設定しておくと、それに応じて自動的にインスタンスを設定してまわってくれる便利な存在」です。

ServiceLocatorパターンではオブジェトが自ら必要なインスタンスを取りに行くという仕組みでしたが、DIではその逆を行います。
環境に存在するDI Containerが、状況に応じて自動的にオブジェクトに対してインスタンスを設定してくれます。(仕組みはともかくとして、そういう便利な存在です。)

そして、このDI Containerの機能を提供してくれるライブラリがZenjectになります。

ZenjectのDI Containerを使ってみる

それでは、実際にZenjectのDI Containerを設定し、実際に使ってみましょう。
さきほどのMoverの例をもとに行っています。

1. Zenjectをインストールする

Asset Storeから導入すれば終わりです。

2. Installerを記述する

Installerとは、ZenjectのDI Containerに対して依存関係を記述してあげる場所になります。

いくつか種類があるのですが、今回はMonoBehaviourとして扱うことができるMonoInstallerを利用します。

ProjectView -> Create -> Zenject -> Mono Installer から作成ができます。

image.png

スクリプトが生成されたら、そこに次のような内容を記述します。

UnityInputProviderを使うInstaller
using InputProviders;
using Player;
using Zenject;

namespace ZenjectSample
{
    public class UnityInputInstaller : MonoInstaller<UnityInputInstaller>
    {
        public override void InstallBindings()
        {
            Container
                .Bind<IInputProvider>()   // IInputProviderが要求されたら
                .To<UnityInputProvider>() // UnityInputProviderを生成して注入する
                .AsCached();              // ただし、UnityInputProviderが生成済みなら使い回す
        }
    }
}

DI Containerに対して、どのインタフェースが要求されたら、どのインスタンスを注入するか、そのときにインスタンスをどう扱うか、設定することができます。
今回は「IInputProviderが要求されたら、UnityInputProviderを注入する、そのときにUnityInputProviderがキャッシュされているならそれを使う」という設定をしています。

3. Contextを用意する

ContextInstallerの影響範囲を設定するものです。主に次の2種類があります。

  • Project Context : そのUnityProject全体に影響を及ぼす
  • Scene Context : そのContextが配置されたシーンにのみ影響を及ぼす

今回はScene Contextを使います。
Installerを配置したいシーンのHierarchyView上で、

右クリック -> Zenject -> Scene Context でSceneContextが配置されます

image.png

4. ContextにInstallerを設定する

生成したScene Contextに、さきほどのUnityInputInstallerを登録します。

image.png

場所はどこでもいいので、UnityInputInstallerをどこかのGameObjectに貼り付けます(今回はそのままScene ContextのGameObjectに配置)。
そして、Scene Context上のInstallers欄にこれを登録します。

これでこのシーン上でDI Continerが有効になりました。

5. DI ContainerからのInjectを受け入れられるようにする

Moverスクリプトを修正し、DIを受けれいられるようにします。
記法はいくつかありますが、MonoBehaviorを継承したオブジェクトの場合は「Field Injection」「Property Injection」「Method Injection」のどれかしか利用できません。

今回はどの記法で書いても挙動は同じになります。

Field InjectionでDI

フィールド変数に[Inject]属性をつけることで、そこにDIContainerがオブジェクトを注入してくれます。

using UnityEngine;
using Zenject;

namespace Player
{
    public class Mover : MonoBehaviour
    {
        [Inject]
        private IInputProvider inputProvider;

        // 以下略

Property InjectionでDI

プロパティに[Inject]属性をつけることで、そこにDIContainerがオブジェクトを注入してくれます。

using UnityEngine;
using Zenject;

namespace Player
{
    public class Mover : MonoBehaviour
    {
        [Inject]
        private IInputProvider inputProvider { get; set; }

        // 以下略

Method InjectionでDI

メソッドに[Inject]属性をつけることで、その引数に応じてDIContainerがオブジェクトを注入してくれます。

using UnityEngine;
using Zenject;

namespace Player
{
    public class Mover : MonoBehaviour
    {
        private IInputProvider inputProvider;

        [Inject]
        private void Injection(IInputProvider inputProvider)
        {
            this.inputProvider = inputProvider;
        }

        // 以下略

6. 実行する

あとはこのままゲームを実行すると、シーンがロードされたタイミングでDI Containerがオブジェクトを探してまわり、注入を自動的に実行してくれます。

今回の場合は、シーンをロードするとMoverUnityInputProviderが自動的に注入されることになります。

7. AddComponent / Instantiate するときは

ZenjectのDI Containerはシーン読み込み時にオブジェクトを検索してくれますが、それ以降は自動的に注入を実行してくれません。
そのためAddComponentInstantiateを実行しても、DIが行われずにNullReferenceExceptionが出てしまいます。

そのため、AddComponentInstantiate時にDIを行う場合は、DI Containerを経由して実行する必要がでてきます。

using UnityEngine;
using Zenject;

public class PlayerMaker : MonoBehaviour
{
    // MoverがついたGameObject
    [SerializeField] private GameObject player;

    // DI ContainerをDIしてもらう
    [Inject] private DiContainer container;


    void Start()
    {
        // DI Container経由でInstantiateする必要がある
        container.InstantiatePrefab(player);

        // もしあとからAddComponentするなら InstantiateComponent を使う
        // container.InstantiateComponent<Mover>(player);
    }
}

8. ZenjectBinding

ZenjectBindingは、DI Containerの機能を応用したもので、シーンロード時にMonoBehaviourの参照解決を行ってくれる機能です。

次のようなMonoBehaviourがあったときに、参照をZenjectBindを使って解決してみます。

using UnityEngine;

/// <summary>
/// 何かのマネージャ
/// </summary>
public class HogeManager : MonoBehaviour
{

}
using UnityEngine;
using Zenject;

public class Fuga : MonoBehaviour
{
    // HogeManagerがほしい
    [Inject] private HogeManager hogeManager;
}

続いて、Zenject Bindingというコンポーネントをどこでもいいので配置します。
ここのComponent欄に、DIしたいコンポーネントを登録します。

image.png

以上です。これで自動的にHogeManagerが要求されている場所に注入されるようになりました。

Bind Type

image.png

ついでに覚えておくとよいのが、このBind Typeです。それぞれ次のような意味です。

  • Self : 指定コンポーネントと型が完全に一致した場合のみDIする
  • AllInterfaces : 指定コンポーネントが実装するインタフェースが要求されている場合のみDIする
  • AllInterfacesAndSelf : Self + AllInterfacesになる
  • BaseType : 指定コンポーネントのインタフェースではなく、基底クラスが要求されている場合のみDIする

仕組み

ZenjectBindingは、次のようなMonoInstallerを自動生成しContextに登録してくれる機能にすぎません。

ZenjectBindingが生成するInstallerと同等の内容
public class GameInstaller : MonoInstaller
{
    public HogeManager hogeManager;

    public override void InstallBindings()
    {
        Container.Bind<HogeManager>().FromInstance(hogeManager);
    }
}

Zenjectを使ってテストを実行する

ZenjectUnitTestFixture / ZenjectIntegrationTestFixtureを使うと、テスト実行時にContainerを設定することができるようになります。

MoverTestのZenject版

using Zenject;
using System.Collections;
using InputProviders;
using NUnit.Framework;
using Player;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;

public class MoverTestZenject : ZenjectIntegrationTestFixture
{
    private TestInputProvider testInputProvider;
    private GameObject targetGameObject;

    [SetUp]
    public void Init()
    {
        SceneManager.LoadScene("TestScene");
        testInputProvider = new TestInputProvider();
    }

    void CommonInstall()
    {
        PreInstall();

        Container.Bind<IInputProvider>().FromInstance(testInputProvider);

        PostInstall();
    }


    [TearDown]
    public void Finish()
    {
        testInputProvider.Reset();
        targetGameObject.transform.position = Vector3.zero;
    }

    private void InitLazy()
    {
        if (targetGameObject == null)
        {
            targetGameObject = GameObject.Find("Player");

            testInputProvider.Reset();
            targetGameObject.transform.position = Vector3.zero;
        }
    }

    [UnityTest]
    public IEnumerator MoveTest()
    {
        CommonInstall();

        InitLazy();

        // 前に進む
        testInputProvider.MoveDirection = Vector3.forward;

        yield return new WaitForSeconds(1);

        // 前進している
        Assert.True(targetGameObject.transform.position.z > 0);
    }


    [UnityTest]
    public IEnumerator DashTest()
    {
        CommonInstall();

        InitLazy();

        // 前に進む
        testInputProvider.MoveDirection = Vector3.forward;

        yield return new WaitForSeconds(1);

        // 通常移動で進んだ距離
        var normal = targetGameObject.transform.position.z;

        testInputProvider.Reset();
        targetGameObject.transform.position = Vector3.zero;
        yield return null;

        // ダッシュする
        testInputProvider.IsDash = true;
        testInputProvider.MoveDirection = Vector3.forward;

        yield return new WaitForSeconds(1);

        // ダッシュした方が遠くに移動できている
        Assert.True(targetGameObject.transform.position.z > normal);
    }

    [UnityTest]
    public IEnumerator JumpTest()
    {
        CommonInstall();

        InitLazy();

        // ジャンプする
        testInputProvider.IsJump = true;

        yield return new WaitForSeconds(1);

        // ジャンプできている
        Assert.True(targetGameObject.transform.position.y > 0);
    }
}

まとめ

  • Zenjectを使うためには、あらかじめ疎結合な設計にしておく必要がある
  • Zenjectは依存性の注入を行うためのフレームワークである
  • DI Containerの扱いが非常に重要になる