はじめに
サムザップ #1 Advent Calendar 2019 の12/2の記事です。
株式会社サムザップの尾崎です。Unityエンジニアです。
内容
Unityでサービスロケーターの活用について紹介します。
サービスロケーターとは
サービスロケーターはプログラムを特定の実装に依存させずに動作させたいときに用いる実装手法の一つです。
柔軟性のあるプログラムを作成できます。
用語
- 本記事ではサービスをシステムと表現しています。
- システムはいろんなクラスから呼び出される共通的なプログラムのことを表しています。サブシステムや基盤と呼ばれることもあります。
例えばゲームでは外部リソースやログを扱うクラスなどが該当します。
背景
newでオブジェクトを生成したり、staticやシングルトンでアクセスすると特定のクラスに依存することになります。
その依存したクラス内で外部環境を扱う処理を行っているとプログラムの動作確認が大変になってくることがあります。
例えば1日に1回だけ挑戦できるステージがあり、セーブデータシステムにステージクリアを記録すると翌日まで再挑戦できない状況があるとします。
開発中はこうした状況でも何度でもステージに挑戦できるようにしておきたいものです。
セーブデータクラスのメソッド内でデバッグ用の分岐を書くこともできますが、クラスが大きくなると分岐が増えて分かりにくいコードになりやすいです。
そこでセーブデータシステムのためのインターフェース定義してクラスを複数作成します。
正規の処理を行うクラスとデバッグ用の処理を行うクラスです。
サービスロケーターはこのクラスへのアクセス方法を提供します。
(セーブデータをクリアしたり日付を進めるなどデバッグ方法はいくつかありますがここでは実装を複数用意するという方向で。)
そしてもう一つ、共通システムはいろんなクラスから簡単に呼び出せるようにしておきたいものです。
staticやシングルトンが使われることが多いですが、便利な反面1つのクラスに依存してしまうのと、グローバル変数と同じ問題があるので、できるだけ使用しないようにしています。ユニットテストの妨げにもなってしまいます。
サービスロケーターの詳細
機能
- システムのインターフェースに対するインスタンスを登録できます
- システムのインターフェースを指定してインスタンスを取得できます
- グローバルなアクセスを提供します
※ インターフェース以外にクラスでも大丈夫です
これらの機能によってシステム利用側のコードからシステムの具体的な実装とインスタンスの生成方法を分離します。
メリット
- 複数の実装を切り替えることができ柔軟性のあるプログラムになります
- 複数実装をのための分岐が初期化時の一箇所で済みます
- インスタンスへの容易なアクセス
- DIコンテナよりシンプルで高速
- モック実装に切り替えることでユニットテストできるようになります
デメリット
- ServiceLocatorクラスへの依存が増えます
- DIコンテナよりクラスの依存関係が分かりにくくなります。一般的に柔軟性を得るための方法としてはDIコンテナの方が推奨されています。
- シングルトンと同じようにインスタンスを単一にするためには別の対策が必要です
コード例
サービスロケーター本体
using System;
using System.Collections.Generic;
/// <summary>
/// サービスロケーター
/// • 型と単一インスタンスを登録
/// • 抽象型と具現型を登録 (取得時に都度インスタンス化)
/// • 型を指定して登録されているインスタンスを取得
/// </summary>
public static class Locator
{
/// <summary>
/// 単一インスタンス用ディクショナリー
/// </summary>
private static Dictionary<Type, object> _instanceDict = new Dictionary<Type, object>();
/// <summary>
/// 都度インスタンス生成用ディクショナリー
/// </summary>
private static Dictionary<Type, Type> _typeDict = new Dictionary<Type, Type>();
/// <summary>
/// 単一インスタンスを登録する
/// 呼び直すと上書き登録する
/// </summary>
/// <typeparam name="T">型</typeparam>
/// <param name="instance">インスタンス</param>
public static void Register<T>(T instance) where T : class
{
_instanceDict[typeof(T)] = instance;
}
/// <summary>
/// 型を登録する
/// このメソッドで登録するとResolveしたときに都度インスタンス生成する
/// </summary>
/// <typeparam name="TContract">抽象型</typeparam>
/// <typeparam name="TConcrete">具現型</typeparam>
public static void Register<TContract, TConcrete>() where TContract : class
{
_typeDict[typeof(TContract)] = typeof(TConcrete);
}
/// <summary>
/// 型を指定して登録されているインスタンスを取得する
/// </summary>
/// <typeparam name="T">型</typeparam>
/// <returns>インスタンス</returns>
public static T Resolve<T>() where T : class
{
T instance = default;
Type type = typeof(T);
if (_instanceDict.ContainsKey(type))
{
// 事前に生成された単一インスタンスを返す
instance = _instanceDict[type] as T;
return instance;
}
if (_typeDict.ContainsKey(type))
{
// インスタンスを生成して返す
instance = Activator.CreateInstance(_typeDict[type]) as T;
return instance;
}
if (instance == null)
{
Debug.LogWarning($"Locator: {typeof(T).Name} not found.");
}
return instance;
}
}
※ [Gist] ozaki-shinya/Locator.cs
システム (サービス)
/// <summary>
/// システムのインターフェース
/// </summary>
public interface ISomeSystem
{
void SomeMethod();
}
/// <summary>
/// 正式版のシステム
/// </summary>
public class SomeSystem : ISomeSystem
{
public void SomeMethod()
{
// 正式な処理
}
}
/// <summary>
/// デバッグ版のシステム
/// </summary>
public class DebugSomeSystem : ISomeSystem
{
public void SomeMethod()
{
// デバッグ用の処理
}
}
システムをサービスロケーターに登録 (プロジェクトの初期化)
#define DEBUG
using UnityEngine;
/// <summary>
/// プロジェクトの初期化
/// </summary>
public static class ProjectInitializer
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
// この変数を切り替えることで生成するインスタンス切り替えます
// 単純化のためクラス内の#defineで定義しています
// 実際にはScripting Define Symbolsや設定ファイルを読み込んだりして切り替えます
bool useDebugSystem;
#if DEBUG
useDebugSystem = false;
#endif
if (useDebugSystem)
{
// 正式な処理を行うインスタンスを登録
Locator.Register<ISomeSystem>(new SomeSystem());
}
else
{
// デバッグ用処理を行うインスタンスを登録
Locator.Register<ISomeSystem>(new DebugSomeSystem());
}
}
}
システムを利用
using UnityEngine;
public class SomeScene : MonoBehaviour
{
public void Start()
{
// システムの型を指定して登録されているインスタンスをServiceLocatorから取得
var system = Locator.Resolve<ISomeSystem>();
system.SomeMethod();
/* 以下、比較用 */
// newの場合
var system2 = new SomeSystem();
system2.SomeMethod();
// staticの場合
SomeSystem.SomeMethod();
// シングルトンの場合
SomeSystem.Instance.SomeMethod();
}
}
構成
ServiceLocator
サービスロケーター実装本体です
型とインスタンスをセットで登録します。
型を指定してインスタンスを取得します。
事前にインスタンス生成しておくパターンと都度インスタンス生成するパターンに対応しています。
事前インスタンス生成パターンはシングルトンの代替になります。
Resources.LoadでPrefabを取得してMonoBehaviourを登録することもできます。
SomeSystem
何らかのシステムです。
例. セーブデータ、マスターデータ、サウンド、チュートリアルなど
ファイルやプラットフォーム周りを扱うシステムが対象になりやすいです。
ProjectInitializer
使用するクラスを決めるところです。
RuntimeInitializeOnLoadMethod
によってどのシーンを実行しても最初に処理されます。Awakeより先に実行されます。
コード例ではインスタンスを生成して登録していてResolveされたときは単一インスタンスが使われます。
下記のようにするとResolveされる度にインスタンス生成されます。
Locator.Register<ISomeSystem, SomeSystem>();
SomeScene
サービスロケーターを使用してシステムを利用するところです。
比較のためnew、static、シングルトンのコードも配置しています。
活用例
アセットバンドルシステム
アセットバンドルをサーバーからロードする実装とローカルファイルをロードする実装を切り替えられるようにします。
システム利用側はアセットバンドルのロード先を意識せずに実装できます。
開発時はローカルファイルからロードすることでアセットバンドルビルドやサーバーから取得する手間をなくし効率良く開発できます。
AssetBundleManagerのSimulation ModeやAddressableのFast Modeと同じ機能です。
通信のモック化
サーバー通信する実装と、ローカルファイルをロードする実装(モック)を切り替えられるようにします。
システム利用側(画面など)はデータの取得先を意識せずに実装できます。
例えばデータをリスト表示する画面を開発する際、データが0件の場合やデータが大量の場合など様々なパターンのデータをローカルファイルとして作成し通信の代わりに取得するシステムを作ると画面のテストを効率よく行えます。
プラットフォームごとに異なる実装
iOSとAndroidなどプラットフォームによって実装が異なる機能を共通インターフェースで機能を提供します。
プラットフォームごとにクラスを作成します。
システム利用側はプラットフォームの違いを意識する必要がなくなり、システム実装側は1クラスにプラットフォームの分岐を多数記述する必要がなくなります。
その他活用例
- 負荷の高いシステムを無効化する
- システムにログを仕込む
補足
- サムザップではDependency Injectionコンテナ(Zenject)を採用しているプロジェクトもあります。Factoryで複数の実装を切り替えています。
ただ、Zenjectはパフォーマンスの懸念と、学習コストが高い面がありサービスロケーターを採用しているプロジェクトもあります。 - 一応シングルトンでも継承を利用することで複数の実装を切り替えることができます。
- 実装は1つで良いと割り切ってシンプルなstaticクラスを採用することもあります。用途に合わせて選択すると良いかと思います。
- サービスロケーターを使ってメリットが大きいクラスに使うと良いです。オブジェクトを引数で簡単に渡せる場合には渡した方が良いと思います。
最後に
明日は @tomeitou さんの記事です。