本記事は Unity Advent Calendar 2018 の 13日目 の記事です。
qiita.com
普段Zenjectを趣味で使っているのですが、Zenjectは基本的なDIContainer機能の他に、ちょっとDIから離れた便利機能をいくつか提供しています。MemoryPool
TestFrameWork
Reflection Baking
Signal
などです。今回はこれらの中で Signal
の使い方について解説していきます。
Pub/Subの話
Signalsの話をする前に、Signalsの考え方であるPub/Subについて軽く解説していきます。
メッセージング
ゲームはイベント駆動が多いので、状態の変更を通知したくなるときが結構あります。ゲーム開始したらキャラクタを動かし始めたいとか、時間切れを知らせてプレイヤーの操作を受け付けなくしたりとか。そんなとき、通知元と通知先のクラスが必ずしも強い関係性を持っているとは限らなかったりします。
一時停止を実装しよう
例えば、マリオのようなアクションゲームにおいて一時停止を実装したくなったときのことを考えてみましょう。一時停止の概要は次の通りです。
- 任意のボタンを押すと発動する
- 一時停止状態はプレイヤーの動きが止まる。操作も受け付けない
- 敵キャラも止まる
- 残り時間のカウントも止まる
- 一時停止が解除されると上記三つは再度動き始める
- 画面描画はそのまま。キャラが見えなくなったりはしない
アクションゲームを作るうえで割と必須な機能です。ヒエラルキーのオブジェクトたちを一気にSetActiveして解決したくなりますが、それだと描画も消えてしまうのでここはちゃんとコードで実装することにしましょう。
一時停止を実装するにあたって、なにはともあれ「今が停止しているのか?」という情報を監視して取ってくる必要があります。今回は雑にゲーム中の入力を管理する GameInputManager
と停止対象の Player
を作ってみましょう。
public class GameInputManager
{
public event Action Pause = delegate { };
public event Action Resume = delegate { };
private bool _isPause = false;
void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
if (_isPause)
{
Resume();
}
else
{
Pause();
}
_isPause = !_isPause;
}
}
}
public class Player : MonoBehaviour
{
[Inject] private GameInputManager _inputManager;
private bool _isPause;
void Start()
{
_inputManager.Pause += () => _isPause = true;
_inputManager.Resume += () => _isPause = false;
}
void Update()
{
if (!_isPause)
{
}
}
}
GameInputManager
は内部で任意の条件(今回はAが押されたかどうか)で停止と再開を切り替えます。外部のオブジェクトはイベントにコールバックを登録することでそれらを検知し、自身の状態を更新します。ひとまずはこれで一時停止達成です。実装は割愛しますが敵キャラEnemy
や時間管理クラスGameTimer
も同様にGameInputManager
のイベントを購読すれば解決するでしょう。この状態を図で示すと以下の通りです。
GameInputManager
のモテ期ですが、まだいける。
増えていくイベント
ところがこの一時停止するという概念、ボタンを押して停止したいという場合以外にも使いたくなってきたのでした。
- ゲーム開始時なども一定時間止めたい
- デバッグとして無条件にいつでも止めれるようにしたい
現在の一時停止は「ユーザが任意のタイミングで実行できる機能」ですが、今回追加される二つはゲーム側が自動で実行する機能だったり、開発者のみ使える機能だったりとタイミングもコンテキストも大きく異なります。これらを実装するとしたらどうすべきでしょうか。
一番手っ取り早いのは上記二つの実装をGameInputManager
に突っ込むことなのですが、これはそもそもユーザ入力でもないしクラスの責務が多くなってしまい最強のManagerクラスが爆誕してしまうでしょう。それはつらい。
素直に各クラスを作るのが一つの手でしょう。試しにゲーム開始時の一時停止を入れてみましょう。何秒止めるとかは割と演出の都合にもなりそうなのでGameDirector
とかそのあたりに書かれるかもしれません。
public class Player : MonoBehaviour
{
[Inject] private GameInputManager _inputManager;
[Inject] private GameDirector _director;
private bool _isPause;
void Start()
{
_inputManager.Pause += () => _isPause = true;
_inputManager.Resume += () => _isPause = false;
_director.Pause += () => _isPause = true;
_director.Resume += () => _isPause = false;
}
void Update()
{
if (!_isPause)
{
}
}
}
3行追加で済んだのでまだ傷は浅そうですね。EnemyもGameTimerも同様に3行追加すれば実装できるでしょう。この場合の依存の状態を図示すると次のような感じです。
ちょっと雲行きが怪しくなってきました。ここからデバッグ用のクラスも作るとなるともう一つ依存が増えるので組み合わせ爆発がえらいことになりそうですね。一時停止可能なクラスが増えるたびに手を加えることになってしまいます。
Pub/Subメッセージングモデル
今回の要件において、Player
はGameInputManager
に依存すべきなのでしょうか。(プレイヤーなら他のキー入力もあるから必要だろ!というのは一旦置いといてください・・)
実際欲しいのは一時停止が起きたというイベントだけで、ユーザが一時停止ボタンを押したから止まったというGameInputManager
のコンテキストは特に求めていません。デバッグ時の停止も演出での停止も同様で、それらの何故発生したかという文脈は今回必要としていません。今回のような不特定多数が発行するイベントを不特定多数が購読するPub/Subの場合、直接依存関係を持つと拡張しづらいという問題が発生します。
なので、このようなPub/Subを実現するときは中央に仲介人(Broker)を構えることになります。
イベント発行側はBrokerにメッセージを送る(Publish)して、購読側はBrokerからイベントを受け取る(Subscribe)仕組みです。この構成は購読側が発行側をまったく意識しなくてよくなるのでスケーラブルな点がメリットです。今後どれだけイベントの発行側が増えても購読側に影響はありません。
Unityにおいては、UniRxのMessageBrokerが有名です。
github.com
サッと導入できて非常に便利なのでおススメです。
Zenject Signal
前置きが長くなりましたが、ZenjectではSignal
という機能を提供しています。
Zenject/Signals.md at master · svermeulen/Zenject · GitHub
これはZenjectのDIを利用している場合に使えるPub/Sub機構です。
とりあえず書き換えてみる
最短ルートでサッと書いてみましょう。
Zenjectを利用するので、登場人物は全てZenject BindingなどでBindされているものとします。
購読側
public class PauseSignal { }
public class ResumeSignal { }
public class Player : MonoBehaviour
{
private bool _isPause;
public void OnPause(PauseSignal signal) => _isPause = true;
public void OnResume(ResumeSignal signal) => _isPause = false;
void Update()
{
if (!_isPause)
{
}
}
}
まず、今回からPlayerはイベントがどこから来るのかわからなくなります。なのでイベント自体をクラスにして明示的にしてあげます。今回は停止イベントをPauseSignal
、再開イベントをResumeSignal
としました。
PlayerはManagerなどの依存がなくなる代わりに、イベントの受け皿としてOnPause
OnResume
メソッドを定義してあげます。
発行側
public class GameInputManager
{
[Inject] private SignalBus _signalBus;
private bool _isPause = false;
void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
if (_isPause)
{
_signalBus.Fire<PauseSignal>();
}
else
{
_signalBus.Fire<ResumeSignal>();
}
_isPause = !_isPause;
}
}
}
変更点としては、eventがなくなり SignalBus
というものが現れます。これはZenjectが提供するBrokerで、このインスタンスを通してイベントを発行したり購読ができるようになります。コールバックを呼んでいた部分はFire<T>
で任意のイベントをSignalBusに発行する処理に変わります。
Installer
public class SignalInstaller : MonoInstaller
{
public override void InstallBindings()
{
SignalBusInstaller.Install(Container);
Container.DeclareSignal<PauseSignal>();
Container.DeclareSignal<ResumeSignal>();
Container.BindSignal<PauseSignal>().ToMethod<Player>(p => p.OnPause).FromResolve();
Container.BindSignal<ResumeSignal>().ToMethod<Player>(p => p.OnResume).FromResolve();
}
}
基本的には以下の流れです。
- Signalを利用するために
SignalBusInstaller
をインストールする
- 利用したいSignalを
DeclareSignal<T>
で定義する
BindSignal<T>().ToMethod<T2>
でSignalを受け取った場合のT2
の処理を定義
1,2は基本的に毎回同じです。3は場合によって異なりますが、だいたいはメソッドを呼んでそのままSignalを渡すことになるでしょう。また、今回はPlayerがBind済みなので FromResolve()
でBind済みのPlayerに流すようにしています。これにより、発行側がSignalBusにFireしてくれればPlayerは全て受け取ることができるようになりました。
このままEnemyもGameTimerもBindSignal.ToMethodすればいいのですが、それだと結局何回も定義することになり面倒です。せっかくZenjectを使っているのでインタフェースでまとめてあげるとだいぶ楽ができます。
public interface IPauseable
{
void OnPause(PauseSignal signal);
void OnResume(ResumeSignal signal);
}
BindSignalは以下に書き換えます。
Container.BindSignal<PauseSignal>().ToMethod<IPauseable>(p => p.OnPause).FromResolveAll();
Container.BindSignal<ResumeSignal>().ToMethod<IPauseable>(p => p.OnResume).FromResolveAll();
FromResolveAll
は任意の型のバインドオブジェクト全てを取ってくるので、PlayerやEnemyにIPauseable
を実装してあげれば自動的にSignalを受け取れるようになります。
動的な購読
先ほどの例は最初からバインドされている状態なので、動的に生成されるインスタンスには適用できません。その場合はSignalBusを受け取って手動で購読する必要があります。
public class DynamicSpawnEnemy : MonoBehaviour
{
private SignalBus _signalBus;
[Inject]
void Initialize(SignalBus signalBus)
{
_signalBus = signalBus;
_signalBus.Subscribe<PauseSignal>(OnPause);
}
void OnDestroy()
{
_signalBus.UnSubscribe<PauseSignal>(OnPause);
}
private void OnPause(PauseSignal signal) => print("Pause!!");
}
SignalBus.Subscribe
、SignalBus.UnSubscribe
で購読及び解除ができます。手動で破棄が必要なのでうっかり忘れないようにしましょう。
また、ZenjectはUniRxとの連携ができます。その場合、IObservable
への変換が可能です。
_signalBus.GetStream<PauseSignal>()
.Subscribe(OnPause)
.AddTo(gameObject);
UniRx使ってる場合はこっちの方が管理しやすいかもしれません。
Zenject Signalsの特徴
SignalsはDIしているという条件はありますが、割と細かく面白い機能がついてます。
Subscribeを必須にできる
シグナルを発行したときに購読者の有無で挙動を変えられます。
Container.DeclareSignal<PauseSignal>().RequireSubscriber()
Container.DeclareSignal<PauseSignal>().OptionalSubscriber()
Container.DeclareSignal<PauseSignal>().OptionalSubscriberWithWarning()
必ずSubscriberがいるなら積極的にRequireSubscriber
にしておくと早期にエラー発見できて便利
定義してないSignalを発行したときの挙動
原則として、DeclareSignal
で定義してないシグナルを発行することはできず、Fire
を呼んだときに例外を吐きます。もし状況に応じて定義されるかわからないシグナルが存在する場合、例外を無視するTryFire
を代わりに使うと良いでしょう。
_signalBus.Fire<NoDeclareSignal>();
_signalBus.TryFire<NoDeclareSignal>();
Signalにpriorityを設定できる
通常、同一フレーム内に複数のSignalが発行された場合、それらは順番に処理されていきます。
Container.DeclareSignal<FooSignal>();
Container.DeclareSignal<BarSignal>();
Container.BindSignal<FooSignal>().ToMethod(_ => Debug.Log("foo"));
Container.BindSignal<BarSignal>().ToMethod(_ => Debug.Log("bar"));
var bus = Container.Resolve<SignalBus>();
bus.Fire<FooSignal>();
bus.Fire<BarSignal>();
bus.Fire<FooSignal>();
bus.Fire<BarSignal>();
実行結果は以下です。
foo
bar
foo
bar
これらは同期的に処理されるため、Fireされた順番にログが吐かれています。Zenject Signalsはこの処理を非同期に、且つSignalに優先順位を付けてさばくことができます。
Container.DeclareSignal<FooSignal>().RunAsync().WithTickPriority(0);
Container.DeclareSignal<BarSignal>().RunAsync().WithTickPriority(1);
Container.BindSignal<FooSignal>().ToMethod(_ => Debug.Log("foo"));
Container.BindSignal<BarSignal>().ToMethod(_ => Debug.Log("bar"));
var bus = Container.Resolve<SignalBus>();
bus.Fire<FooSignal>();
bus.Fire<BarSignal>();
bus.Fire<FooSignal>();
bus.Fire<BarSignal>();
結果は以下の通りです。
foo
foo
bar
bar
WithTickPriorityで設定したとおり先にFooSignalが処理されています。これは個人的には面白い機能だと思っていて、必ず実行順が固定されなければならない場合に効果を発揮します。例えばキャラが死んだイベントと、それとは別でキャラにアクセスするようなイベントが同時に発生したとき、実行順番によってはすでにDestroyしたインスタンスにアクセスしてエラーでちゃうなんてことがたまーーーにあったりして、0.1msずらしDestroyしたりとかyield return nullしたりRxでNextFrame.Subscribeしたりしてお茶を濁すようなことをしなくて済みます。
Signalsのメリット
個人的にはスコープが狭まることが大きいかなと考えてます。
Pub/Subのメリットは先に述べたとおりですが、一方で疎結合故に「どこからイベントが発行されるのか把握しにくい」という問題もあります。最初は問題ないけど、プロダクトの規模が大きくなるにつれあらゆる個所からイベントが発生する可能性を考慮することになります。とある1画面を改修して問題ないと思ったら、全く想定していないバックエンドで動いていた何かから突然イベントが飛んできて謎・・・みたいなことになりがちです。且つ、便利故に「データの渡し方に困ったらとりあえずBrokerに送ってSubscribeでいいか」みたいな判断を取ってしまう危険性もあります。実質グローバルな何かに依存しているみたいな状況です。
Zenject Signalsの良いところは影響範囲が同一のDIContainerだけだという点です。Containerに登録されているSignalしか発行できず、Containerはシーン単位、またはサブコンテナなど細かい粒度で構築できるため、遠いところからSignalを発行するのは難易度が高いです。この制限のおかげで安心して使っていけるのかなと思います。
もちろん、ProjectContextにSignalを定義してしまえば超便利になる代わりに完全にグローバルになって問題が再燃してしまうので使いどころはよく見極めましょう。
最後に
Zenject Signalは色々条件はありますが割と面白い機能です。Zenjectを使っていて通知周りに困っていたらぜひ使ってみてください。
それ以外にもZenjectのオプションたちは便利なものが多いので興味があれば調べてみてください。そしてブログを書いてください。僕が喜びます。