すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【Unity】Unity向けDependency Injection講座

概要

Unity向けDependency Injectionの方法について紹介します。
もしDependency Injectionを知らない方は前回の記事も併せてどうぞ!

ちなみにZenject(Unity向けDIフレームワーク)は使用しません!

環境

  • Unity 2019.4.3f1

今回の主目的

UnityEditor上で依存を切り替え可能にすること

DIの種類

前回の#DIの種類でも紹介しましたが、
DIには主に "Constructor Dependency Injection"と"Method/Property Dependency Injection"があります。

前回と同様に以下のILoggerを注入する前提で紹介します。

public interface ILogger
{
    void Log(string message);
}

Method/Property Dependency Injection

public変数や[SerializeField]を経由させて依存注入することになります。
注意点としてInspectorで扱うにはMonoBehaviour継承クラスである必要があります。

Inspector上でも扱いたいですが「抽象に依存」させたいため、abstractクラスとして実装します。

public abstract class BaseLogger : MonoBehaviour, ILogger
{
    public abstract void Log(string message);
}

後は上記 abstractクラス をpublic変数や[SerializeField]で定義します。

using UnityEngine;
using System.Threading.Tasks;

public class PropertyDIMessenger : MonoBehaviour
{
    [SerializeField]
    public BaseLogger logger;

    public void Recv(string message)
    {
        logger.Log(message);
    }

    // 動作確認用
    async void Start()
    {
        await Task.Delay(1000);
        Recv("Hello, I'm Messenger!");
    }
}

そして、UnityEditorのInspector上でBaseLoggerを継承したコンポーネントを渡してあげましょう

f:id:tsgcpp:20200715231322j:plain

Constructor Dependency Injection

残念ながらUnityのMonoBehaviourのコンストラクタは直接コールすることは基本的にありません。
よって、Unityのコンポーネントでは Constructor Dependency Injection はできません。

ただ、
インスタンス生成後は依存の切り替えはなしのDIは実装可能です!

その方法は、以下の2つで実現可能です。

  • [SerializeField] private変数を定義する
    • Method/Property Dependency Injection で紹介したものと同様
  • Awake()などのメソッド内でGetComponent経由で注入

ということでAwake()メソッドを使ったDIを紹介します!

Awake()だけでなく、Start(), OnEnable()でも可能です!

Awake を使った Dependency Injection

以下のように実装します!

using UnityEngine;
using System.Threading.Tasks;

[RequireComponent(typeof(ILogger))]
public class AwakeDIMessenger : MonoBehaviour
{
    private ILogger _logger;

    void Awake()
    {
        _logger = GetComponent<ILogger>();
    }

    public void Recv(string message)
    {
        _logger.Log(message);
    }

    // 動作確認用
    async void Start()
    {
        await Task.Delay(1000);
        Recv("Hello, I'm Messenger!");
    }
}

ポイントとしては

  • RequireComponentで依存する抽象インターフェースを指定
  • GetComponentで依存する依存オブジェクトを取得(注入)

Awake を使ったDIのメリット

  • インスタンス生成後は依存の切り替え不可ということを明示可能
  • interfaceに依存させることが可能
    • abstractクラスの用意が不要となる

今回使用してるILogger実装クラスであれば以下のようにabstractクラスを継承させなくてもよくなります。

using UnityEngine;

public class BaselessDebugLogger : MonoBehaviour, ILogger
{
    public void Log(string message)
    {
        Debug.Log(message);
    }
}

Inspector上では以下のようになります。

f:id:tsgcpp:20200715234301j:plain

RequireComponentで指定したコンポーネントが追加されていない場合

以下のようにエラーメッセージが表示されます。
よって、依存するコンポーネントから先に追加してください

f:id:tsgcpp:20200715234411j:plain

注意:スクリプトで直接AddComponentする場合はエラーメッセージは出ません!

Script Execution Order に注意

Awake()などの実行順序はScript Execution Orderで決まります。
もし注入される依存のAwake()が実行されていることが前提の場合は注意してください!

以下は注意が必要な例です。

以下のようにAwake()がコールされていることが前提のLogger AwakeInitializedLogger があったとします(結構無理やりに見えますがw)。

using System;
using UnityEngine;

/// <summary>
/// Awake後のみ使用可能なLogger(ExecutionOrder検証用)
/// </summary>
public class AwakeInitializedLogger : MonoBehaviour, ILogger
{
    private Action<string> _loggerAction = null;

    void Awake()
    {
        _loggerAction = message => Debug.Log(message);
    }

    public void Log(string message)
    {
        // Awake前に実行するとNullReferenceExceptionが発生
        _loggerAction(message);
    }

}

そして、以下のようにAwake()で早速Loggerを使用する AwakeLoggingMessengerがあったとします。

using UnityEngine;

// [DefaultExecutionOrder(-1)]  // こちらを指定することでAwake()のコールを優先可能
[RequireComponent(typeof(ILogger))]
public class AwakeLoggingMessenger : MonoBehaviour
{
    void Awake()
    {
        var logger = GetComponent<ILogger>();

        // logger取得後すぐにLogをコール
        logger.Log("Good Morning!");
    }
}

そして、「Awake を使った Dependency Injection」と同様に依存注入を実施しましょう。

f:id:tsgcpp:20200716215253j:plain

このDIの構成、問題があります。

AwakeLoggingMessenger.Awake()AwakeInitializedLogger.Awake()の順番で実行されるとNullReferenceExceptionが発生します。

Order of Execution の何がいけないのか

「それってOrder of Executionを設定すればいいだけじゃない?」って思った人もいるかもしれません。

確かにその通りではあるのですが、
DIのメリットの1つである「抽象に依存」というメリットが弱くなります!

注入される依存の状態を考慮する必要が出た時点で、少し蜜結合になっていると言えます。

余談:Awake, OnEnableの順番について

「じゃあOnEnableで依存注入すれば」と自分は考えました。
が、残念ながらそれも問題解決にならず。。。

オブジェクトA, BがそれぞれAwake(), OnEnable()を持っていて同時に生成される場合、以下の順番でメソッドがコールされます。

  1. A.Awake()
  2. A.OnEnable()
  3. B.Awake()
  4. B.OnEnable()

これはOrder of Execution for Event FunctionsによるとAwake()後は即座にOnEnable()が実施される仕様のためですね。

Script Execution Order の問題がなぜ起きるのか

理由は単純で Script Execution Order は依存関係を設定するために存在します。
逆に言えば Script Execution Order の設定なしの場合は依存関係が考慮されません。

設定すれば少し蜜結合になり、設定しなければ問題が発生するという悩ましい部分があります。
もちろん、今回紹介したDebugLoggerの様に問題が起きないパターンも存在します(インスタンスができた時点で使用できるため)。

Script Execution Orderの問題を回避したい場合は、次に紹介するDIフレームワークを使用することをオススメします!

Unity向けDIフレームワークについて

Unity向けDIフレームワークには以下のようなものがあります(詳細は省略)。

Unity向けDIフレームワークのメリット

  • 依存関係を指定する設計になっているため、依存関係を考慮した初期化が実施される
    • 基本的に依存するオブジェクトが先に初期化される
    • GetComponentの方法では「必ず依存注入されること」を保証できない(AddComponentを使用する場合など)
  • Script Execution Orderの意識がある程度不要
    • DIフレームワークは基本的にScript Execution Orderは意識しなくてもいいように設計されています(Awake()より前に依存解決と依存注入が実施されるなど)

上記のメリットがあるため、場合によっては利用を検討してみても良いと思います。

サンプルプロジェクト

github.com

雑感

DIはアプリケーションの柔軟性やテスタビリティにおいてとても重要なテクニックなのでぜひ活用してみて下さい。

最近、記事書くことが目的になりつつあって個人開発がおろそかになってる気が。。。

でも皆さんのお役立てできれば何よりです。
最後まで読んでいただきありがとうございました!

それではまた~。