【Unity】Unity C#設計パターン:イベントとデリゲートで疎結合なコードを実現する

作成: 2025-12-07最終更新: 2026-07-13

GetComponentや直接参照の多用は、コードを複雑で壊れやすくします。C#の強力な機能であるデリゲート(delegate)とイベント(event)を使い、オブジェクト間の依存関係を断ち切り、拡張性と再利用性の高い疎結合なシステムを設計する方法を解説します。

プレイヤーがダメージを受けたら、体力バーを更新して、悲鳴を再生して、カメラを揺らして、ゲームオーバー判定もして——気づけばプレイヤーのスクリプトが UIManagerAudioManagerCameraManager も全部知っている。「1つ直すと全部壊れる」コードのできあがりです。

この「全員が全員を知っている」状態(密結合)を断ち切るのが、C#の デリゲート (delegate)イベント (event) を使った イベント駆動設計 です。プレイヤーは「ダメージを受けた!」と叫ぶだけ。誰がそれを聞いているかは知らなくていい——この記事では、その仕組みと実装方法を解説します。

イベント駆動のイメージ。発信者がメガホンで出来事を叫び、複数の受信者がそれぞれ耳を傾けている

この記事でわかること

  • 直接参照だらけの「密結合」が抱える3つの問題
  • デリゲート=「メソッドへの参照」、イベント=「安全なラッパー」という関係
  • OnEnableで購読・OnDisableで解除、という鉄則の理由
  • 体力システムを題材にした疎結合な実装例

Sponsored

なぜ直接参照だとダメなのか?

最も素朴な実装は、プレイヤーのスクリプトがUIManagerAudioManagerCameraManagerGameManagerなどをすべて直接参照し、それぞれのメソッドを呼び出す方法です。しかしこのアプローチ(密結合)には、多くの問題があります。

  • 依存関係の複雑化: プレイヤースクリプトが、本来知る必要のないUIやオーディオといった他の多くのシステムの存在を前提にしてしまいます。
  • 拡張性の低下: 新しく「実績システムに通知する」という処理を追加したい場合、プレイヤースクリプトを修正する必要があります。
  • 再利用性の低下: このプレイヤーの仕組みを敵キャラクターに流用しようとしても、UIやカメラなど、敵には不要な依存関係が絡んできて簡単には再利用できません。

イベント駆動設計の基本的な考え方は、「何か(イベント)が起きたことを宣言する側」と、「そのイベントが起きたら何か(処理)をしたい側」を完全に分離することです。

密結合とイベント駆動の比較図。左は発信者が各マネージャーへ直接参照の線を張り巡らせて絡み合い、右は発信者がイベントを発行するだけで、購読者が各自それを受け取るすっきりした構造

デリゲート (Delegate) とは?

デリゲートは、一言で言えば「メソッドへの参照を保持できる型」です。デリゲートを使うと、メソッドをあたかも変数であるかのように、他のメソッドに渡したり、クラスのフィールドとして保持したりできます。

// まず、デリゲートの「型」を定義します
// これは「戻り値がvoidで、int型の引数を一つ取るメソッド」の型定義です
public delegate void MyDelegate(int number);

public class DelegateExample
{
    public void Run()
    {
        // デリゲート型の変数に、メソッドを代入する
        MyDelegate myDelegate = PrintNumber;
        myDelegate += PrintDoubleNumber; // マルチキャストデリゲート:複数のメソッドを登録できる

        // デリゲートを呼び出す(登録されたメソッドがすべて呼ばれる)
        myDelegate(5);
        // 出力:
        // Number: 5
        // Double Number: 10
    }

    void PrintNumber(int num) { Debug.Log($"Number: {num}"); }
    void PrintDoubleNumber(int num) { Debug.Log($"Double Number: {num * 2}"); }
}

補足: 実際の開発では、delegateを毎回自分で定義する代わりに、.NET標準の汎用デリゲート型 Action(戻り値なし)や Func(戻り値あり)を使うのが主流です。Action<int> は「int引数1つ・戻り値なしのメソッド」を表します。

イベント (Event) とは?

デリゲートは強力ですが、publicなデリゲート変数は、クラスの外部から自由に呼び出したり(myDelegate(5))、登録されたメソッドをまるごと消したり(myDelegate = null)できてしまい、カプセル化が不十分です。

イベント (event) は、このデリゲートをラップし、クラスの外部からはメソッドの登録(+=)と解除(-=)しかできないように制限する仕組みです。イベントの発行(呼び出し)は、そのイベントを宣言したクラスの内部からしか行えません。これにより、安全なイベント通知パターンを実装できます。

Sponsored

Unityでの実践例:疎結合な体力システム

プレイヤーの体力システムをイベント駆動で実装してみましょう。コードの前に、まず全体像を図で掴んでください。

体力システムのイベントフロー図。プレイヤーがOnHealthChangedイベントを発行すると、購読しているUIの体力バー・オーディオ・カメラがそれぞれ自分の処理を実行する

プレイヤーはOnHealthChangedというイベントを発行する(叫ぶ)だけ。それを購読しているUI・オーディオ・カメラが、通知を受けてそれぞれ自分の仕事をします。この形を、これからコードにしていきます。

1. イベントを発行する側 (PlayerHealth.cs)

PlayerHealthクラスは、ダメージを受けたことと、体力がゼロになったことをイベントとして通知します。このクラスは、UIやオーディオの存在を一切知りません。

using System;
using UnityEngine;

public class PlayerHealth : MonoBehaviour
{
    // イベントの定義
    // Action<T>は、.NETに標準で用意されている便利なデリゲート型

    // instanceイベント: 「このプレイヤーの」体力変化を通知する
    public event Action<int, int> OnHealthChanged; // 引数: 現在の体力, 最大体力

    // staticイベント: 「ゲーム全体の合図」に限定して使う
    public static event Action OnPlayerDied;

    public int maxHealth = 100;
    private int currentHealth;

    private void Start()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(int damage)
    {
        currentHealth -= damage;
        if (currentHealth < 0) currentHealth = 0;

        // イベントを発行(購読者がいれば通知される)
        // ?.Invoke() は、購読者が1人もいない場合のNullReferenceExceptionを防ぐ安全な呼び出し方
        OnHealthChanged?.Invoke(currentHealth, maxHealth);

        if (currentHealth <= 0)
        {
            OnPlayerDied?.Invoke();
        }
    }
}

2. イベントを購読する側 (UIManager.cs, AudioManager.cs)

UIManagerAudioManagerは、PlayerHealthのイベントを購読し、通知が来たらそれぞれの処理を実行します。

// UIManager.cs
using UnityEngine;
using TMPro; // TextMeshProを使うために必要

public class UIManager : MonoBehaviour
{
    [SerializeField] private PlayerHealth player; // 監視する相手への参照はこの1本だけ
    public TextMeshProUGUI healthText;

    private void OnEnable() // オブジェクトが有効になった時
    {
        // playerのイベントを購読
        player.OnHealthChanged += UpdateHealthUI;
    }

    private void OnDisable() // オブジェクトが無効になった時
    {
        // 必ず購読を解除する(破棄済みオブジェクトへの参照が残るのを防ぐ)
        player.OnHealthChanged -= UpdateHealthUI;
    }

    private void UpdateHealthUI(int current, int max)
    {
        healthText.text = $"HP: {current} / {max}";
    }
}

// AudioManager.cs
public class AudioManager : MonoBehaviour
{
    private void OnEnable()
    {
        PlayerHealth.OnPlayerDied += PlayDeathSound;
    }

    private void OnDisable()
    {
        PlayerHealth.OnPlayerDied -= PlayDeathSound;
    }

    private void PlayDeathSound()
    {
        // 死亡サウンドを再生する処理
    }
}

2種類のイベントを使い分けている点に注目してください。体力の変化は「 どの個体の話か 」が重要なので instanceイベント にし、購読者は相手への参照を1本だけ持ちます。一方OnPlayerDiedのような「 ゲーム全体への合図 」は送信元の個体を区別する必要がないため、staticイベントとしてクラス名から直接購読できるようにしています。この使い分けの意味は、次の実践で敵を3体に増やすとはっきり分かります。

鉄則:OnEnableで購読、OnDisableで解除

購読(+=)したまま解除(-=)を忘れると、オブジェクトが破棄されてもイベント側に参照が残り続けます。staticイベントの場合はシーンを切り替えても残るため、破棄済みオブジェクトのメソッドが呼ばれてエラーになるGCに回収されずメモリリークするといった、発見しづらいバグの温床になります。

購読と解除のライフサイクル図。OnEnableでイベントにプラグを差し込み、OnDisableでプラグを抜く。解除を忘れると破棄されたオブジェクトへの配線が残ってエラーの原因になる

OnEnable+=したら、必ずOnDisable-=する」——ペアで書く習慣をつけましょう。

実践:敵が3体いても通知が混ざらない体力バー

instanceイベントの真価は、 同じクラスのオブジェクトが複数いるとき に出ます。敵が3体並ぶARPG、ユニットが何十体もいるRTS、頭上にHPバーが浮かぶMMO風の画面——「敵Bを攻撃したのに、なぜか全員のバーが減る」を防げるかどうかは、イベントの持ち方で決まります。

組み立て手順: 敵プレハブにEnemyHealthと、頭上のSlider+EnemyHealthBarを持たせて、シーンに3体(A・B・C)並べます。

// ファイル名: EnemyHealth.cs — 各敵にアタッチ
using System;
using UnityEngine;

public class EnemyHealth : MonoBehaviour
{
    // instanceイベント: 「この敵の」体力変化だけを通知する
    public event Action<int, int> OnHealthChanged;

    public int maxHealth = 30;
    private int currentHealth;

    void Awake() { currentHealth = maxHealth; }

    public void TakeDamage(int damage)
    {
        currentHealth = Mathf.Max(currentHealth - damage, 0);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }
}
// ファイル名: EnemyHealthBar.cs — 各敵の頭上HPバーにアタッチ
using UnityEngine;
using UnityEngine.UI;

public class EnemyHealthBar : MonoBehaviour
{
    [SerializeField] private EnemyHealth owner; // 自分の主。この1体だけを見る
    [SerializeField] private Slider slider;

    void OnEnable()  { owner.OnHealthChanged += UpdateBar; }
    void OnDisable() { owner.OnHealthChanged -= UpdateBar; }

    void UpdateBar(int current, int max)
    {
        slider.value = (float)current / max;
    }
}

再生して真ん中の敵Bだけを攻撃してみてください。 Bのバーだけが減り、AとCは微動だにしませんownerという参照1本で、通知の宛先が自動的に正しくなる——これがinstanceイベントの効果です。

もしOnHealthChangedstaticイベントにすると、敵Bを攻撃した瞬間に 3本のバーが全部反応します。staticイベントはクラスに1つしかないので、「誰の体力の話か」を区別できないのです。区別しようとすると引数に送信元を足して購読側でフィルタする羽目になり、本末転倒です。判断基準はシンプルに——

  • 送信元の個体が重要(被弾・HP・選択状態)→ instanceイベント
  • ゲーム全体への合図(Game Over・ポーズ・ステージクリア)→ staticイベント。シーンをまたぐ規模なら イベントチャネル
instanceイベントと全体イベントの対比図。敵Bの被弾は敵BのHPバーだけに届き、プレイヤー死亡のような全体の合図はUI・オーディオ・ゲーム管理の複数システムへ届く

わざと壊して、直す

イベントまわりの定番事故を、この3体で安全に再現してみましょう。

  1. 解除忘れで二重反応: EnemyHealthBarOnDisable-=をコメントアウトし、バーのGameObjectを非表示→再表示してから攻撃すると、UpdateBarに仕込んだDebug.Log1回の被弾で2回 出ます。開閉のたびに購読が積み上がるからです。-=を戻せば何度開閉しても1回に戻ります
  2. ラムダ式は別のラムダで解除できない: owner.OnHealthChanged += (c, m) => slider.value = (float)c / m;と購読し、同じ見た目の-=を書いても 解除されません。ラムダは書くたびに別物だからです。解除が必要な購読は、メソッド名(または変数に保存したラムダ)で行います
  3. 破棄済みの購読者が残る: バーだけをDestroyして攻撃すると、イベントに残った参照経由で破棄済みUIのUpdateBarが呼ばれ、MissingReferenceExceptionが出ます。「 購読者は破棄される前に必ず解除する 」はinstance/staticどちらでも鉄則です

仕上げの確認はシンプルで、「その通知は誰に届くべきか」と画面が一致しているかどうかです。敵Bを殴ってBのバーだけが減り、Game Overの合図だけはUI・音・カメラへ一斉に届く——instanceとstaticをこの感覚で選べるようになっていれば、イベント設計はもう自分の道具です。

Sponsored

おまけ:先に知っておくと良いこと

イベント駆動に慣れてきたら、次はこのあたりが視野に入ってきます。

  • UnityEventとの使い分け: Inspectorから購読を設定できる UnityEvent(ボタンの OnClick と同じ仕組み)もあります。デザイナーが繋ぎ替えたい部分はUnityEvent、コード内の通知はC# event、が目安です。
  • staticイベントのリセット問題: staticイベントはシーンをまたいで生き残るため、シーン再ロード時に古い購読が残ることがあります。規模が大きくなったら、ScriptableObjectをイベントの入れ物にする イベントチャネル パターンが有力です。イベントチャネル設計の記事 で本記事の続きとして解説しています。
  • 探し回る前にイベントを: GetComponentFindFirstObjectByTypeで他のオブジェクトを探し回る設計は密結合のもとです。参照取得の基本は GetComponentの記事 で整理できます。

まとめ

イベント駆動設計は、Unityでクリーンでスケーラブルなコードを書くための基本かつ強力なテクニックです。

  • 密結合な直接参照は、コードの修正や再利用を困難にする。
  • デリゲートは「メソッドへの参照」、イベントは「安全なデリゲートのラッパー」。
  • イベントの発行側は「起きたこと」を宣言するだけ。購読側の存在を知る必要はない。
  • イベントの購読側は、興味のあるイベントを購読(+=)し、OnDisableで必ず解除(-=)する。
  • このパターンにより、各システムは独立して機能し、疎結合で拡張性の高い設計が実現できる。

他のオブジェクトを探し回って直接呼び出す前に、「これはイベントで通知できないか?」と考えてみましょう。その習慣が、あなたのコードをよりプロフェッショナルなレベルに引き上げてくれるはずです。