Unity でサービスロケーターを使う

2023.02.20 追記

本記事で紹介しているスクリプトを発展させ、Package Manager から導入することができるサービスロケーターのパッケージを公開しています。

blog.gigacreation.jp

はじめに

例えば GameManager のような、どこからでもアクセスしたい・一つだけ存在していてほしいクラスを Unity で実装する場合、よく使われるのは【シングルトン】というデザインパターンです。

シングルトンは非常にシンプルでお手軽に実装でき、小規模なプロジェクトでは大変便利なものではありますが、LevelManager とか SaveLoadManager とか LanguageManager とかといった感じでマネージャークラスが増えてくると、いろんなシングルトンがいろんなクラスから好き勝手にアクセスされるようになり、クラス間の依存関係がぐちゃぐちゃになってしまいがちです。*1

そこで登場するのが【サービスロケーター】で、簡単に言うと、

  • どこからでもアクセスできる「格納場所*2」を一つだけ用意し、
  • GameManager や LevelManager などをそこに登録し、
  • 他のクラスからは GameManager などに直接アクセスするのではなく、「格納場所」を経由してアクセスするようにする

という代物です。こうすることで、クラス間の依存を「格納場所」に集約でき、依存関係が追いやすくなるというわけです。

(👇この解説動画が分かりやすかったです)

www.youtube.com

実装

それでは実装です。

サービスロケーター本体のクラス

まず、サービスロケーター本体のスクリプトは以下のようになります。

using System;
using System.Collections.Generic;
using UnityEngine;

public static class ServiceLocator
{
    /// <summary>
    /// インスタンスを登録する辞書。
    /// </summary>
    static readonly Dictionary<Type, object> instances = new Dictionary<Type, object>();

    /// <summary>
    /// インスタンスの登録をすべて解除します。
    /// </summary>
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void Initialize()
    {
        instances.Clear();
    }

    /// <summary>
    /// インスタンスを登録します。すでに同じ型のインスタンスが登録されている場合は登録できませんので、先に Unregister を行ってください。
    /// </summary>
    /// <param name="instance">登録するインスタンス。</param>
    /// <typeparam name="T">登録するインスタンスの型。</typeparam>
    public static void Register<T>(T instance) where T : class
    {
        var type = typeof(T);

        if (instances.ContainsKey(type))
        {
            Debug.LogWarning($"すでに同じ型のインスタンスが登録されています:{type.Name}");
            return;
        }

        instances[type] = instance;
    }

    /// <summary>
    /// インスタンスの登録を解除します。インスタンスが登録されていなかった場合は警告が出ます。
    /// </summary>
    /// <param name="instance">登録を解除するインスタンス。</param>
    /// <typeparam name="T">登録を解除するインスタンスの型。</typeparam>
    public static void Unregister<T>(T instance) where T : class
    {
        var type = typeof(T);

        if (!instances.ContainsKey(type))
        {
            Debug.LogWarning($"要求された型のインスタンスが登録されていません:{type.Name}");
            return;
        }

        if (!Equals(instances[type], instance))
        {
            Debug.LogWarning($"登録されている要求された型のインスタンスと渡されたインスタンスが一致しません:{type.Name}");
            return;
        }

        instances.Remove(type);
    }

    /// <summary>
    /// 指定された型のインスタンスがすでに登録されているかをチェックします。
    /// </summary>
    /// <typeparam name="T">登録を確認するインスタンスの型。</typeparam>
    /// <returns>指定された型のインスタンスがすでに登録されている場合は true を返します。</returns>
    public static bool IsRegistered<T>() where T : class
    {
        return instances.ContainsKey(typeof(T));
    }

    /// <summary>
    /// 渡されたインスタンスがすでに登録されているかをチェックします。
    /// </summary>
    /// <param name="instance">登録を確認するインスタンス。</param>
    /// <typeparam name="T">登録を確認するインスタンスの型。</typeparam>
    /// <returns>渡されたインスタンスが既に登録されている場合は true を返します。</returns>
    public static bool IsRegistered<T>(T instance) where T : class
    {
        var type = typeof(T);

        return instances.ContainsKey(type) && Equals(instances[type], instance);
    }

    /// <summary>
    /// インスタンスを取得します。取得できなかった場合はエラーになります。
    /// </summary>
    /// <typeparam name="T">取得したいインスタンスの型。</typeparam>
    /// <returns>取得したインスタンスを返します。取得できなかった場合は null を返します。</returns>
    public static T GetInstance<T>() where T : class
    {
        var type = typeof(T);

        if (instances.ContainsKey(type))
        {
            return instances[type] as T;
        }

        Debug.LogError($"要求された型のインスタンスが登録されていません:{type.Name}");
        return null;
    }

    /// <summary>
    /// インスタンスを取得し、渡された引数に代入します。取得できなかった場合は null が入ります。
    /// </summary>
    /// <param name="instance">取得したインスタンスを入れる変数。</param>
    /// <typeparam name="T">取得したいインスタンスの型。</typeparam>
    /// <returns>取得が成功したら true を返します。</returns>
    public static bool TryGetInstance<T>(out T instance) where T : class
    {
        var type = typeof(T);

        instance = instances.ContainsKey(type) ? instances[type] as T : null;

        return instance != null;
    }
}

Dictionary<Type, object> instances が「格納場所」で、ここに GameManager などを詰めていきます。

サービスロケーターに格納するクラス(各種マネージャーなど)

お次は、GameManager などといった外部からアクセスしたいクラスを、サービスロケーターに登録する手順です。

using UnityEngine;

/// <summary>
/// マネージャークラス。
/// </summary>
public class GameManager : MonoBehaviour, IGameManager
{
    /// <summary>
    /// 外部から使いたいメソッド。
    /// </summary>
    public void DoSomething()
    {
        Debug.Log("I am GameManager!");
    }

    void Awake()
    {
        // サービスロケーターに自身を登録
        ServiceLocator.Register<IGameManager>(this);
    }

    void OnDestroy()
    {
        // サービスロケーターから自身の登録を解除
        ServiceLocator.Unregister<IGameManager>(this);
    }
}

/// <summary>
/// マネージャークラスのインターフェイス。
/// </summary>
public interface IGameManager
{
    /// <summary>
    /// 外部から使いたいメソッド。
    /// </summary>
    void DoSomething();
}

登録する型が GameManager ではなく IGameManager というインターフェイスになっていますが、こうすることで外部からアクセスできるメソッドを限定することができるので、なるべくインターフェイスを定義して実装するのをおすすめします。

この例だと DoSomething() しかないので恩恵を感じられませんが、実際にはいろいろなメソッドやプロパティが実装されるはずなので、アクセス可能な対象を限定しておくことは決して悪いことではありません。

外部からサービスロケーターにアクセスするクラス

最後に、サービスロケーターへアクセスする外部のクラスです。

using UnityEngine;

public class Foo : MonoBehaviour
{
    void Start()
    {
        var gameManager = ServiceLocator.GetInstance<IGameManager>();

        gameManager.DoSomething();
    }
}

ServiceLocator.GetInstance<IGameManager>() で、格納された GameManager を取得できます。取得できたら、あとは普通にメソッドを実行することができます✨

いちいち GetInstance でインスタンスを取得しないといけないのが面倒に感じるかもしれませんが、逆に言うと GetInstance が無い場合はマネージャークラスとは無関係だということが保証されるので、スクリプトの理解がしやすくなります。

なお、GameManager の登録は Awake() で行うので、他のクラスからは Start() 以降にアクセスする必要があります。

補遺

インスタンスを一つのみに制限する

さて、サービスロケーターを使うことで「どこからでもアクセスしたい」は実現できましたが、「一つだけ存在していてほしい」は実現できていません。

これは、「サービスロケーターへの登録時に、すでに同じ型のインスタンスが登録されていたら自身を破棄する」という処理を追加することで実現できます。

具体的にはこのように書きます。

public class GameManager : MonoBehaviour, IGameManager
{
    public void DoSomething()
    {
        Debug.Log("I am GameManager!");
    }

    void Awake()
    {
        // すでに IGameManager がサービスロケーターに登録されていたら、自身を破棄して終了
        if (ServiceLocator.IsRegistered<IGameManager>())
        {
            Destroy(gameObject);
            return;
        }

        ServiceLocator.Register<IGameManager>(this);
    }

    void OnDestroy()
    {
        ServiceLocator.Unregister<IGameManager>(this);
    }
}

GetInstance と TryGetInstance

今回紹介したスクリプトでは、 GetInstance でインスタンスを取得できなかった場合はエラーになるようにしています。

Unity でよく使われる GetComponent などは取得に失敗してもエラーにならないので、それに倣ったほうが良いという意見もあるとは思うのですが、自分のコーディングだと、

  • 基本的に GetComponent は必ずそのコンポーネントを取得できる想定の時しか使わない
  • 取得できなかった場合も処理を続行したい場合は TryGetComponent を使う

という理由から、今回はエラーになる仕様を採用しました。なお、TryGetComponent に相当するメソッドとして TryGetInstance を用意しています。

Awake と Start

記事中にも書きましたが、サービスロケーターへの登録は Awake() で行っているので、アクセスは Start() 以降でないとできません。

これが嫌だという方もいらっしゃるかもしれませんが、 Awake() の時点では他のオブジェクトの初期化が完了していない場合があるので、今回の例に限らず、そもそも Awake() 内で他のオブジェクトに働きかけるのは避けたほうが良いでしょう。

おわりに

サービスロケーターを使えば、依存関係の複雑化を回避しつつ、どこからでもアクセスできるクラスを作ることができます。サービスロケーターを使いこなして、シングルトンを駆逐していきましょう!

*1:そもそもこんなに「○○マネージャー」を作るの自体が良くない? それはそう🙂

*2:Dictionary 型が使われることが多い気がします。

The coloring of this site is Dracula PRO🧛🏻‍♂️
This website uses the FontAwesome icons licensed under CC BY 4.0.

2020 GIGA CREATION