はじめに
最近、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
」を直接参照して利用しているという点です。
つまり、このスクリプトはUnityEngine.Input
以外の要素から入力値を取得することができなくなっています。
「密結合なInput」では何がダメなのか?
これ以上の機能拡張やテストを書かないというのであればこれで問題ありません。
逆に言うと、機能拡張やテストを書きたくなったときに問題が出てきます。
たとえば、
- UnityEngine.Inputではなく、RewirdのInputに差し替えたい
- テストするときに任意のタイミングでInputイベントを差し込みたい
といったときにこのままでは対応ができません。
疎結合
疎結合とは、密結合の逆で、「特定のクラスに依存しない状態」になっていることを指します。
つまり、具体的なクラスには紐付かず、それを抽象化したインタフェースを参照する設計を指します。
密結合から疎結合へ
では、さきほどのInputの例をもとに、密結合から疎結合へと作り直してみます。
やるべき作業としては、「Mover
が直接UnityEngine.Input
を触らない」ようにしてしまいます。
つまり、インタフェースを介してMoverはInputの状態を取得するという設計にします。
using UnityEngine;
namespace Player
{
interface IInputProvider
{
bool GetDash();
bool GetJump();
Vector3 GetMoveDirection();
}
}
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"));
}
}
}
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)がこちらです。
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()
を使って、テスト用のInputProvider
をMover
に登録しています。
このように、通常時はUnityInputProvider
を渡し、テスト時はTestInputProvider
に差し替える、みたいなことが疎結合化していると簡単に行えるようになります。
疎結合化することの問題点
疎結合化することによるデメリットはいくつかあるのですが、もっとも大きな問題は「利用するインスタンスの決定をどうやって行うのか?」というものです。
さきほどのMover
の説明ではしれっと「SetInputProvider()
にUnityInputProvider
を与えればよい」みたいな説明をしましたが、**じゃあ誰がどのタイミングでSetInputProvider()を呼び出すのか?**という問題が起きることを無視していました。
この問題を回避する方法として、手法として「ServiceLocatorパターン」と「Dependency Injectionパターン」というものがあります。
ServiceLocator パターン
ServiceLocatorパターンは、簡単に言えば「ServiceLocatorというオブジェクトに対して依存関係を解決しにいく」という手法です。
たとえば次のような実装はServiceLocatorパターンになります。
public class ServiceLocator
{
private Dictionary<Type, object> container = new Dictionary<Type, object>();
public T Resolve<T>()
{
return (T)container[typeof(T)];
}
public void Register<T>(T instance)
{
container.Add(typeof(T), instance);
}
}
public class ServiceLocatorProvider : SingletonMonoBehaviour<ServiceLocatorProvider>
{
public ServiceLocator Current { get; private set; }
private void Awake()
{
Current = new ServiceLocator();
// 依存関係を登録
Current.Register<IInputProvider>(new UnityInputProvider());
}
}
namespace Player
{
public class Mover : MonoBehaviour
{
//関係ない部分は省略
private IInputProvider inputProvider;
void Start()
{
// サービスロケータから利用するインスタンスを取得する
inputProvider = ServiceLocatorProvider.Instance.Current.Resolve<IInputProvider>();
}
void Update()
{
// 以下略
}
このように、依存関係の解決をServiceLocatorというオブジェクトに集約し、static変数経由でアクセスして解決する手法をServiceLocatorパターンと呼びます。
この手法は実装がシンプルで使い方も単純というメリットがあるのですが、一方で開発規模が大きくなって依存関係が複雑化すると途端にリファクタリングが困難になるというデメリットも持っています。(ServiceLocatorへの密結合がリファクタリング時に邪魔になってしまう。)
そのため、「規模が小さいプロジェクトで依存関係を解決する」といった場面や、「アプリケーションのグローバル設定を参照するのに使う」といったような限定的な場面で使うなど、できるだけ狭い範囲でのみServiceLocatorパターンは利用することを推奨します。
そして、規模が大きくなってきた場合は「Dependency Injectionパターン」を利用すると良いでしょう。
Dependency InjectionとDI Container
あらためて、「依存性の注入(Dependency Injection)」について解説します。
Dependency Injectionパターンでは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 から作成ができます。
スクリプトが生成されたら、そこに次のような内容を記述します。
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を用意する
Context
はInstaller
の影響範囲を設定するものです。主に次の2種類があります。
- Project Context : そのUnityProject全体に影響を及ぼす
- Scene Context : そのContextが配置されたシーンにのみ影響を及ぼす
今回はScene Context
を使います。
Installerを配置したいシーンのHierarchyView上で、
右クリック -> Zenject -> Scene Context でSceneContextが配置されます
4. ContextにInstallerを設定する
生成したScene Context
に、さきほどのUnityInputInstaller
を登録します。
場所はどこでもいいので、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
がオブジェクトを探してまわり、注入を自動的に実行してくれます。
今回の場合は、シーンをロードするとMover
にUnityInputProvider
が自動的に注入されることになります。
7. GameObject.Instantiate するときは
GameObject.Instantiate
を直接実行した場合、そのオブジェクトはDI Container
が認識してくれないためDIが実行されません。
DIを実行しながらGameObjectを生成する場合は次の方法を取る必要があります。
Factoryを使う場合
指定したPrefabからGameObject
を生成するFactory
を用意し、そのFactory
経由でオブジェクトを生成することでDIを実行することができます。
class MoverFactory : PlaceholderFactory<Mover>
{
// 中身は空で良い
}
public class UnityInputInstaller : MonoInstaller<UnityInputInstaller>
{
[SerializeField] private GameObject MoverPrefab;
public override void InstallBindings()
{
Container
.Bind<IInputProvider>() // IInputProviderが要求されたら
.To<UnityInputProvider>() // UnityInputProviderを生成して注入する
.AsCached(); // ただし、UnityInputProviderが生成済みなら使い回す
Container
.BindFactory<Mover, MoverFactory>() //MoverのFactoryはMoverFactoryであると紐付ける
.FromComponentInNewPrefab(MoverPrefab); //生成はPrefabをベースに行う
}
}
public class MoverGenerator: MonoBehaviour
{
[Inject] private MoverFactory factory;
void Start()
{
// 3つ作ってみる
factory.Create();
factory.Create();
factory.Create();
}
}
DI Containerを直接使う場合
別の方法として、DI Container
を直接DIして使うこともできます。ただしこちらのパターンはService Locator
パターンとやってることは同じになります。
public class MoverGenerator: MonoBehaviour
{
[SerializeField] private GameObject MoverPrefab;
[Inject] private DiContainer container;
void Start()
{
// Containerから直接生成する
container.InstantiatePrefab(MoverPrefab);
}
}
なお、DI Container
を直接操作することで、AddComponent
相当の処理を実行することもできます。
public class MoverGenerator: MonoBehaviour
{
[Inject] private DiContainer container;
[SerializeField] private GameObject EmptySpherePrefab;
void Start()
{
// 何もついてないSphereをPrefabから作る
var go = Instantiate(EmptySpherePrefab);
// DIが不要なコンポーネントはそのままAddComponent
go.AddComponent<CharacterController>();
// DIが必要なコンポーネントはContainer経由で行う
container.InstantiateComponent<Mover>(go);
}
}
8. Container経由でInstantiateできない場合では
PhotonNetwork.Instantiate
を使っているなど、Container
経由でInstantiate
できないシチュエーションなどがあると思います。
その場合はZenAutoInjectorコンポーネントをPrefabにあらかじめ貼り付けて置くことで、生成時に後追いでそのオブジェクトに対してDIが実行されるようになります。
ただし、この場合はStart()
よりあとのタイミングでDIが実行されることになります。そのためDIが確実に完了していることを検知した上でUpdate()
を実行するなどの工夫が必要になってきます。
たとえばMethodInjectionを使ってDIのタイミングでフラグを立てる、などするとよいでしょう。
using UnityEngine;
using Zenject;
namespace Player
{
public class Mover : MonoBehaviour
{
private bool isInitialized;
private IInputProvider inputProvider;
[Inject]
private void Injection(IInputProvider inputProvider)
{
this.inputProvider = inputProvider;
// DIが実行されたらフラグを立てる
isInitialized = true;
}
void Update()
{
if(!isInitialized) return; //初期化が終わってないならUpdateを実行しない
9. 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したいコンポーネントを登録します。
以上です。これで自動的にHogeManager
が要求されている場所に注入されるようになりました。
Bind Type
ついでに覚えておくとよいのが、このBind Type
です。それぞれ次のような意味です。
- Self : 指定コンポーネントと型が完全に一致した場合のみDIする
- AllInterfaces : 指定コンポーネントが実装するインタフェースが要求されている場合のみDIする
- AllInterfacesAndSelf : Self + AllInterfacesになる
- BaseType : 指定コンポーネントのインタフェースではなく、基底クラスが要求されている場合のみDIする
仕組み
ZenjectBindingは、次のようなMonoInstaller
を自動生成しContextに登録してくれる機能にすぎません。
public class GameInstaller : MonoInstaller
{
public HogeManager hogeManager;
public override void InstallBindings()
{
Container.Bind<HogeManager>().FromInstance(hogeManager);
}
}
Zenjectを使ってテストを実行する
ZenjectUnitTestFixture
/ ZenjectIntegrationTestFixture
を使うと、テスト実行時にContainer
を設定することができるようになります。
なお、 ZenjectIntegrationTestFixture
はAssetStoreからZenjectを導入した場合はデフォルトでは使えないようになっています。
Assets/Plugins/Zenject/OptionalExtras/ZenjectIntegrationTestFixture.zip
を解凍して中のファイルを手動で配置することで利用可能になります。
MoverTestのZenject版
PreInstall()
とPostInstall()
の間にDI Container
に対して操作することができるようになっています。
このタイミングでDI Container
にテスト用のオブジェクトを注入するように設定してあげることで、テストモジュールが差し込まれてテストができるようになります。
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の扱いが非常に重要になる
- 設計の勉強がしたいならこの資料をみましょう