メインコンテンツまでスキップ

実行順序とベストプラクティス

GameEventがどのようにコールバックを実行し、イベントフローを管理するかを理解することは、信頼性が高くパフォーマンスの良いイベント駆動型システムを構築するために不可欠です。このガイドでは、実行順序、一般的なパターン、注意点、および最適化戦略について説明します。


🎯 実行順序

ビジュアルタイムライン

myEvent.Raise() が呼び出されると、実行は以下の厳密な順序に従います:

myEvent.Raise() 🚀

├── 1️⃣ 基本リスナー (FIFO順: 先入れ先出し)
│ │
│ ├─► OnUpdate() 📝
│ │ ✓ 実行済み
│ │
│ └─► OnRender() 🎨
│ ✓ 実行済み

├── 2️⃣ 優先度付きリスナー (高 → 低)
│ │
│ ├─► [Priority 100] Critical() ⚡
│ │ ✓ 最初に実行
│ │
│ ├─► [Priority 50] Normal() 📊
│ │ ✓ 二番目に実行
│ │
│ └─► [Priority 0] LowPriority() 📌
│ ✓ 最後に実行

├── 3️⃣ 条件付きリスナー (優先度 + 条件)
│ │
│ └─► [Priority 10] IfHealthLow() 💊
│ │
│ ├─► 条件チェック: health < 20?
│ │ ├─► ✅ True → リスナーを実行
│ │ └─► ❌ False → リスナーをスキップ
│ │
│ └─► (次の条件付きリスナーをチェック...)

├── 4️⃣ 常駐リスナー (シーンを跨ぐ)
│ │
│ └─► GlobalLogger() 📋
│ ✓ 常に実行 (DontDestroyOnLoad)

├── 5️⃣ トリガーイベント (並列 - ファンアウト) 🌟
│ │
│ ├─────► lightOnEvent.Raise() 💡
│ │ (独立して実行)
│ │
│ ├─────► soundEvent.Raise() 🔊
│ │ (独立して実行)
│ │
│ └─────► particleEvent.Raise() ✨
│ (独立して実行)

│ ⚠️ 1つが失敗しても、他は実行されます

└── 6️⃣ チェーンイベント (直列 - 厳格な順序) 🔗

└─► fadeOutEvent.Raise() 🌑
✓ 成功

├─► ⏱️ 待機 (期間/遅延)

└─► loadSceneEvent.Raise() 🗺️
✓ 成功

├─► ⏱️ 待機 (期間/遅延)

└─► fadeInEvent.Raise() 🌕
✓ 成功

🛑 いずれかのステップが失敗 → チェーン停止

実行特性

ステージパターンタイミング失敗時の挙動ユースケース
基本リスナー直列同フレーム、同期次へ進む標準的なコールバック
優先度付きリスナー直列 (ソート済み)同フレーム、同期次へ進む順序指定が必要な処理
条件付きリスナー直列 (フィルタ済み)同フレーム、同期Falseならスキップし次へ状態依存のロジック
常駐リスナー直列同フレーム、同期次へ進むシーンを跨ぐシステム
トリガーイベント並列同フレーム、独立他には影響しない副作用、通知
チェーンイベント直列複数フレーム、ブロッキングチェーン停止カットシーン、シーケンス

主な違いの解説

特性:

  • 現在のフレームで 同期的に 実行される
  • 定義された順序で次々と実行される
  • 各リスナーは独立している
  • 1つのリスナーで失敗(例外)が発生しても、他は停止しない

例:

healthEvent.AddListener(UpdateUI);           // 1番目に実行
healthEvent.AddPriorityListener(SaveGame, 100); // 2番目に実行 (高優先度)
healthEvent.AddConditionalListener(ShowWarning,
health => health < 20); // 3番目に実行 (条件がTrueの場合)

healthEvent.Raise(15f);
// 順序: SaveGame() → UpdateUI() → ShowWarning() (health < 20 の場合)

タイムライン:

🖼️ Frame 1024
🚀 healthEvent.Raise(15.0f)

├─► 💾 SaveGame() ⏱️ 0.1ms
├─► 🖥️ UpdateUI() ⏱️ 0.3ms
└─► ⚠️ ShowWarning() ⏱️ 0.2ms

📊 合計コスト: 0.6ms | ⚡ ステータス: 同期 (同フレーム)

💡 ベストプラクティス

1. リスナーの管理

必ず購読を解除する

メモリリークは、イベントシステムにおける最大の問題です。必ずリスナーをクリーンアップしてください。

public class PlayerController : MonoBehaviour
{
[GameEventDropdown] public GameEvent onPlayerDeath;

void Start()
{
onPlayerDeath.AddListener(HandleDeath);
}

// オブジェクトが破棄されても、リスナーがメモリに残る!
// これはメモリリークや、潜在的なクラッシュの原因になります
}

OnEnable/OnDisable パターンの使用

Unityでは、OnEnable/OnDisable パターンが推奨されるアプローチです。

public class HealthUI : MonoBehaviour
{
[GameEventDropdown] public GameEvent<float> healthChangedEvent;

void OnEnable()
{
// アクティブ時に購読
healthChangedEvent.AddListener(OnHealthChanged);
}

void OnDisable()
{
// 非アクティブ時に購読解除
healthChangedEvent.RemoveListener(OnHealthChanged);
}

void OnHealthChanged(float newHealth)
{
// UIを更新
}
}

メリット:

  • オブジェクトが無効化/破棄された際に自動的にクリーンアップされる
  • リスナーが必要な時だけアクティブになる
  • 重複した購読を防げる
  • オブジェクトプーリングに対応しやすい

2. スケジュール(予約実行)の管理

キャンセルのためにハンドルを保存する

後でキャンセルする必要がある場合は、必ず ScheduleHandle を保存してください。

public class PoisonEffect : MonoBehaviour
{
void ApplyPoison()
{
// 後でこれをキャンセルできない!
poisonEvent.RaiseRepeating(damagePerTick, 1f, repeatCount: 10);
}

void CurePoison()
{
// 毒を止める方法がない!
// 10回すべてのティックが実行され続けてしまう
}
}

複数スケジュールのパターン

複数のスケジュールを管理する場合は、コレクションを使用します。

public class BuffManager : MonoBehaviour
{
[GameEventDropdown] public GameEvent<string> buffTickEvent;

private Dictionary<string, ScheduleHandle> _activeBuffs = new();

public void ApplyBuff(string buffName, float interval, int duration)
{
// 既存のバフがあればキャンセル
if (_activeBuffs.TryGetValue(buffName, out var existingHandle))
{
buffTickEvent.CancelRepeating(existingHandle);
}

// 新しいバフを適用
var handle = buffTickEvent.RaiseRepeating(
buffName,
interval,
repeatCount: duration
);

_activeBuffs[buffName] = handle;
}

public void RemoveBuff(string buffName)
{
if (_activeBuffs.TryGetValue(buffName, out var handle))
{
buffTickEvent.CancelRepeating(handle);
_activeBuffs.Remove(buffName);
}
}

void OnDisable()
{
// すべてのバフをキャンセル
foreach (var handle in _activeBuffs.Values)
{
buffTickEvent.CancelRepeating(handle);
}
_activeBuffs.Clear();
}
}

3. トリガーとチェーンの管理

安全な削除のためにハンドルを使用する

他システムのトリガーやチェーンを誤って削除しないよう、常にハンドルを使用してください。

public class DoorSystem : MonoBehaviour
{
void SetupDoor()
{
doorOpenEvent.AddTriggerEvent(lightOnEvent);
}

void Cleanup()
{
// 危険: lightOnEvent への「すべての」トリガーを削除してしまう
// 他のシステムによって登録されたものまで削除される!
doorOpenEvent.RemoveTriggerEvent(lightOnEvent);
}
}

複数のトリガー/チェーンの整理

複雑なシステムでは、構造化されたアプローチをとります。

public class CutsceneManager : MonoBehaviour
{
// クリーンアップ用にすべてのハンドルを保持
private readonly List<ChainHandle> _cutsceneChains = new();
private readonly List<TriggerHandle> _cutsceneTriggers = new();

void SetupCutscene()
{
// カットシーンのシーケンスを構築
var chain1 = startEvent.AddChainEvent(fadeOutEvent, duration: 1f);
var chain2 = startEvent.AddChainEvent(playVideoEvent, duration: 5f);
var chain3 = startEvent.AddChainEvent(fadeInEvent, duration: 1f);

_cutsceneChains.Add(chain1);
_cutsceneChains.Add(chain2);
_cutsceneChains.Add(chain3);

// エフェクト用の並列トリガーを追加
var trigger1 = startEvent.AddTriggerEvent(stopGameplayMusicEvent);
var trigger2 = startEvent.AddTriggerEvent(hideCrosshairEvent);

_cutsceneTriggers.Add(trigger1);
_cutsceneTriggers.Add(trigger2);
}

void SkipCutscene()
{
// すべてのチェーンをクリーンアップ
foreach (var chain in _cutsceneChains)
{
startEvent.RemoveChainEvent(chain);
}
_cutsceneChains.Clear();

// すべてのトリガーをクリーンアップ
foreach (var trigger in _cutsceneTriggers)
{
startEvent.RemoveTriggerEvent(trigger);
}
_cutsceneTriggers.Clear();
}
}

4. 優先度の使用方法

優先度の値に関するガイドライン

プロジェクト全体で一貫した優先度スケールを使用してください。

// 優先度の定数を定義
public static class EventPriority
{
public const int CRITICAL = 1000; // 絶対に最初に実行すべき
public const int HIGH = 100; // 重要なシステム
public const int NORMAL = 0; // デフォルト
public const int LOW = -100; // 後で実行してもよい
public const int CLEANUP = -1000; // 最終的なクリーンアップ
}

// 使用例
healthEvent.AddPriorityListener(SavePlayerData, EventPriority.CRITICAL);
healthEvent.AddPriorityListener(UpdateHealthBar, EventPriority.HIGH);
healthEvent.AddPriorityListener(PlayDamageSound, EventPriority.NORMAL);
healthEvent.AddPriorityListener(UpdateStatistics, EventPriority.LOW);

優先度のアンチパターン

// ランダムまたは一貫性のない優先度を使用しない
healthEvent.AddPriorityListener(SystemA, 523);
healthEvent.AddPriorityListener(SystemB, 891);
healthEvent.AddPriorityListener(SystemC, 7);

// 順序が重要でない場合に優先度を使いすぎない
uiClickEvent.AddPriorityListener(PlaySound, 50);
uiClickEvent.AddPriorityListener(PlayParticle, 49);
// これらは優先度は不要です、基本リスナーを使用してください!

5. 条件付きリスナー

効果的な条件設計

条件はシンプルかつ高速に保ってください。

// 条件の中で重い操作を行わない
enemySpawnEvent.AddConditionalListener(
SpawnBoss,
() => {
// 悪い例: 条件の中で複雑な計算を行う
var enemies = FindObjectsOfType<Enemy>();
var totalHealth = enemies.Sum(e => e.Health);
var averageLevel = enemies.Average(e => e.Level);
return totalHealth < 100 && averageLevel > 5;
}
);

⚠️ よくある落とし穴

1. メモリリーク

問題: オブジェクトが破棄される時に、リスナーの購読を解除していない。

症状:

  • 時間経過とともにメモリ使用量が増加する
  • 破棄されたオブジェクトに関するエラーが発生する
  • null参照のオブジェクトに対してコールバックが実行される

解決策:

// 常に OnEnable/OnDisable パターンを使用する
void OnEnable() => myEvent.AddListener(OnCallback);
void OnDisable() => myEvent.RemoveListener(OnCallback);

2. スケジュールハンドルの紛失

問題: ハンドルを保存せずにスケジュールを作成している。

症状:

  • 繰り返しイベントをキャンセルできない
  • オブジェクトが破棄された後もイベントが継続する
  • 不要な実行によるリソースの無駄

解決策:

private ScheduleHandle _handle;

void StartTimer()
{
_handle = timerEvent.RaiseRepeating(1f);
}

void StopTimer()
{
timerEvent.CancelRepeating(_handle);
}

3. 広範囲に及ぶ削除の影響

問題: ハンドルベースの削除ではなく、ターゲットベースの削除(RemoveTriggerEvent(event)など)を使用している。

症状:

  • 他のシステムのトリガー/チェーンが予期せず削除される
  • イベントが発行されなくなるという、デバッグが困難な問題
  • システム間の不必要な結合と脆弱性

解決策:

// ハンドルを保存し、ピンポイントで削除する
private TriggerHandle _myTrigger;

void Setup()
{
_myTrigger = eventA.AddTriggerEvent(eventB);
}

void Cleanup()
{
eventA.RemoveTriggerEvent(_myTrigger); // 安全!
}

4. 再帰的なイベント発行

問題: イベントリスナーが同じイベントを発行し、無限ループを引き起こす。

症状:

  • スタックオーバーフロー例外
  • Unityのフリーズ
  • 実行回数の指数関数的な増大

例:

// ❌ 危険: 無限再帰!
void Setup()
{
healthEvent.AddListener(OnHealthChanged);
}

void OnHealthChanged(float health)
{
// これが再び OnHealthChanged をトリガーする!
healthEvent.Raise(health - 1); // ← 無限ループ
}

解決策:

// ✅ フラグを使用して再帰を防ぐ
private bool _isProcessingHealthChange = false;

void OnHealthChanged(float health)
{
if (_isProcessingHealthChange) return; // 再帰を防止

_isProcessingHealthChange = true;

// ここなら安全に発行できる
if (health <= 0)
{
deathEvent.Raise();
}

_isProcessingHealthChange = false;
}

🚀 パフォーマンスの最適化

1. リスナー数を最小限に抑える

コードは高度に最適化されていますが、各リスナーにはわずかなオーバーヘッドがあります。可能な限りまとめましょう。

// 関連する操作に対して複数のリスナーを設定
healthEvent.AddListener(UpdateHealthBar);
healthEvent.AddListener(UpdateHealthText);
healthEvent.AddListener(UpdateHealthIcon);
healthEvent.AddListener(UpdateHealthColor);

2. リスナー内での重い処理を避ける

リスナーは軽量に保ってください。重い処理はコルーチンや非同期(async)に移動させます。

void OnDataLoaded(string data)
{
// 悪い例: 後続のすべてのリスナーの実行をブロックする
var parsed = JsonUtility.FromJson<LargeData>(data);
ProcessComplexData(parsed); // 50ms かかる
SaveToDatabase(parsed); // 100ms かかる
}

3. デリゲートの割り当てをキャッシュする

毎フレーム、新しいデリゲートの割り当てが発生するのを避けます。

void OnEnable()
{
// 毎回新しいデリゲートが割り当てられる
updateEvent.AddListener(() => UpdateHealth());
}

📊 概要チェックリスト

GameEvent を使用する際のチェックリストとして活用してください:

リスナーの管理

  • 必ず OnDisable で購読を解除しているか
  • OnEnable/OnDisable パターンを使用しているか
  • 可能な限りデリゲート参照をキャッシュしているか
  • リスナーを軽量に保っているか

スケジュール管理

  • キャンセルが必要な時に ScheduleHandle を保存しているか
  • OnDisable でスケジュールをキャンセルしているか
  • 複数のスケジュールにはコレクションを使用しているか
  • オブジェクト破棄時にクリーンアップしているか

トリガー/チェーン管理

  • 安全な削除のためにハンドルを使用しているか
  • クリーンアップ用にハンドルをコレクションに保存しているか
  • 並列にはトリガー、直列にはチェーンを正しく選択しているか
  • チェーンの場合、ExecuteChainEvents() を呼び出すことを忘れていないか

パフォーマンス

  • 関連するリスナーを統合しているか
  • 重い処理をコルーチン/非同期に逃がしているか
  • 条件はシンプルで高速か
  • 再帰的なイベント発行を避けているか

優先度と条件

  • 一貫した優先度スケールを使用しているか
  • 順序が重要な時のみ優先度を使用しているか
  • 条件をシンプルに保ち、キャッシュしているか
  • 優先度の依存関係をドキュメント化しているか