Unity 事件機制淺談 (C# events, unity events)
在上次 GameJam 遇到的情況,組員不太會使用 C# event,也發現同事也不太熟悉,因此整理在 Unity 中使用事件 (event) 的記錄。
為什麼要使用事件機制 C# event
使用該機制來通知傳遞訊息通知該事件發生,該設計最重要一點是可以能降低模組 (module) 間的依賴性 (dependency),或是稱為耦合性 (coupling)。在軟體開發中,當類別 (class) 間相互依賴性越少,其程式碼可讀性以及可維護性會比較好。
以主遊戲流程 (MainApp) 開啟道具背包 (ItemBag) 畫面為例,有需求為,當玩家 (操作者) 使用輸入點擊道具時,主遊戲流程想得知玩家選了什麼道具,進而執行該道具所產生的效果。
經驗不足的開發者,可能會直接將 MainApp instance 傳給 ItemBag,讓 ItemBag 呼叫 MainApp 函數來通知玩家點選了什麼道具,但這樣會造成該兩個類別互相依賴。對於有經驗的程式設計師來說,這樣的設計方式有點壞味道。
using UnityEngine;
public partial class MainApp : MonoBehaviour
{
[SerializeField]
ItemBag itemBag = null;
public void OnItemSelected(Item item)
{
this.itemBag.Hide();
// TODO: Item
}
void Update()
{
if (this.openItemBag)
{
this.itemBag.Show(this);
}
}
}
public partial class ItemBag : MonoBehaviour
{
MainApp callee;
public void Show(MainApp app)
{
this.callee = app;
// TODO: 開啟道具選單
}
public void Hide()
{
// TODO: 關閉道具選單
}
// 此函數假設由 UI 按鈕呼叫
void OnItemSelected(Item item)
{
if (this.callee != null)
{
callee.OnItemSelected(item);
}
}
}
但如果改用 event 傳遞訊息,由 MainApp 自行向 ItemBag 訂閱點選道具的事件,而 ItemBag 則是發生該事件時負責通知所有訂閱者,這樣的機制使得 ItemBag 不用再依賴於 MainApp,降低 ItemBag 依賴性。
using System;
using UnityEngine;
public partial class MainApp : MonoBehaviour
{
[SerializeField]
ItemBag itemBag = null;
void Awake()
{
itemBag.ItemSelected += this.OnItemSelected;
}
void OnItemSelected(object sender, ItemEventArgs args)
{
(sender as ItemBag).Hide();
// TODO: args.Item
}
void Update()
{
if (this.openItemBag)
{
this.itemBag.Show();
}
}
}
public partial class ItemBag : MonoBehaviour
{
public event EventHandler<ItemEventArgs> ItemSelected;
public void Show()
{
// TODO: 開啟道具選單
}
public void Hide()
{
// TODO: 關閉道具選單
}
// 此函數假設由 UI 按鈕呼叫
void OnItemSelected(Item item)
{
if (this.ItemSelected != null)
{
this.ItemSelected(this, new ItemEventArgs(item));
}
}
}
委派 Delegation
在使用 C# event 前,必須要先知道什麼是委派 (delegation),委派是一種函數類別,描述函數所需傳入的參數型態以及回傳型態,如果使用過 C++,就如同 function pointer 的概念。
例如一個宣告委派範例,輸入參數 int x, y,回傳 int:
public delegate int PerformCalculation(int x, int y);
委派可以指定函數:
int Add(int x, int y)
{
return x + y;
}
int Multiply(int x, int y)
{
return x * y;
}
void Start()
{
// 通常如此指定
PerformCalculation cal = this.Add;
// 或是使用 var
var cal2 = (PerformCalculation)this.Multiply;
}
然後使用該委派來呼叫其函數:
cal(3, 5); // return 8
cal2(3, 5); // return 15
當然委派也可以使用 Lambda 宣告指定其函數:
// 通常這樣宣告
PerformCalculation cal3 = (x, y) =>
{
return x - y;
};
// 或是使用 var 方式
var cal4 = (PerformCalculation)((x, y) =>
{
return x - y;
});
Net framework 已經定義常用的委派型別,如果不需要回傳值可使用 System.Action:
namespace System
{
// 不帶任何參數
public delegate void Action();
// 帶參數,參數傳入 T1, T2, T3, T4 型別資料
public delegate void Action<T>(T obj);
public delegate void Action<T1, T2>(T1 arg1, T2 arg2);
public delegate void Action<T1, T2, T3>(T1 arg1, T2 arg2, T3 arg3);
public delegate void Action<T1, T2, T3, T4>(T1 arg1, T2 arg2, T3 arg3, T4 arg4);
}
如果需要回傳值的委派可使用 System.Func:
namespace System
{
// 不帶任何參數,回傳 TResult 型別資料
public delegate TResult Func<TResult>();
// 參數傳入 T1, T2, T3, T4 型別資料,回傳 TResult 型別資料
public delegate TResult Func<T, TResult>(T obj);
public delegate TResult Func<T1, T2, TResult >(T1 arg1, T2 arg2);
public delegate TResult Func<T1, T2, T3, TResult>(T1 arg1, T2 arg2, T3 arg3);
public delegate TResult Func<T1, T2, T3, T4, TResult>(T1 arg1, T2 arg2, T3 arg3, T4 arg4);
}
所以可以改用 System.Func,不再額外宣告 PerformCalculation:
var cal = (System.Func<int, int, int>)((x, y) => {
return x + y;
})
cal(3, 5); // return 8
事件 Event
在 C# 中定義 event 必須先定義上述提到的委派,定義該 event 發生時所呼叫的函數樣子。在 Net framework 的函數庫中,定義事件的委派都類似於 System.EventHandler 的宣告,而且其委派的命名通常是以 EventHandler 結尾:
namespace System
{
// 最基本的定義
public delegate void EventHandler(object sender, EventArgs e);
// 或是使用 Generic 使用不同的 EventArgs
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e) where TEventArgs : EventArgs;
}
該 event handler 不回傳值 (void),傳遞兩個參數,sender 表示是哪個物件發起該事件,e 表示該事件參數。C# 文件是建議自訂事件委派最好也能跟隨相同規則,讓程式碼在相同規則下易讀好維護。
例如自訂一個按鈕類別,並且定義其按下事件 Clicked:
public partial class MyButton : MonoBehaviour
{
public event System.EventHandler Clicked;
}
通常以發生事件名稱都會以過去動詞結尾,例如 Clicked, Dropped, or FormClosed。若以 ing 結尾通常表示該事件要準備發生,且可能有取消該事件的機制,例如 FormClosing,更多關於事件命名規則請參考 Reference: Event Naming Guidelines。
之後發起該事件通知所有訂閱者,會先檢查是否為 null,null 表示沒有任何訂閱者。此外若是傳遞一般的事件參數 System.EventArgs,為了避免過多的記憶體配置 (memory allocation), 會直接使用 System.EventArgs.Empty
,而不是 new System.EventArgs()
,如以下範例:
if (this.Clicked != null)
{
this.Clicked(this, System.EventArgs.Empty);
}
其他模組便可向該物件使用 +=
訂閱 Clicked 事件,或是 -=
反訂閱該事件,再撰寫事件發生後的處理函數 OnButtonClicked
:
using UnityEngine;
public class Example : MonoBehaviour
{
[SerializeField]
MyButton button = null;
void Awake()
{
this.button.Clicked += this.OnButtonClicked;
}
void OnDestroy()
{
if (this.button != null)
{
this.button.Clicked -= this.OnButtonClicked;
}
}
private void OnButtonClicked(object sender, System.EventArgs e)
{
// TODO: 處理事件
}
}
也許哪天 MyButton 事件要進行擴充,其參數要多回傳資料,因此宣告新的事件參數類別:
public class MyButtonEventArgs : System.EventArgs
{
// ...省略額外參數
}
修改 Clicked 事件宣告:
public partial class MyButton : MonoBehaviour
{
public event System.EventHandler<MyButtonEventArgs> Clicked;
}
調整發起事件的參數:
if (this.Clicked != null)
{
this.Clicked(this, new MyButtonEventArgs());
}
其他模組註冊事件可以按照原本的方式註冊,或者調整處理新的事件參數:
public partial class Example : MonoBehaviour
{
void Awake()
{
// 這兩種方式可以編譯,套用 C# 的建議事件設計,擴充調整可以不需要修改舊的程式碼
this.button.Clicked += this.OnButtonOldClicked;
this.button.Clicked += this.OnButtonNewClicked;
}
// 原本的方式
private void OnButtonOldClicked(object sender, System.EventArgs e)
{
}
// 新的處理方式
private void OnButtonNewClicked(object sender, MyButtonEventArgs e)
{
}
}
如果事件宣告修改成以下,實際上程式也可以正常執行與編譯,跟沒有加 event
有什麼差異嗎?
public partial class MyButton : MonoBehaviour
{
// 原先
public event System.EventHandler Clicked;
// 修改成
public System.EventHandler Clicked;
}
其他類別可以取消該事件的所有註冊,也可以發起該事件,表示該事件沒有良好封裝,可以讓其他類別惡意的操弄:
public class MyClass : MonoBehaviour
{
[SerializeField]
MyButton button = null;
void Update()
{
// 取消所有事件監聽
button.Clicked = null;
// 發起該事件
button.Clicked(this, System.EventArgs.Empty);
}
}
反之加入 event
關鍵字不會,編譯器會檢查並丟出錯誤:
error CS0070: The event `MyButton.Clicked’ can only appear on the left hand side of += or -= when used outside of the type `MyButton’
另外 event
宣告也可以類似於屬性 (property) getter/setter 的自定義:
public event System.EventHandler Clicked
{
add
{
// TODO:
}
remove
{
// TODO:
}
}
更多關於 event 進階用法,請參考 Reference: event (C# Reference)。
Unity Event
Unity 自己也有定義自己的事件 (UnityEngine.Events
),大量運作在 Unity GUI 上 (UnityEngine.UI
),能用於在編輯器中設定事件處理函數,基本的 Unity 事件宣告:
namespace UnityEngine.Events
{
public class UnityEvent : UnityEventBase
{
// 註冊事件
public void AddListener(UnityAction call);
// 反註冊事件
public void RemoveListener(UnityAction call);
// 發起該事件
public void Invoke();
}
}
例如常使用到的 UI Button,可以向其 clicked 註冊事件處理按鈕按下的行為:
using System;
using UnityEngine;
using UnityEngine.UI;
public class UnityEventExample : MonoBehaviour
{
[SerializeField]
Button button = null;
void Awake()
{
this.button.onClick.AddListener(this.OnButtonClicked);
}
void OnDestroy()
{
this.button.onClick.RemoveListener(this.OnButtonClicked);
}
private void OnButtonClicked()
{
// TODO: 處理事件
}
}
或是使用 EventTrigger 處理更多 Unity UI 事件:
public partial class UnityEventExample : MonoBehaviour
{
[SerializeField]
Button button = null;
void Awake()
{
var eventTrigger = this.button.gameObject.AddComponent<EventTrigger>();
{
var pointerDown = new EventTrigger.Entry();
pointerDown.eventID = EventTriggerType.PointerDown;
pointerDown.callback.AddListener((data) => { this.OnButtonPointerDown((PointerEventData)data); });
eventTrigger.triggers.Add(pointerDown);
}
{
var pointerUp = new EventTrigger.Entry();
pointerUp.eventID = EventTriggerType.PointerUp;
pointerUp.callback.AddListener((data) => { this.OnButtonPointerUp((PointerEventData)data); });
eventTrigger.triggers.Add(pointerUp);
}
}
private void OnButtonPointerDown(PointerEventData data)
{
// TODO: 處理事件
}
private void OnButtonPointerUp(PointerEventData data)
{
// TODO: 處理事件
}
}
更多關於 EventTrigger 事件處理請參考 Reference: Unity EventTrigger。
沒有留言: