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>
</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></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></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></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>
</summary>
<param name="instance"></param>
<typeparam name="T"></typeparam>
<returns></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()
{
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()
内で他のオブジェクトに働きかけるのは避けたほうが良いでしょう。
おわりに
サービスロケーターを使えば、依存関係の複雑化を回避しつつ、どこからでもアクセスできるクラスを作ることができます。サービスロケーターを使いこなして、シングルトンを駆逐していきましょう!