關於 web service, unity, blogger 等軟體工程筆記

Unity 事件機制淺談 (C# events, unity events)

Edit icon 沒有留言
Unity

在上次 GameJam 遇到的情況,組員不太會使用 C# event,也發現同事也不太熟悉,因此整理在 Unity 中使用事件 (event) 的記錄。

為什麼要使用事件機制 C# event

使用該機制來通知傳遞訊息通知該事件發生,該設計最重要一點是可以能降低模組 (module) 間的依賴性 (dependency),或是稱為耦合性 (coupling)。在軟體開發中,當類別 (class) 間相互依賴性越少,其程式碼可讀性以及可維護性會比較好。

以主遊戲流程 (MainApp) 開啟道具背包 (ItemBag) 畫面為例,有需求為,當玩家 (操作者) 使用輸入點擊道具時,主遊戲流程想得知玩家選了什麼道具,進而執行該道具所產生的效果。

Diablo III 道具介面

Diablo III 道具介面

經驗不足的開發者,可能會直接將 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。

Reference

沒有留言: