🤖

UnityのDI超ざっくり入門 1 - そもそもUnityのDIって何?

2024/05/29に公開

はじめに

DI(Dependency Injection)。

単語自体を聞いたことがある方はそこそこいるのではないでしょうか。

Unityにおいては、ZenjectやVContainerといった便利なDIライブラリも登場し、その話題もちらほら耳にすることがあるかと思います。

しかしながらそれらのライブラリの意義・メリットが分かりにくいと感じたことはないでしょうか?

これには主に以下の2つの理由があると思っています。

  • UnityにおけるDIのメリット・効能を実践的に示した記事があまりない
  • UnityにおけるDIはちょっと軽めの意味で使われることが多い

Zenject / VContainerはDIのライブラリなので、「UnityにおけるDI」が何たるかを知らないと話も分かりづらいですし、何より使う気にならないと思います。

そこで、今回からは、そもそもDIって何がいいのかを知ってもらうことを主目的とした記事を書いていきます。

DIの基本的な概念

UnityにおけるDI(Dependency Injection)とは、クラス間の参照のごたごたを解決する方法、くらいの認識で大丈夫です。

本当はもうちょっと細かい話とかもあるのですが、Unityにおいてはあまりそういった話を中心にすることは少ないです。

本記事でも、UnityにおけるDIの基本的な概念を解説することを目的としているので、そういった話は一旦置いておきます。

以降、Unityにおけるそういうふわふわした概念としてのDIのことをUnityDIと呼ぶことにします。

例えば...

例えば、以下のようなシチュエーションを考えてみます。

  • PlayerクラスがCoinと衝突したらScoreクラスを10ポイント加算する

これを実現するためには、PlayerクラスがScoreクラスを参照する必要があります。

public class Player : MonoBehaviour
{
    private Score _score;

    private void Awake()
    {
        _score = /*どうやって参照する?*/
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform.GetComponent<Coin>())
        {
            _score.Add(10);
        }
    }
}

public class Score : MonoBehaviour
{
    private int _score;

    public Score(int score)
    {
        _score = score;
    }

    public void Add(int score)
    {
        _score += score;
    }
}

public class Coin : MonoBehaviour
{
}

この際にどうやって参照しよう…となる問題、Unityあるあるだと思います。これに対してUnity / デザインパターンは色々な解決法を用意しています。

主な解決法を見ていきましょう。

Find()系の関数

まずはFind()系の関数です。こちらはUnityのAPI。

直感的にオブジェクトを取得できます。

  • GameObject.Find()
  • GameObject.FindWithTag()
  • GameObject.FindObjectsByType<T>()

特にGameObject.FindObjectsByType<T>()は型を指定してオブジェクトを取得できるので、この中で一番安全にオブジェクトを取得できます。しかも引数のオプション次第ではかなり高速らしい。

_score = GameObject.FindObjectsByType<Score>()[0]; // Score型のオブジェクトを全件取得し、最初の要素を取得

メリット

  • Unityというゲームエンジン的にはかなり直感的なので理解しやすい

デメリット

  • 実行時にオブジェクトを探すため、ヒエラルキーの状態に細心の注意を払う必要がある
    • ヒエラルキーが崩れるとエラー、なんてこともしばしば
  • API自体がちょっと重い
  • オブジェクトが存在しない場合にはnullが返ってくるため、その場合の処理を考慮する必要がある
  • Scoreは絶対にMonoBehaviourを継承しないといけないため、Scoreを純粋なC#クラス(以降PureC#)にしたい場合には使えない
  • 濫用するとクラス間の関係が複雑になりやすい
  • FindObjectsByType<T>()以外は名前参照なので、安易に名前を変えられなくなる
    • この中ではFindObjectsByType<T>()はかなり優秀です

Find()系のメソッドは実行時まで実際にクラスを取得できるかが分からないという怖さがあり、ふとしたときにヒエラルキーの構造をちょっと変えるとnull参照エラーが発生し、その原因がわかりにくいという問題が大きいです。

また、簡単にクラスを取得できる関係上、濫用してしまうと簡単にクラス間の依存関係が複雑になり、スパゲッティコードになってしまいますが、参照関係の確認も面倒なため、この辺りの意識が薄れがちになります。

使いたい側のクラスで毎回そういったリスクを考えながら使用するのは、ちょっと面倒です。

シングルトンパターン

Scoreクラスが確実に1つしか存在しないことが保証される場合、シングルトンパターンを使う方法もあります。

public class Score : MonoBehaviour
{
    private static Score _instance;

    public static Score Instance => _instance; // 外部からScoreクラスのインスタンスを取得するためのプロパティ

    private int _score;

    private void Awake()
    {
        if (_instance == null)
        {
            _instance = this;
        }
        else
        {
            Destroy(gameObject);
        }
    }

    public void Add(int score)
    {
        _score += score;
    }
}

public class Player : MonoBehaviour
{
    private void OnCollisionEnter(Collsion collision)
    {
        if (collision.transform.GetComponent<Coin>())
        {
            Score.Instance.Add(10); // Score.Instanceと書けばいつでもどこでもScoreクラスのインスタンスを取得できる
        }
    }
}

メリット

  • シングルトンパターンを使うことで、Scoreクラスが確実に1つしか存在しないことが保証される
  • どこからでも簡単に参照できる

デメリット

しかし、これにもデメリットが多々あります。

  • 1つしかないことが保証されないとそもそも利用できない
  • どこからでも簡単に参照できるのはデメリットでもある
    • やりたい放題しやすい
  • 濫用すると実行順などがわかりにくくなって変なバグが発生しやすい
  • ヒエラルキーがぐちゃぐちゃになりやすい
  • DontDestroyOnLoadを使いだすと、オブジェクトの管理が若干むずかしくなる
  • テストしにくい
  • シングルトンクラスに依存しているという感覚が薄れる
    • クラスをパッと見て、シングルトンクラスを使っているかどうかを判断するのがちょっと面倒

こちらは単純にデザインパターンとしても推奨されづらくなっており、あまり使いたくない方法です。
シングルトンクラスはどこからでも簡単に参照できるため、徐々に肥大化し、神クラス(なんでもできちゃうクラス)のようになることがしばしばあります。

[SerializeField]を使う

ここからいわゆるUnityDIの範疇に入ってきます。

public class Player : MonoBehaviour
{
    [SerializeField] private Score _score; // InspectorからScoreクラスのオブジェクトをアタッチすることができる

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform.GetComponent<Coin>())
        {
            _score.Add(10);
        }
    }
}

このように[SerializeField]を使うと、インスペクターからあらかじめオブジェクトの依存関係を解決することが出来ます!

この方法はUnityDIとしては一番シンプルで、かつUnityのゲームエンジンとしての機能を有効に活用できる方法です。オススメ。

メリット

  • Unityのいいところ(ドラッグドロップでオブジェクトをアタッチできるという直感的な機能)を活かしている
  • 実行時に参照することがないので実行順によるバグなどもない
  • 使える場面が多い

クラス間の参照を解決する方法としてはこれ以上ないほどシンプルなのに、ヒエラルキー等の影響を受けづらく、実行順バグ等の問題もないため、とても使いやすいです。

nullエラーが出た場合も、該当のインスペクターからオブジェクトをアタッチするだけで解決できるため、比較的安心です。

デメリット

ただ、これにもデメリット(というかケースバイケースな部分)があります。

  • PureC#のコードは参照できない(MonoBehaviourを継承していないといけない)
  • 動的にオブジェクトを生成する場合には使えない

特に「PureC#のコードは参照できない」というデメリットが、UnityにおけるPureC#の実装を難しくしています。

コンストラクタを利用する

DIとして最もそれっぽい方法です。

といっても、ただコンストラクタで依存を解決するだけです。

コンストラクタとは、クラスをインスタンス化する際に呼ばれる関数のことです。C#においてはnew演算子を使うことでコンストラクタを呼び出すことができます。

これを利用してクラス間の依存を解決するというのが、MonoBahaviourを継承していないクラスにおけるDIの基本です。

もしMonoBehaviourを継承していないクラスでDIをする場合
public class Player
{
    private Score _score;

    /// <summary>
    /// コンストラクタ
    /// 呼び出すときはnew Player(score)のように呼び出す
    /// </summary>
    public Player(Score score)
    {
        _score = score; // 依存関係を解決
    }
}

public class GameManager : MonoBehaviour
{
    private void Start()
    {
        var score = new Score(0); // Scoreクラスのインスタンスを生成
        var player = new Player(score); // Playerクラスのインスタンスを生成(DI!)
    }
}

MonoBehaviourを継承しているとコンストラクタを使えないので、こちらでは自前のInit()関数を用意する方法がよく取られます。

MonoBehaviourを継承しているクラスでDIをする場合
public class Player : MonoBehaviour
{
    private Score _score;

    /// <summary>
    /// 依存関係を解決するための関数(絶対呼んでね!!!!!)
    /// </summary>
    public void Init(Score score)
    {
        _score = score;
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.transform.GetComponent<Coin>())
        {
            _score.Add(10);
        }
    }
}

public class GameManager : MonoBehaviour
{
    [SerializeField] private Player _player;
    [SerializeField] private Score _score;

    private void Start()
    {
        // 動的にPlayerクラスを生成
        var player = Instantiate(_player);

        // 疑似的なコンストラクタ
        player.Init(_score);
    }
}

メリット

  • あらゆる場面で柔軟に使用できる
  • 動的に依存関係を解決できる
  • PureC#の場合は実行時エラーになりづらい
    • コンストラクタを使っているため、上手く取れていなかったらコンパイル時にエラーが発生する

デメリット

  • MonoBehaviourの場合、"絶対呼んでね!!!!!"がだるい
    • 呼ぶのを忘れるとnullエラー
    • 呼ぶ側もわざわざこのために2行書かないといけない
      • コンストラクタなら1行で済むのに...!
コンストラクタとの比較
// コンストラクタを使った場合(実際はMonoBehaviour継承してるから無理だけど)
var player = new Player(_score);

// Init()関数を使った場合
var player = Instantiate(_player);
player.Init(_score);

雑感・まとめ

今回はUnityにおけるDI(Dependency Injection)の基本的な概念について見ていくために、Unityにおける依存関係の解決方法をいくつか見ていきました。

Find()系の関数やシングルトンパターンはUnityDIとはいえず、問題も多い一方、[SerializeField]やコンストラクタを使った方法は柔軟性があり、実行時の環境に左右されづらい形になっています。

次回は、これら2つの方法を使って実際にゲームを作ってみて、その効能を確認していきます。

次回;

https://zenn.dev/qemel/articles/4a032e10b3629c

Discussion