imog

主にUnityとかの事を書いています

Zenject Signals

本記事は 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のイベントを購読すれば解決するでしょう。この状態を図で示すと以下の通りです。

Untitled-2

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行追加すれば実装できるでしょう。この場合の依存の状態を図示すると次のような感じです。

Untitled-2

ちょっと雲行きが怪しくなってきました。ここからデバッグ用のクラスも作るとなるともう一つ依存が増えるので組み合わせ爆発がえらいことになりそうですね。一時停止可能なクラスが増えるたびに手を加えることになってしまいます。

Pub/Subメッセージングモデル

今回の要件において、PlayerGameInputManagerに依存すべきなのでしょうか。(プレイヤーなら他のキー入力もあるから必要だろ!というのは一旦置いといてください・・) 実際欲しいのは一時停止が起きたというイベントだけで、ユーザが一時停止ボタンを押したから止まったというGameInputManagerのコンテキストは特に求めていません。デバッグ時の停止も演出での停止も同様で、それらの何故発生したかという文脈は今回必要としていません。今回のような不特定多数が発行するイベントを不特定多数が購読するPub/Subの場合、直接依存関係を持つと拡張しづらいという問題が発生します。

なので、このようなPub/Subを実現するときは中央に仲介人(Broker)を構えることになります。

image

イベント発行側は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;
    // Signalを受け取るメソッド
    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
{
    // Zenjectが用意したBroker
    [Inject] private SignalBus _signalBus;

    private bool _isPause = false;
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            if (_isPause)
            {
                // PauseEventを発行する
                _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()
    {
        // SignalBusを利用する
        SignalBusInstaller.Install(Container);
        // Signalを定義する
        Container.DeclareSignal<PauseSignal>();
        Container.DeclareSignal<ResumeSignal>();
        // Signalを受け取った際の処理
        Container.BindSignal<PauseSignal>().ToMethod<Player>(p => p.OnPause).FromResolve();
        Container.BindSignal<ResumeSignal>().ToMethod<Player>(p => p.OnResume).FromResolve();
    }
}

基本的には以下の流れです。

  1. Signalを利用するためにSignalBusInstallerをインストールする
  2. 利用したいSignalをDeclareSignal<T>で定義する
  3. 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.SubscribeSignalBus.UnSubscribeで購読及び解除ができます。手動で破棄が必要なのでうっかり忘れないようにしましょう。

また、ZenjectはUniRxとの連携ができます。その場合、IObservableへの変換が可能です。

_signalBus.GetStream<PauseSignal>() // IObservable<PauseSignal>に変換
          .Subscribe(OnPause)
          .AddTo(gameObject);

UniRx使ってる場合はこっちの方が管理しやすいかもしれません。

Zenject Signalsの特徴

SignalsはDIしているという条件はありますが、割と細かく面白い機能がついてます。

Subscribeを必須にできる

シグナルを発行したときに購読者の有無で挙動を変えられます。

// イベント発行時に誰もSubscribeしていないなら例外を吐く
Container.DeclareSignal<PauseSignal>().RequireSubscriber()
// 誰もSubscribeしてなくても動作する(デフォルト設定)
Container.DeclareSignal<PauseSignal>().OptionalSubscriber()
// 誰もSubscribeしてない場合Warningを出す
Container.DeclareSignal<PauseSignal>().OptionalSubscriberWithWarning()

必ずSubscriberがいるなら積極的にRequireSubscriberにしておくと早期にエラー発見できて便利

定義してないSignalを発行したときの挙動

原則として、DeclareSignalで定義してないシグナルを発行することはできず、Fireを呼んだときに例外を吐きます。もし状況に応じて定義されるかわからないシグナルが存在する場合、例外を無視するTryFireを代わりに使うと良いでしょう。

// 例外を吐く
_signalBus.Fire<NoDeclareSignal>(); 
// 定義されていない場合Warningを吐いて無視する
_signalBus.TryFire<NoDeclareSignal>();

Signalにpriorityを設定できる

通常、同一フレーム内に複数のSignalが発行された場合、それらは順番に処理されていきます。

Container.DeclareSignal<FooSignal>();
Container.DeclareSignal<BarSignal>();
// Signalが来たらLogに吐く
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に優先順位を付けてさばくことができます。

// FooSignalの優先度は0
Container.DeclareSignal<FooSignal>().RunAsync().WithTickPriority(0);
// BarSignalの優先度は1(FooSignalより優先度が低い)
Container.DeclareSignal<BarSignal>().RunAsync().WithTickPriority(1);
// Signalが来たらLogに吐く
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のオプションたちは便利なものが多いので興味があれば調べてみてください。そしてブログを書いてください。僕が喜びます。