Event Bus

ゲームシステム間の、型安全で同期的な publish/subscribe。

ゲームシステム間の型安全な publish/subscribe · 同期・メインスレッド専用 · SubscribeIDisposable トークンを返す · no-op 差し替え: NullEventBus

概要

Event Bus は、互いを知らないシステム同士の対話を可能にします。パブリッシャーが bus.Publish(new PlayerDamagedEvent { ... }) を呼ぶと、bus.Subscribe<PlayerDamagedEvent>(handler) で登録されたすべてのハンドラーが、購読した順に同期的にそれを受け取ります。イベント型はコンパイル時にマッチングされます — リフレクションなし、動的検索なし — ので、バスは IL2CPP(AOT コンパイル)ビルドでも安全に動作します。Input サービス、シーン遷移、UI パネルスタック、そしてあなた自身のゲームコードの間をつなぐ配管です。

簡単な使用例

イベントクラスを定義し、OnEnable で購読、OnDisable で購読解除します:

using System;
using CommonGameSystem.Core;
using UnityEngine;

// Any plain class works as an event.
public class PlayerDamagedEvent
{
    public int Amount;
}

[DefaultExecutionOrder(100)]
public class DamageUI : MonoBehaviour
{
    private IEventBus _bus;
    private IDisposable _subscription;

    private void Awake()
    {
        _bus = ServiceLocator.Resolve<IEventBus>();  // Cache it once
    }

    private void OnEnable()
    {
        _subscription = _bus.Subscribe<PlayerDamagedEvent>(OnPlayerDamaged);
    }

    private void OnDisable()
    {
        _subscription?.Dispose();
    }

    private void OnPlayerDamaged(PlayerDamagedEvent ev)
    {
        Debug.Log($"Damage: {ev.Amount}");
        // Update UI, play a sound, etc.
    }
}

発行は、メインスレッド上ならどこからでも 1 行です:

_bus.Publish(new PlayerDamagedEvent { Amount = 12 });

1 つのスコープ内だけで生きるべきハンドラーには、using がトークンを自動的に破棄します:

private void RunScopedListener()
{
    using var token = _bus.Subscribe<PlayerDamagedEvent>(ev => Debug.Log(ev.Amount));
    // The handler is active here...
}   // ...and unsubscribed automatically when the scope ends.

API の全体像

IEventBus インターフェース

  • IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : classTEvent のイベントに対するハンドラーを登録します。Dispose() が購読解除となるトークンを返します — 二重に破棄しても安全で、2 回目は何もしません。イベント型は参照型(class または record)でなければなりません。handler が null なら ArgumentNullException をスローします。

  • void Publish<TEvent>(TEvent ev) where TEvent : class 正確な型 TEvent に登録されたすべてのハンドラーを、購読順(先に購読したものから先に呼ばれる)に同期的に呼び出します。ハンドラーが 1 つもなければ、呼び出しは静かに戻ります。イベントが null なら ArgumentNullException をスローします。ハンドラーがスローしても捕捉されてログに記録され、残りのハンドラーは引き続き実行されます。

EventBusOptions によるチューニング(オプション)

デフォルトのバスは Bootstrap がデフォルトオプションで登録します。自分でバスを構築する場合(テスト用や、隔離されたサブバス)は、チューニングできます:

var bus = new DefaultEventBus(
    EventBusOptions.Default.With(
        initialListCapacity: 8,
        warnSubscribersPerType: 100));
  • WarnSubscribersPerType(デフォルト 50): エディタ専用のしきい値。あるイベント型の購読者数がこれを超えると、そのイベント型につき 1 回警告をログに出します。たいていは購読リークの兆候です。
  • WarnPublishDepth(デフォルト 8): エディタ専用のしきい値。publish 呼び出しのネストがこれより深くなると(ハンドラーが別のイベントを発行し、それがまた…という場合)一度警告します。publish のコールスタックが完全に巻き戻ると、警告は再アームされます。
  • InitialListCapacity(デフォルト 4): メモリチューニング — 型ごとの購読者リストの初期サイズ。

EventBusOptions はイミュータブルな struct です: EventBusOptions.Default から始めて、.With(...) で個別の値を上書きします。

EventBusDiagnostics(エディタ専用)

エディタビルドでのみ利用可能で、バスの状態を調べられます:

#if UNITY_EDITOR
int count = EventBusDiagnostics.SubscribeCount<MyEvent>(_bus);
int depth = EventBusDiagnostics.PeekPublishDepth(_bus);
long warnings = EventBusDiagnostics.WarningsEmitted;
#endif

オフにする

ServiceLocator.Replace<IEventBus>(new NullEventBus());

NullEventBus はすべての publish を静かに破棄し、Subscribe からは無害な no-op トークンを返します。呼び出し側のコードを一切変えずに、テストや特殊ビルドでイベントルーティングを無効化するのに使ってください。他のサービス(Input、UI、Scene Flow)は動作し続けます — 単にイベントを受け取らなくなるだけです。ログなし、副作用なしです。

挙動とエッジケース

  • メインスレッド専用。 すべての SubscribePublish の呼び出しは、Unity のメインスレッドから行う必要があります。Debug ビルドはこれをアサートします。Release ビルドは速度のためチェックを省くので、そこでのバックグラウンドスレッド呼び出しは状態を破壊し得ます — 決して行わないでください。ワーカースレッドから発行するには、まず作業をメインスレッドへキューイングします(Scheduler サービスには、まさにこのためのメインスレッド実行ディスパッチがあります)。

  • ディスパッチはスナップショット上で実行されます。Publish は、publish 開始時点に取得した購読者リストのスナップショットを反復します。そのため、ハンドラーがディスパッチ中に購読・購読解除しても — 自分自身であっても — 安全です: 変更は進行中の publish ではなく、の publish から効きます。スナップショットは publish ごとの小さなアロケーションです。極端に高い発行頻度と多数の購読者が重なるとガベージコレクター圧として現れるため、非常におしゃべりな更新は可能なら束ねてください。

  • バスはキャッシュし、毎回 Resolve しないでください。 Resolve<IEventBus>() は辞書検索です。Update で呼ぶと毎フレームそのコストを支払います。AwakeStart で一度取得し、参照を保持してください。

  • イベント型は class または record であって、struct ではありません。これによりディスパッチが単純に保たれ、ボクシングのアロケーションを回避できます。

  • 継承ベースのディスパッチはありません。 基底イベント型を購読しても、派生イベント型は受け取れません。Publish<DerivedEvent> のとき、Subscribe<BaseEvent> のハンドラーは呼ばれません。発行する型と正確に同じ型を購読してください。

  • 購読トークンは破棄してください。 Dispose() を忘れると、ハンドラーは永久に登録されたままです — メモリリークであり、購読しているオブジェクトも生かし続けます。OnDisable/OnDestroy で購読解除するか、スコープ付きハンドラーには using を使ってください。

  • ハンドラーの例外はディスパッチを壊しません。 あるハンドラーがスローしても、例外はログに記録され、次のハンドラーは引き続き実行されます。あなたの publish 呼び出しは常に正常に完了します。

  • カスタムイベントクラスは IL2CPP で link.xml が必要です。 IL2CPP でビルドする場合、ビルドのコードストリッピングに除去されないよう、イベントクラスをプロジェクトの link.xml に追加してください:

    <assembly fullname="YourNamespace" preserve="all">
        <type fullname="YourNamespace.PlayerDamagedEvent" preserve="all"/>
    </assembly>
    

関連ページ

  • Deferred Event Queue — イベントを今キューに積み、後でフラッシュ(このバスの非同期の兄弟)
  • Configuration — このバスを通じて ConfigurationChanged<TGroup> イベントを発行
  • Scheduler — ワーカースレッドから発行するためのメインスレッド実行ディスパッチ
  • Bootstrap — 起動順序
  • Service Locator — バスの解決とキャッシュ