[Unity] 事件訂閱與問題(上)- UnityEvent
與 C# event
在遊戲開發中,一個物件經常需要訂閱另一個物件的事件,以在事件發生時,執行對應的動作,像是玩家輸入、觸發機關、關卡管理等。而在 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();
}
}
例如:為了能在程式中訂閱事件,LevelManager
將 OnLevelStart
事件設為 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
- 可以用匿名函式註冊
- 優點