見出し画像

Dependency InjectionとZenject

こんにちは、デザイニウムGeraldです。今日はUnityのZenjectプラグインを紹介します。 Zenjectを使用すると、他のオブジェクト指向プログラミングのようにDependency Injectionを使用できます。Dependency Injectionはデザインパターンではありませんが、クラス間の依存関係を管理するのに役立ちます。この投稿では、Dependency Injectionがどのように機能し、なぜ役立つのかを説明します。高度な使用法については、Zenjectのドキュメントを参照してください。

なおこちらの記事は英語版もあります。
Click Here! for English Version)

Dependency Injectionとは

Unityでの従来のコーディング方法は、主に次のようになります。

画像1

ご覧のとおり、シーン内のスクリプトは交差しており、相互に参照しています。特に大規模なプロジェクトでは、さらに悪化し、変更を行うことが難しくなります。すべてが密に結合されており、1つの変更が多くのクラスに影響を与えます。

Dependency Injectionコンテナを使用することにより、他の多くのスクリプトからではなく、1つの場所から参照を取得します。

画像2

このスタイルにより、コードの変更が容易になり、コードの品質が向上します。

Dependency Injection(別名DI)はコードを自動的に分割することはないためその点は注意してください。 コードを分割するためにDIフレームワークは必要ありません。
classとdependencyを分割する際に、大きなクラスを小さなクラスに分割することがよくあります。 基本的に単一責任原則を尊重します!

一般的なPlayerスクリプト

1000行を超え、多くの役割を負うPlayerスクリプトを想像してみてください。これを簡単にするために、基本的な機能のみを見ていきます。

従来の方法:

public class Player : MonoBehaviour {
   [SerializeField]
   private float _moveSpeed = 5;
   [SerializeField]
   private Camera _camera;
   private Animator _animator;
   private Rigidbody _rigidbody;

   void Start() {
       _animator = GetComponent<Animator>();
       _rigidbody = GetComponent<Rigidbody>();
   }

   void Update() {
       UpdateAnimator();
       MovePlayer();
       UpdateCamera();
   }

   private void UpdateAnimator() {
       _animator.SetFloat("moveSpeed", _rigidbody.velocity.magnitude);
   }

   private void MovePlayer() {
       transform.position += transform.forward * Time.deltaTime * _moveSpeed;
   }

   private void UpdateCamera() {
       _camera.transform.position = transform.forward * -2;
   }
}

このクラスには3つの役割があります。Animatorの処理、プレーヤーの移動、カメラの更新です。それを分離するために、それをより他のクラスに分割することができます。

Zenjectを使用しない疎結合コード:

public class Player : MonoBehaviour {
   [SerializeField]
   private float _moveSpeed = 5;
   [SerializeField]
   private Camera _camera;
   private Animator _animator;
   private Rigidbody _rigidbody;

   private PlayerAnimatorHandler _playerAnimatorHandler;
   private PlayerCameraHandler _playerCameraHandler;
   private PlayerMovementHandler _playerMovementHandler;

   void Start() {
       _animator = GetComponent<Animator>();
       _rigidbody = GetComponent<Rigidbody>();
       _playerAnimatorHandler = new PlayerAnimatorHandler(_animator, _rigidbody);
       _playerCameraHandler = new PlayerCameraHandler(_camera, transform);
       _playerMovementHandler = new PlayerMovementHandler(transform);
   }

   void Update() {
       _playerAnimatorHandler.Update();
       _playerCameraHandler.Update();
       _playerMovementHandler.Update(_moveSpeed);
   }
}
public class PlayerAnimatorHandler {
   private Animator _animator;
   private Rigidbody _rigidbody;

   public PlayerAnimatorHandler(Animator animator, Rigidbody rigidbody) {
       _animator = animator;
       _rigidbody = rigidbody;
   }

   public void Update() {
       _animator.SetFloat("moveSpeed", _rigidbody.velocity.magnitude);
   }
}
public class PlayerMovementHandler {
   private Transform _transform;

   public PlayerMovementHandler(Transform transform) {
       _transform = transform;
   }

   public void Update(float moveSpeed) {
       _transform.position += _transform.forward * Time.deltaTime * moveSpeed;
   }
}
public class PlayerCameraHandler {
   private Camera _camera;
   private Transform _transform;

   public PlayerCameraHandler(Camera camera, Transform transform) {
       _camera = camera;
       _transform = transform;
   }

   public void Update() {
       _camera.transform.position = _transform.forward * -2;
   }
}

Playerスクリプトははるかに簡単で、各クラスの役割は1つです。 ただし、Playerは正しい依存関係(カメラ、Animator、Transformなど)を持つ他の3つのクラスをインスタンス化する必要があります。

これが、DIが役に立つ理由です。 Zenjectフレームワークはクラスを作成し、必要な依存関係を自動的に提供します。


Zenjectの方法:

public class Player : MonoBehaviour {
   [SerializeField]
   private float _moveSpeed = 5;

   public float GetMoveSpeed() {
       return _moveSpeed;
   }
}
public class PlayerAnimatorHandler : ITickable {
   private Animator _animator;
   private Rigidbody _rigidbody;

   public PlayerAnimatorHandler(Animator animator, Rigidbody rigidbody) {
       _animator = animator;
       _rigidbody = rigidbody;
   }

   public void Tick() {
       _animator.SetFloat("moveSpeed", _rigidbody.velocity.magnitude);
   }
}
public class PlayerMovementHandler : ITickable {
   private Player _player;

   public PlayerMovementHandler(Player player) {
       _player = player;
   }

   public void Tick() {
       _player.transform.position += _player.transform.forward * Time.deltaTime * _player.GetMoveSpeed();
   }
}
public class PlayerCameraHandler : ITickable {
   private Camera _camera;
   private Player _player;

   public PlayerCameraHandler(Camera camera, Player player) {
       _camera = camera;
       _player = player;
   }

   public void Tick() {
       _camera.transform.position = _player.transform.forward * -2;
   }
}

これで、Playerスクリプトはシンプルになりました。 Animator、動き、カメラスクリプトがPlayerから完全に切り離されました。
以前は、Update()メソッドを使用して他のクラスを呼び出していました。 PlayerAnimatorHandlerはMonoBehaviourではないため、Unity Update()メソッドを使用できません。 ITickableインターフェースを実装することにより、ZenjectはフレームごとにTick()メソッドを自動的にコールします。

したがって、Start()、Update()、LateUpdate()などを取得するためにMonoBehaviourは必要ありません。

Zenjectでは、どうやってクラスをインスタンス化するかの指定が必要です。 Zenject MonoInstallerでこれを行うことができます:

public class PlayerInstaller : MonoInstaller<PlayerInstaller> {
   [SerializeField]
   private Player _player;
   [SerializeField]
   private Rigidbody _rigidbody;
   [SerializeField]
   private Camera _camera;
   [SerializeField]
   private Animator _animator;

   public override void InstallBindings() {
       Container.BindInstance(_player);
       Container.BindInstance(_rigidbody);
       Container.BindInstance(_camera);
       Container.BindInstance(_animator);

       Container.BindInterfacesAndSelfTo<PlayerAnimatorHandler>().AsSingle();
       Container.BindInterfacesAndSelfTo<PlayerCameraHandler>().AsSingle();
       Container.BindInterfacesAndSelfTo<PlayerMovementHandler>().AsSingle();
   }
}

Zenject DIコンテナーに何かを入れるには、いくつかのBind()メソッドを使えます。 カメラ、Gameobject、MonoBehaviourなどの既存の参照については、BindInstance()メソッドがあります。
BindInterfacesAndSelfTo()メソッドは、UnityのStart()で目的のタイプの新しいクラスを作成するようZenjectに指示します。

MonoInstallerとBindingの詳細については、こちらをご覧ください。

使い方

Zenjectはオープンソースであり、GitHubまたはUnity Asset Storeからダウンロードできます。 残念ながら、しばらくの間アップデートを取得できなかったため、UnityはAsset StoreからZenjectを削除しました。
別の開発者が、Extenjectと呼ばれる別の名前でプラグインを再公開しました。

Zenjectを使うために他のインストール方法はありません。

簡単なキューブの移動方法を見てみましょう。
まず、SceneContextを作成する必要があります。 これはDI Containerです。

画像3

SceneContextで、SceneInstallerスクリプトを追加し、「Mono Installer」リストにインストーラーを追加します。

画像4

インストーラーは、シーンからのキューブとCubeMoverスクリプトをDI Containerにバインドします。

public class SceneInstaller : MonoInstaller {
   [SerializeField]
   private GameObject _cube;

   public override void InstallBindings() {
       Container.BindInstance(_cube);
       Container.BindInterfacesAndSelfTo<CubeMover>().AsSingle();
   }
}

CubeMoverスクリプトは、IInitializableおよびITickableインターフェイスを使用して、MonoBehaviourと同様に、Start()およびUpdate()でキューブを移動します。 キューブGameObjectリファレンスは、DI Containerによって提供されます。

public class CubeMover : IInitializable, ITickable {
   private Vector3 _startPosition;
   private float _moveSpeed = 5f;
   private GameObject _cube;

   public CubeMover(GameObject cube) {
       _cube = cube;
   }

   // Initialize is called before the first frame update
   public void Initialize() {
       _cube.transform.position = _startPosition;
   }

   // Tick is called once per frame
   public void Tick() {
       _cube.transform.Translate(_cube.transform.forward * _moveSpeed * Time.deltaTime);
   }
}

いわゆるConstructor Injectionをして、キューブ参照を取得しました。これは推奨方法です。 他のInjection方法はこちらをご覧ください

Zenject Bindingは便利

Zenjectではシーンや他のクラス参照から参照を挿入できます。 ただし、基本的には任意のタイプをバインドできます。 たとえば、Enum:

public enum Difficulty {
   Easy, Medium, Hard
}
public class Enemy : ITickable {
   private Difficulty _difficulty;

   public Enemy(Difficulty difficulty) {
       _difficulty = difficulty;
   }

   public void Tick() {
       // use _difficulty here
       Debug.Log(_difficulty);
   }
}
public class SceneInstaller : MonoInstaller<SceneInstaller> {
   [SerializeField]
   private Difficulty _difficulty;

   public override void InstallBindings() {
       Container.BindInstance(_difficulty);
       Container.BindInterfacesAndSelfTo<Enemy>().AsSingle();
   }
}

EnemyクラスにDifficulty Enumを挿入しました。どのクラスでも使用できます。
従来の方法で行った場合、各クラスに列挙型を設定する必要があります(Enemy、EnemyMovement、EnemyAttackなど)。

Zenjectはすべてのクラスで簡単にアクセスでき、1か所にのみ保存できます。

Singletonを削除

Singletonは、明示的な参照がなくても、データの取得とコールによく使用されます。 残念ながら、Singletonはアンチパターンと見なされます。
- コードをより複雑にする
- クラスを再利用できなくする
- Unitテストができません
- Singletonはコードに隠されています

ZenjectでConstructorを表示することにより、クラスでどのタイプがあるかがすぐにわかります。 Singletonは悪いコードになります。

ここでは、HUD Singletonでプレーヤーのヘルスを見せる例を示します。

従来の方法:

public class Player : MonoBehaviour {
   [SerializeField]
   private int _startHealth = 100;

   private int _health;

   void Start() {
       _health = _startHealth;
       PlayerUI.Instance.SetHealth(_health);
   }

   public void TakeDamage(int damage) {
       _health -= damage;
       PlayerUI.Instance.SetHealth(_health);
   }
}
public class PlayerUI : MonoBehaviour {
   public static PlayerUI Instance { get; private set; }

   [SerializeField]
   private Text _healthText;

   void Awake() {
       Instance = this;
   }

   public void SetHealth(int health) {
       _healthText.text = health.ToString();
   }
}

SingletonをZenjectに置き換える方法を見てみましょう。

public class Player : IInitializable {

   private int _health;
   private int _startHealth;
   private PlayerUI _playerUI;

   public Player(int startHealth, PlayerUI playerUI) {
       _startHealth = startHealth;
       _playerUI = playerUI;
   }

   public void Initialize() {
       _health = _startHealth;
       _playerUI.SetHealth(_health);
   }

   public void TakeDamage(int damage) {
       _health -= damage;
       _playerUI.SetHealth(_health);
   }
}
public class PlayerUI {
   private Text _healthText;

   public PlayerUI(Text healthText) {
       _healthText = healthText;
   }

   public void SetHealth(int health) {
       _healthText.text = health.ToString();
   }
}
public class SceneInstaller : MonoInstaller<SceneInstaller> {
   [SerializeField]
   private int _startHeath = 100;
   [SerializeField]
   private Text _healthText;

   public override void InstallBindings() {
       Container.BindInstance(_healthText);
       Container.BindInstance(_startHeath);
       Container.BindInterfacesAndSelfTo<Player>().AsSingle();
       Container.BindInterfacesAndSelfTo<PlayerUI>().AsSingle();
   }
}

PlayerとPlayerUIはDI Containerにバインドされているため、PlayerはPlayerUI参照を簡単に挿入できます。 Singletonはもう必要ありません!

補足:PlayerスクリプトはMonoBehaviourではなくなり、インスペクターでStartHealthを変更できません。 そのため、Playerに挿入されるSceneInstallerでこの変数を公開しました。

クラスを再利用

Zenjectでクラスの再利用が少し簡単になります。 この例では、PlayerとEnemyの移動スクリプトを再利用する方法を示します。
違いは、入力が動きを制御する方法だけです。 Playerの場合、入力はキーボードから来ます。 AIの場合、特定の計算ロジックによって制御されます。 私の場合、Enemyはウェイポイントだけに行きます。

インターフェイスは、両方の場合に同じMovementスクリプトを使用するのに助かります。 次に、IInputProviderインターフェイスを使って、2つのインプリメンテーションを作ります。

public interface IInputProvider {
   Vector3 GetMoveDirection();
   bool GetJump();
}
public class PlayerInputProvider : IInputProvider {

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

   public bool GetJump() {
       return Input.GetButtonDown("Jump");
   }
}
public class EnemyInputProvider : IInputProvider {
   private Waypoint _wayPoint;
   private Transform _transform;

   public EnemyInputProvider(Transform transform, Waypoint waypoint) {
       _transform = transform;
       _wayPoint = waypoint;
   }

   public Vector3 GetMoveDirection() {
       return _wayPoint.transform.position - _transform.position;
   }

   public bool GetJump() {
       return false;
   }
}

Movementスクリプトでは、方向とジャンプ入力のみが必要です。 IInputProviderInjectする方法に注意してください。

public class Movement : ITickable {
   private const float JumpPower = 20f;
   private const float MoveSpeed = 0.5f;

   private IInputProvider _inputProvider;
   private CharacterController _characterController;

   public Movement(IInputProvider inputProvider, CharacterController characterController) {
       _inputProvider = inputProvider;
       _characterController = characterController;
   }

   public void Tick() {
       Vector3 moveDirection = _inputProvider.GetMoveDirection() * MoveSpeed;
       if (_inputProvider.GetJump() && _characterController.isGrounded) {
           moveDirection.y += JumpPower;
       }
       moveDirection += Physics.gravity;
       _characterController.Move(moveDirection * Time.deltaTime);
   }
}

Movementスクリプトは、どのプロバイダー(PlayerとかEnemy)を使用するかを気にしません。
この例では、GameObjectContextを使用します。 GameObjectContextは、SceneContext内にあり、基本的にはシーンDI Container内の小さなDI Containerです。
そうすれば、PlayerのロジックがEnemyのロジックに干渉しません
PlayerとEnemyはこのようなインストーラーを取得します。

public class PlayerInstaller : MonoInstaller<PlayerInstaller> {
   [SerializeField]
   private Transform _transform;
   [SerializeField]
   private CharacterController _characterController;

   public override void InstallBindings() {
       Container.Bind<IInputProvider>().To<PlayerInputProvider>().AsSingle();
       Container.BindInterfacesAndSelfTo<Movement>().AsSingle();
       Container.BindInstance(_transform);
       Container.BindInstance(_characterController);
   }
}
public class EnemyInstaller : MonoInstaller<EnemyInstaller> {
   [SerializeField]
   private Transform _transform;
   [SerializeField]
   private Waypoint _waypoint;
   [SerializeField]
   private CharacterController _characterController;

   public override void InstallBindings() {
       Container.Bind<IInputProvider>().To<EnemyInputProvider>().AsSingle();
       Container.BindInterfacesAndSelfTo<Movement>().AsSingle();
       Container.BindInstance(_transform);
       Container.BindInstance(_waypoint);
       Container.BindInstance(_characterController);
   }
}

Container.Bind<IInputProvider>().To<PlayerInputProvider>().AsSingle()」はクラスがIInputProviderを挿入するときに、DI ContainerにPlayerInputProviderインスタンスをインスタンス化するように指示します。
Movementクラスは2回作成されます。1回はPlayerとEnemy用です。 しかし、コンストラクタで2つの異なるInputクラスを取得します。

Zenjectの利点

- コードを疎結合
- 単一責任原則の実装
- スクリプトに他のスクリプトへの直接参照がない
- すべてのシーンレファレンスは1つの場所に保存されます(MonoInstaller)
- レファレンスが失われた場合でも見やすくなります
- Injectされたクラス名を変更できます
- 通常、普通の方法でレファレンスはインスペクターで失われます
- コード品質を向上させる
- Singletonは不要
- MonoBehaviourの減少
- UnityからのUpdate()コールが少ないため、パフォーマンスを向上させる
- Unitテストが可能

Zenjectの短所

- デバッグが難しい
- MonoBehaviourが少ないため、エディターで変数が公開されない
- プロトタイピングがはるかに複雑になる
- インスペクターで変数と設定を公開するには、常に追加のコードが必要
- 新しいECSを使えない
- 従来のプログラミング方法からDIに切り替えるには、最初は手間と時間がかかります
- コードの品質自体を魔法のように改善するわけではない。ただクリーンコードには便利です。
- 単一スクリプトの使用パフォーマンスを確認するには、Advanced Profilingを有効にする必要です
- 過去数か月間の開発者からのアップデートが少ない

Zenjectを使用する場合

- 大規模プロジェクトでECSを使用していない場合。。。
- シーンに多くのレファレンスがある場合
  多くの可動部品(ホイール、クランプ、ロック、アクチュエーターなど)を含むプロジェクトがあり、すべてがシーンにクロスコネクトされていました。
- Zenjectを使用すると、すべての参照に対して1つの場所がありました
- DIに慣れている、またはDIに興味がある開発者

まとめ

- Dependency InjectionとZenjectはコード疎結合に役立ちます
- 正しく使用するには練習と努力が必要です
- コードの品質が向上する
- パフォーマンスも少しは向上する
- プロトタイプを作成するときに良くない
- レファレンス管理は改善できる
- 複雑なトピックなので、Zenjectのドキュメントとその使用方法を読む必要があります

出典
https://github.com/modesttree/Zenject
https://qiita.com/toRisouP/items/b3d3c43db40857ca4ad4
https://stackoverflow.com/questions/12755539/why-is-singleton-considered-an-anti-pattern

編集後記

広報のマリコです!今回もGerald英語版と日本語版の記事を書いてくれました✨外国人のエンジニアが多いチームの方などシェアに役立てて頂けるのではないかなと思います!今回の記事はもともと「1ヶ月以上かかるような大きなプロジェクトでZenjectはとても役立つ」というGeraldの知見を社内で共有して欲しいということだったため、英語版もすでにデザイニウム内で役に立っています😊Gerald, danke schön!

The Designium.inc
インタラクティブ ウェブサイト
Twitter
Facebook
Instagram

この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!
スキありがとうございます!
5
東京(五反田)と会津若松市(本社)に拠点をおくテクノロジーとデザインの会社です。「人に楽しんでもらうこと」をコンセプトに、様々な体験型コンテンツの開発をしたり「地域やクライアントの役に立つもの」「自分たちが楽しいと感じられるもの」をつくっています。案件やコラボのご相談はお気軽に!