在遊戲開發中,一個物件經常需要訂閱另一個物件的事件,以在事件發生時,執行對應的動作,像是玩家輸入、觸發機關、關卡管理等。而在 Unity 中,常見的方式是使用 UnityEvent 與 C# event 來讓物件提供事件介面,讓其它物件訂閱,但這兩者各自問題,反而讓程式容易出錯。本篇文章整理 UnityEvent 與 C# event,並講解可能的問題,而下篇則會介紹利用 C# 介面做為事件介面的第三種方式。

UnityEvent

UnityEvent 在 Unity 中是最常見的訂閱事件方法,好處是讓其它物件可以在 inspector 上直接設定事件的處理函式(event handler)。通常在設計套件時,會經常使用 UnityEvent 來讓使用者可以從介面設定處理函式,但這也是 UnityEvent 的唯一好處了。

public class LevelManager : MonoBehaviour
{
    [SerializeField]
    private UnityEvent _onLevelStart;
}

UnityEvent 的問題

讓訂閱的物件提供過多的 public 函式

使用 UnityEvent 的一個大問題就是想要訂閱事件的物件得要提供 public 函式才能在 inspector 中設置處理函式,這會讓其它物件也有機會去呼叫這個處理函式。

public class Enemy : MonoBehaviour
{
    public void OnLevelStart()
    {
        Debug.Log("OnLevelStart");
    }
}

例如:敵人物件為了在 inspector 上能訂閱關卡開始的事件,就得要提供一個 public 的函式,這讓其它有參考這個敵人的物件也有機會呼叫 OnLevelStart

而且如果該物件需要接入不同的事件時,這些 public 函式就會越來越多。在寫程式時,就會多出許多不必要的候選函式,造成困擾。

外部物件能觸發事件

除了透過 inspector 設置處理函式之外,通常也會讓處理函式能在程式中設置。雖然可以讓要訂閱事件的物件在自己內部訂閱,而能將處理函式設為 private 的,但為此將 UnityEvent 設為 public 的話,反而會讓其它物件有機會呼叫 Invoke() 來觸發事件。

public class LevelManager : MonoBehaviour
{
    public static LevelManager Instance;

    public UnityEvent OnLevelStart;

    private void Awake()
    {
        Instance = this;
    }
}

public class Enemy : MonoBehaviour
{
    private void Start()
    {
        LevelManager.Instance.OnLevelStart.AddListener(OnLevelStart);
    }

    private void OnLevelStart()
    {
        Debug.Log("OnLevelStart");
    }
}

public class SomeObject : MonoBehaviour
{
    private void SomeFunc()
    {
        // 不相關的物件可以呼叫
        LevelManager.Instance.OnLevelStart.Invoke();
    }
}

例如:為了能在程式中訂閱事件,LevelManagerOnLevelStart 事件設為 public,雖然可以讓 Eneny 物件在程式中訂閱事件,但可能讓其它物件不小心觸發到關卡開始的事件,造成意外的結果。

要避免這種情況的話,就還是把 UnityEvent 物件宣告為 private 的,並讓物件提供訂閱事件的函式。如果還是要保留在 inspector 設置處理函式的功能,則加上 SerializeField 屬性。

public class LevelManager : MonoBehaviour
{
    [SerializeField]
    private UnityEvent _onLevelStart;

    public void SubscribeOnLevelStart(UnityAction callback)
    {
        _onLevelStart.AddListener(callback);
    }

    public void UnsubscribeOnLevelStart(UnityAction callback)
    {
        _onLevelStart.RemoveListener(callback);
    }
}

重複訂閱事件

無論是透過 inspector 還是 AddListener() 來訂閱事件,都有可能會重複訂閱,造成同一個事件處理函式被呼叫數次。

public class PlayerInputManager : MonoBehaviour
{
    public static PlayerInputManager Instance;

    [NonSerialized]
    public UnityEvent OnFire = new UnityEvent();

    private void Awake()
    {
        Instance = this;
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.F))
            OnFire.Invoke();
    }
}

public class Player : MonoBehaviour
{
    private void Start()
    {
        PlayerInputManager.Instance.OnFire.AddListener(OnPlayerFire);
    }

    public void Init()
    {
        // 重複訂閱
        PlayerInputManager.Instance.OnFire.AddListener(OnPlayerFire);
    }

    private void OnPlayerFire()
    {
        Debug.Log("OnPlayerFire");
    }
}

例如:在 Player 物件在訂閱玩家的輸入事件時,不小心在 Start() 跟給外部呼叫的 Init() 中各訂閱一次 OnFire 事件,讓玩家在按下發射按鍵時,執行兩次發射行為。而且更有可能在玩家復活時,呼叫 Init() 來重置 Player 物件的狀態,造成每次復活後,會執行更多次發射行為。

C# event

在 Unity 中,另一個訂閱事件的方式是使用 C# 的 event

public class LevelManager : MonoBehaviour
{
    public static LevelManager Instance;

    public event Action OnLevelStart;

    private void Awake()
    {
        Instance = this;
    }
}

C# event 的優點是外部物件只能訂閱事件,而無法觸發事件,只能由宣告事件的物件呼叫 Invoke()

public class LevelManager : MonoBehaviour
{
    private void LevelStart()
    {
        OnLevelStart?.Invoke();
    }
}

public class Enemy : MonoBehaviour
{
    private void Start()
    {
        LevelManager.Instance.OnLevelStart += OnLevelStart;
        // 無法呼叫 Invoke
        // LevelManager.Instance.OnLevelStart.Invoke();
    }

    private void OnLevelStart()
    {
        Debug.Log("OnLevelStart");
    }
}

而且比起 UnityEvent,C# event 呼叫處理函式的時間相對少很多。

C# event 的問題

重複訂閱事件

不過 C# event 還是會發生重複訂閱事件的問題,如果同一個處理函式被重複訂閱,一樣會被呼叫數次。另一個問題是,在取消訂閱時,重複訂閱的函式只會被移除一個。但是這個情況在 UnityEvent 中反而不會發生,因為呼叫 RemoveListener() 取消訂閱時,重複的訂閱函式都會被移除。

public class Enemy : MonoBehaviour
{
    private void Start()
    {
        LevelManager.Instance.OnLevelStart += OnLevelStart;
    }

    public void Init()
    {
        LevelManager.Instance.OnLevelStart += OnLevelStart;
    }

    private void OnDisable()
    {
        // 只會移除其中一個處理函式
        LevelManager.Instance.OnLevelStart -= OnLevelStart;
    }

    private void OnLevelStart()
    {
        Debug.Log("OnLevelStart");
    }
}

例如:Enemy 物件在 Start()Init() 各訂閱一次 OnLevelStart 事件,在 OnDisable 中取消訂閱時,就只會移除一個處理函式。當 OnLevelStart 事件觸發時,還是會看到有訊息輸出,讓原本被關閉的 Enemy 物件又開始執行。

無物件訂閱

在觸發 C# event 時,要注意如果該 event 沒有任何物件訂閱,或是因為取消訂閱到空時,此時事件物件會被設為 null,如果這時觸發事件會出現 null reference exception,所以最好以 onEvent?.Invoke() 來觸發事件。

public class LevelManager : MonoBehaviour
{
    public event Action OnLevelStart;

    private void LevelStart()
    {
        OnLevelStart?.Invoke();
    }
}

匿名函式

如果以匿名函式訂閱事件時,該函式就再也無法被取消訂閱了,這也會發生在 UnityEvent 上。因為就算內容完全一樣,在不同地方宣告的匿名函式是不同的。所以應該要避免用匿名函式訂閱事件。

public class Enemy : MonoBehaviour
{
    public void Init()
    {
        LevelManager.Instance.OnLevelStart += () => Debug.Log("OnLevelStart");
    }

    private void OnDisable()
    {
        // 無法取消在 Init() 訂閱的事件
        LevelManager.Instance.OnLevelStart -= () => Debug.Log("OnLevelStart");
    }
}

例如:敵人物件在 Init() 用匿名函式訂閱了 OnLevelStart 事件,但在 OnDisable() 取消訂閱時,無法移除前面用來訂閱的函式。

小結

這邊小結這兩種事件訂閱方式的優點與要注意的問題:

  • UnityEvent
    • 優點
      • 可以在 inspector 中設定處理函式
    • 問題
      • 讓訂閱的物件提供過多的 public 函式
      • 外部物件能觸發事件
      • 重複註冊
      • 可以用匿名函式註冊
  • C# event
    • 優點
      • 只有宣告事件的物件才能觸發事件
      • 執行速度快
    • 問題
      • 重複註冊
      • 無物件訂閱時,會是 null
      • 可以用匿名函式註冊