Dependency InjectionとZenject
こんにちは、デザイニウムのGeraldです。今日はUnityのZenjectプラグインを紹介します。 Zenjectを使用すると、他のオブジェクト指向プログラミングのようにDependency Injectionを使用できます。Dependency Injectionはデザインパターンではありませんが、クラス間の依存関係を管理するのに役立ちます。この投稿では、Dependency Injectionがどのように機能し、なぜ役立つのかを説明します。高度な使用法については、Zenjectのドキュメントを参照してください。
なおこちらの記事は英語版もあります。
(Click Here! for English Version)
Dependency Injectionとは
Unityでの従来のコーディング方法は、主に次のようになります。
ご覧のとおり、シーン内のスクリプトは交差しており、相互に参照しています。特に大規模なプロジェクトでは、さらに悪化し、変更を行うことが難しくなります。すべてが密に結合されており、1つの変更が多くのクラスに影響を与えます。
Dependency Injectionコンテナを使用することにより、他の多くのスクリプトからではなく、1つの場所から参照を取得します。
このスタイルにより、コードの変更が容易になり、コードの品質が向上します。
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です。
SceneContextで、SceneInstallerスクリプトを追加し、「Mono Installer」リストにインストーラーを追加します。
インストーラーは、シーンからのキューブと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スクリプトでは、方向とジャンプ入力のみが必要です。 IInputProviderをInjectする方法に注意してください。
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
この記事が気に入ったらサポートをしてみませんか?