はじめに
そろそろ仕事でUnityを扱いたい気分の@yoship1639です。
Unityに特化したReactiveExensionであるUniRx、開発現場でも浸透していることかと思います。
オブザーバパターンはよく独自実装で利用していたのですが、実は最近までUniRx(というよりRx)を触る機会がありませんでした。大分出遅れてしまいましたが、ようやく色々触り始めてその力を感じているところです。
そのため稚拙ではありますが、Unityオブジェクト同士の疎結合化の仕組み+αであるを考えてみたので記事にしてみたいと思います。
今回考えたのはサブジェクトプール(以降SubjectPool)というもので、Subjectを一元管理することでイベントの発行・購読のみにオブジェクトが集中することができ、発行者であるSubjectと購読者であるObservableの疎結合化を図るというものです。
疎結合化のメリットは説明しなくてもわかる通り、追加実装やモジュール再利用の容易化などを実現するので、できる限りそのようにするべきですね。
また、SubjectPoolのさらなる恩恵として新たにゲームオブジェクトが追加された時、そのゲームオブジェクトが持つSubjectをSubjectPoolが監視することができ、あらゆるゲームオブジェクトにイベント発行者の追加を通知させることができます。この仕組みが意外とメインかもしれません。
(知っている方は知っているMessageBrokerに似たものですが、SubjectPoolの方がより柔軟な仕組みとなっています。)
この説明だけだとわかりづらいと思うので、次のセクションで分かりやすく説明したいと思います。
SubjectPool
SubjectPoolのメリットを簡潔に言うと、ゲームオブジェクトやコンポーネント同士の依存度をかなり下げ、無駄な依存・包含を不要にし、UniRxの機能のみを考えることができます。ゲーム開発はゲームオブジェクトやコンポーネントの依存度が高くなりがちなので、これを下げるということはオブジェクト本来の機能のみに集中することができるということです。
簡単な例で説明します。
一定時間ごとにイベントを発行するTimerと、そのタイマーを購読し出力するTimerLoggerを考えます。
(Timerという名前は名前が被りがちであまりよくありませんが今回はわかりやすくTimerにします)
UniRxのみの場合
まずは、純粋なUniRxで上記を作るとこのようになると思います。
public class Timer : MonoBehaviour
{
[SerializeField] private int count = 100;
[SerializeField] private float interval = 1.0f;
private Subject<int> timer = new Subject<int>();
public IObservable<int> OnTimeChanged
{
get { return timer; }
}
void Start()
{
StartCoroutine(TimerCoroutine());
}
IEnumerator TimerCoroutine()
{
var c = count;
while (c > 0)
{
timer.OnNext(c);
yield return new WaitForSeconds(interval);
c--;
}
timer.OnCompleted();
Destroy(this);
}
}
public class TimerLogger : MonoBehaviour
{
[SerializeField] private Timer timer;
void Start()
{
timer.OnTimeChanged.Subscribe(x =>
{
Debug.Log(x);
});
}
}
Timerが一定時間で発行するイベントをTimerLoggerが購読しデバッグ出力していますね。一見何も問題ないように思えます。
しかし、実は些細な問題があります。「TimerLoggerがTimerに依存している」です。
当たり前すぎて何を言っているのかわからないと思いますが、この依存関係は意外と厄介な問題を後に引き起こします。
「TimerのほかにStopwatchも必要になった、まだ時間関連のSubjectが追加されるかもしれない」
この時考えられる対処は以下のいずれかです。さて何番が正解でしょうか。
- StopwatchLoggerを追加する。さらにSubjectが増えそうならLoggerを管理するLoggerManagerを追加しFacadeでまとめる
- TimerLoggerを拡張しStopwatchLoggerとしてもを扱えるようにする、さらに増えそうなら時間関連Subjectを取り扱うTimeLoggerにする
- Timerを拡張し、Stopwatch機能を追加する。さらに増えそうならTimerを時間関連を取り扱うTimeにする
プログラミングにおいて正解という概念はありませんが、この中なら間違いなく1がベストです。クラスで取り扱う処理は1つにするのがベターです(小規模なので、?となるかもしれませんが機能の規模を考えずに今回は手段として考えてください)。2はTimerLoggerを追加修正するという処理が発生し、機能追加ごとにその処理が発生するのであまりおすすめしません。3もダメです。1の選択はかなり有効で、機能追加が発生しても新たなLoggerを追加し、マネージャがLoggerを管理するという機構ができているのでおススメです。
それでも、オブジェクトの包含依存関係があるので、さらなる機能拡張が発生する場合に一歩間違えてしまうとたちまちカオスに化けてしまう可能性があります。
他にも問題があり、ゲーム中にログ出力すべきオブジェクトが追加された時、どのようにLoggerに追加するかという問題があります。純粋に考えられるのはマネージャがオブジェクトの追加を検知しLoggerとして追加するというものです。
しかし、この選択は正しいのですが、マネージャにFacadeとListenerと動的に追加されたオブジェクトの管理が求められます。これを回避するには別のマネージャを作るかどうするか・・・
上記はゲーム制作でよく表れる現象かと思います。依存関係があるというのは上記の問題がいずれ発生するということです。
設計がしっかりしていれば特に問題にはなりませんが、最初から完全に設計するというのは難しいものです。。。
(下図は本来ILoggerを実装すべきですが面倒なので省いています)
UniRxはオブザーバであり、オブジェクト同士の依存関係がなくなるわけではありません。
そこで、UniRxにSubjectPoolを追加し本領を発揮させてみたいと思います。
UniRx + SubjectPoolの場合
オブジェクト同士の依存関係によりいずれ発生する問題に対処するには、オブジェクトを疎結合化させるしかありません。
TimerLoggerがTimerに依存しているという問題を解決するには、TimerLoggerがTimerではなく一定時間ごとに発生するイベントのみを知る必要があります。
そこで、Subjectを一元管理するSubjectPoolを考えます。SubjectPoolはSingleton(SubjectPoolを複数扱いたい場合は通常クラス)で実装するUniRxのSubjectをマップ管理する機構で、以下の実装となります。
using System;
using System.Collections.Generic;
using UniRx;
public class SubjectPool : SingletonMonoBehaviour<SubjectPool>
{
private Dictionary<string, object> subjects = new Dictionary<string, object>();
public ISubject<T> AddSubject<T>(string key)
{
var subject = new Subject<T>();
subjects.Add(key, subject);
return subject;
}
public IObservable<T> FindObservable<T>(string key)
{
object obj;
if (subjects.TryGetValue(key, out obj))
{
return obj as IObservable<T>;
}
return null;
}
public bool RemoveSubject(string key)
{
return subjects.Remove(key);
}
}
非常にシンプルなプールです。SubjectPoolにはSubjectの作成、検索、削除があるだけです。
次に、SubjectPoolをMonoBehaivourから簡単に利用できるようにするために、Extensionを記述します。
using System;
using UnityEngine;
using UniRx;
public static class SubjectPoolExtensions
{
public static ISubject<T> CreateSubject<T>(this Component component, string key, bool isBroadcast = true)
{
var subject = SubjectPool.Instance.CreateSubject<T>(key);
if (isBroadcast)
{
foreach (var com in UnityEngine.Object.FindObjectsOfType<Component>())
{
var listener = com as IObservableAddedListener<T>;
if (listener != null) listener.OnObservableAdded(component, key, subject);
}
}
return subject;
}
public static IObservable<T> FindObservable<T>(this Component component, string key)
{
return SubjectPool.Instance.FindSubject<T>(key);
}
public static bool RemoveSubject(this Component component, string key)
{
return SubjectPool.Instance.RemoveSubject(key);
}
}
using UnityEngine;
using System;
public interface IObservableAddedListener<T>
{
void OnObservableAdded(Component sender, string key, IObservable<T> observable);
}
これで、拡張メソッドからSubjectの作成、検索、削除ができるようになりました。
これだけだと、で?って感じなので、先ほどのTimerの例に当てはめてみます。
using UnityEngine;
using UniRx;
using System.Collections;
using System;
public class Timer : MonoBehaviour
{
[SerializeField] private string timerKey = "timer";
[SerializeField] private int count = 100;
[SerializeField] private float interval = 1.0f;
private ISubject<int> timer;
void Awake()
{
// SubjectPoolに追加
timer = this.CreateSubject<int>(timerKey);
}
void Start()
{
StartCoroutine(TimerCoroutine());
}
IEnumerator TimerCoroutine()
{
var c = count;
while (c > 0)
{
timer.OnNext(c);
yield return new WaitForSeconds(interval);
c--;
}
timer.OnCompleted();
this.RemoveSubject(timerKey);
Destroy(this);
}
}
using UnityEngine;
using UniRx;
using System.Collections;
using System;
public class TimerLogger : MonoBehaviour
{
[SerializeField] private string timerKey = "timer";
void Start()
{
this.FindObservable<int>(timerKey).Subscribe(x =>
{
Debug.Log(x);
});
}
}
見ていただければ分かりますが、TimerLoggerはTimerを知りません。時間が経過したときに発行されるイベントをkeyのみでバインドしています。言わなくても分かるように、最小限の参照による疎結合化を実現しています。
さらに、SubjectPoolExtensionsで記述したbroadcast部分は、IObservableAddedListenerを実装したオブジェクトすべてにSubjectの追加を通知します。これで、動的にイベントを発行するオブジェクトが現れても、そのオブジェクトが追加されたタイミングで別のオブジェクトが購読する事が出来ます。
TimerLoggerをIObservableAddedListenerバージョンで書くとこうなります。
using UnityEngine;
using UniRx;
using System;
public class TimerLogger : MonoBehaviour, IObservableAddedListener<int>
{
[SerializeField] private string timerKey = "timer";
public void OnObservableAdded(Component sender, string key, IObservable<int> observable)
{
if (key == timerKey)
{
observable.Subscribe(x =>
{
Debug.Log(x);
});
}
}
}
これで動的にタイマーが追加されても追加を感知し購読することができるようになりました。この仕組みがあるとあらゆるSubjectの追加を感知することができ、追加されたSubjectを購読し煮るなり焼くなりすることができるようになります。
また、SubjectPoolにより先ほどの動的オブジェクトがうんたらかんたらはこうなります。
あのごちゃっとした依存関係がこうなりました。自分がするべき処理のみに専念できる状態です。
SubjectPoolの例
もう少し、SubjectPoolを使うと依存関係がどうなるかを示したいと思います。
プレイヤーに追加したプレイヤーコントローラ同士の依存関係
移動、ジャンプ、攻撃ができるプレイヤー。移動速度に応じてジャンプ距離、攻撃力が変化、ダメージを受けると体力が減る
音ゲーの依存関係
タップでシャンシャン♪する
依存が無くなるだけでこれだけ変わります。
Service LocatorとDependency Injectionをちょこっと
疎結合周りなので多分この辺りの話も関係してくるかなぁと思ったので一応軽く。
Zenjectについて理解のある方は、今回紹介したやり方はService LocatorとDependency Injectionの両方を実現しているものだと認識できるかと思います。(Service LocatorパターンはFindObservableのやり方。Dependency InjectionはOnObservableAddedのやり方)。参照のバインドの仕方は、自ら参照先を探すのと、外部から注入されるの2択しかありません。stringをキーにしているだけなので、DI Containerも容易です。
キーをstringからインタフェースに変えるのも、型に変えるのも自由です。自分に合ったやり方に改造してみてください。
Zenject, Serivce Locator, Dependency Injection, DI Containerがよく分からない方は下記を参考にしてください。
終わりに
今回記述したSubjectPoolは最低限の機能しか実装していません。SubjectPoolはSubjectすべてを管理しているので、ほかにもSubjectに関する様々な機能が追加できます。(配列を扱えるようにするとか、Profile追加とか)
シングルトンで一元化するのはSubjectPoolに強い依存をもたらすので良くないかもしれません。このあたりは、個々の判断にお任せいたします。
また、SubjectだけじゃなくReactivePropertyを集約するReactivePropertyPoolというのを作ってもいいかもしれません。(購読可能なグローバル変数みたいな感じになるのであまりお勧めしませんが。。。)
UniRxは初心者なのでもしかしたら認識が間違っている可能性もあるので、その時はご指摘いただければ幸いです。
最後まで読んでいただきありがとうございました。