動態編程入門第一節:C# 反射 - Unity 開發者的超級工具箱
動態編程入門第二節:委托與事件 - Unity 開發者的高級回調與通信藝術
上次我們聊了 C# 反射,它讓程序擁有了在運行時“看清自己”的能力。但光能看清還不夠,我們還需要讓代碼能夠靈活地“溝通”和“響應”。這就不得不提到 C# 中另外兩個非常重要的概念:委托 (Delegate) 和 事件 (Event)。
作為 Unity 開發者,你可能每天都在使用它們,比如 Unity UI 按鈕的 OnClick
事件、SendMessage
或 GetComponent<T>().SomeMethod()
等等,它們背后或多或少都離不開委托和事件的思想。今天,我們就來深入探討它們的進階用法,以及它們如何構建起 Unity 中高效、解耦的回調和消息系統。
1. 委托(Delegate):方法的“引用”或“簽名”
簡單來說,委托是一個類型安全的函數指針。它定義了一個方法的簽名(包括返回類型和參數列表),可以引用任何符合這個簽名的方法。一旦委托引用了一個或多個方法,你就可以通過調用委托來執行這些被引用的方法。
1.1 委托的基礎與回顧
你可能已經習慣了使用 Unity 的 UnityEvent
或者直接使用 Action
和 Func
。它們都是委托的體現。
-
定義委托:
// 定義一個委托類型,它能引用一個沒有參數,沒有返回值的函數 public delegate void MyActionDelegate();// 定義一個委托類型,它能引用一個接收一個int參數,返回string的函數 public delegate string MyFuncDelegate(int value);
-
實例化與調用:
using UnityEngine;public class DelegateBasicExample : MonoBehaviour {public delegate void MySimpleDelegate(); // 定義委托void Start(){MySimpleDelegate del; // 聲明委托變量// 引用一個方法 (方法簽名必須與委托匹配)del = SayHello;del(); // 調用委托,等同于調用 SayHello()// 委托可以引用靜態方法del += SayGoodbye; // += 用于添加方法到委托鏈 (多播委托)del(); // 會依次調用 SayHello() 和 SayGoodbye()del -= SayHello; // -= 用于從委托鏈中移除方法del(); // 只會調用 SayGoodbye()}void SayHello(){Debug.Log("Hello from delegate!");}static void SayGoodbye(){Debug.Log("Goodbye from static delegate!");} }
1.2 Action
和 Func
:泛型委托的便捷性
在 C# 3.0 之后,微軟引入了 Action
和 Func
這兩個內置的泛型委托,極大地簡化了委托的定義。
-
Action
: 用于引用沒有返回值的委托。Action
:沒有參數,沒有返回值。Action<T1, T2, ...>
:接收 T1, T2… 類型參數,沒有返回值。- 最多支持 16 個參數。
-
Func
: 用于引用有返回值的委托。Func<TResult>
:沒有參數,返回 TResult 類型。Func<T1, T2, ..., TResult>
:接收 T1, T2… 類型參數,返回 TResult 類型。- 最多支持 16 個參數和 1 個返回值。
示例:
using System; // Action 和 Func 在 System 命名空間
using UnityEngine;public class ActionFuncExample : MonoBehaviour
{void Start(){// Action 示例Action greetAction = () => Debug.Log("Hello using Action!");greetAction();Action<string> printMessage = (msg) => Debug.Log("Message: " + msg);printMessage("This is a test.");// Func 示例Func<int, int, int> addFunc = (a, b) => a + b;Debug.Log("10 + 20 = " + addFunc(10, 20));Func<string> getRandomString = () => Guid.NewGuid().ToString();Debug.Log("Random string: " + getRandomString());}
}
通過 Action
和 Func
,我們幾乎可以滿足所有常見委托簽名的需求,無需再手動定義 delegate
關鍵字。
1.3 匿名方法與 Lambda 表達式:讓委托更簡潔
-
匿名方法: 在 C# 2.0 引入,允許你定義一個沒有名字的方法,直接賦值給委托。
MySimpleDelegate del = delegate() { Debug.Log("I'm an anonymous method!"); }; del();
-
Lambda 表達式: 在 C# 3.0 引入,是匿名方法的進一步簡化和增強,也是現在最常用的寫法。
// 無參數: Action noParam = () => Debug.Log("No parameters!"); noParam();// 單參數: Action<string> oneParam = msg => Debug.Log($"Message: {msg}"); // 如果只有一個參數,可以省略括號 oneParam("Hello Lambda!");// 多參數: Func<int, int, int> add = (a, b) => a + b; Debug.Log($"Add: {add(3, 5)}");// 包含多行代碼: Action multiLine = () => {Debug.Log("First line.");Debug.Log("Second line."); }; multiLine();
Lambda 表達式極大地提高了代碼的可讀性和簡潔性,使得編寫事件回調和 LINQ 查詢變得非常流暢。
2. 事件(Event):基于委托的安全發布/訂閱機制
委托為我們提供了回調的能力,而 事件 (Event) 則是在委托基礎上構建的一種特殊的類型成員,它提供了一種安全的機制來發布和訂閱通知。
事件的核心思想是:發布者(擁有事件的類)只負責“發出通知”,而不知道誰會接收;訂閱者(其他類)只負責“接收通知”,而不需要知道通知來自何方。這種解耦是實現松耦合代碼的關鍵。
2.1 事件的優勢
事件相對于直接暴露委托變量有以下優勢:
- 封裝性: 事件只能在聲明它的類內部被觸發(
Invoke
),外部代碼只能通過+=
和-=
運算符來訂閱或取消訂閱,不能直接賦值或清空整個委托鏈。這防止了外部代碼不小心破壞事件的訂閱列表。 - 安全性: 外部代碼無法得知事件有多少個訂閱者,也無法在未經授權的情況下觸發事件。
2.2 事件的實現與使用
using System;
using UnityEngine;// 事件發布者
public class GameEventManager : MonoBehaviour
{// 聲明一個事件,通常使用 Action 或自定義委托類型public event Action OnPlayerDeath; // 當玩家死亡時觸發public event Action<int> OnScoreChanged; // 當分數改變時觸發,并傳遞新分數// 單例模式,方便全局訪問public static GameEventManager Instance { get; private set; }void Awake(){if (Instance == null){Instance = this;}else{Destroy(gameObject);}}// 外部調用此方法來“發布”或“觸發”事件public void PlayerDied(){// 檢查是否有訂閱者,避免 NullReferenceExceptionOnPlayerDeath?.Invoke(); // C# 6.0 的 ?. 操作符糖,等同于 if (OnPlayerDeath != null) OnPlayerDeath.Invoke();Debug.Log("玩家死亡事件已發布!");}public void ChangeScore(int newScore){OnScoreChanged?.Invoke(newScore);Debug.Log("分數改變事件已發布,新分數: " + newScore);}
}// 事件訂閱者
public class PlayerStats : MonoBehaviour
{private int currentScore = 0;void OnEnable() // 建議在 OnEnable 訂閱,在 OnDisable 取消訂閱{if (GameEventManager.Instance != null){GameEventManager.Instance.OnPlayerDeath += HandlePlayerDeath;GameEventManager.Instance.OnScoreChanged += UpdateScore;Debug.Log("PlayerStats 已訂閱事件。");}}void OnDisable() // 退出時取消訂閱,防止內存泄漏{if (GameEventManager.Instance != null){GameEventManager.Instance.OnPlayerDeath -= HandlePlayerDeath;GameEventManager.Instance.OnScoreChanged -= UpdateScore;Debug.Log("PlayerStats 已取消訂閱事件。");}}void HandlePlayerDeath(){Debug.Log("PlayerStats 收到玩家死亡事件,執行死亡處理邏輯。");// 例如:顯示死亡界面}void UpdateScore(int newScore){currentScore = newScore;Debug.Log($"PlayerStats 收到分數改變事件,當前分數: {currentScore}");// 例如:更新UI顯示}void Update(){// 測試代碼:按下空格鍵觸發玩家死亡事件if (Input.GetKeyDown(KeyCode.Space)){GameEventManager.Instance?.PlayerDied();}// 測試代碼:按下回車鍵改變分數if (Input.GetKeyDown(KeyCode.Return)){GameEventManager.Instance?.ChangeScore(currentScore + 100);}}
}
在這個例子中:
GameEventManager
是事件的發布者,它聲明并觸發OnPlayerDeath
和OnScoreChanged
事件。PlayerStats
是事件的訂閱者,它通過+=
運算符將自己的方法關聯到GameEventManager
的事件上。- 注意
OnEnable
和OnDisable
: 這是 Unity 中管理事件訂閱非常重要的模式。在組件激活時訂閱事件,在組件禁用或銷毀時取消訂閱,可以有效防止因訂閱者被銷毀而發布者仍在觸發事件導致的NullReferenceException
和內存泄漏問題。
3. 委托與反射的結合:從性能問題引出表達式樹
在上一篇教程中,我們提到了反射的性能開銷,特別是 MethodInfo.Invoke()
方法。雖然它能讓我們動態地調用方法,但每次調用都會有不小的運行時性能損耗。
你可能會想,既然委托就是方法的“引用”,我能不能把反射獲取到的 MethodInfo
轉換為一個委托來調用呢?答案是肯定的,而且這正是 表達式樹 出現的重要原因之一。
C# 提供了一個方法 Delegate.CreateDelegate()
,它可以在運行時根據 MethodInfo
創建一個委托。
using System;
using System.Reflection;
using UnityEngine;public class DelegateFromReflectionExample : MonoBehaviour
{public void MyTargetMethod(string msg){Debug.Log("Target method invoked: " + msg);}void Start(){Type type = typeof(DelegateFromReflectionExample);MethodInfo methodInfo = type.GetMethod("MyTargetMethod");if (methodInfo != null){// 嘗試創建委托// 參數1:委托類型 (例如 Action<string>)// 參數2:委托要綁定的對象實例 (如果是靜態方法則為 null)Action<string> myDelegate = (Action<string>)Delegate.CreateDelegate(typeof(Action<string>), this, methodInfo);// 通過委托調用方法myDelegate("Hello from Delegate.CreateDelegate!");// 測量性能差異(簡單粗略測試)MeasurePerformance(methodInfo, this);}}void MeasurePerformance(MethodInfo methodInfo, object instance){int iterations = 1000000; // 100萬次迭代// 1. 直接調用long startTime = System.Diagnostics.Stopwatch.GetTimestamp();for (int i = 0; i < iterations; i++){MyTargetMethod("test");}long endTime = System.Diagnostics.Stopwatch.GetTimestamp();double directCallTime = (double)(endTime - startTime) / System.Diagnostics.Stopwatch.Frequency * 1000;Debug.Log($"直接調用 {iterations} 次耗時: {directCallTime:F2} ms");// 2. 反射 InvokestartTime = System.Diagnostics.Stopwatch.GetTimestamp();for (int i = 0; i < iterations; i++){methodInfo.Invoke(instance, new object[] { "test" });}endTime = System.Diagnostics.Stopwatch.GetTimestamp();double reflectionInvokeTime = (double)(endTime - startTime) / System.Diagnostics.Stopwatch.Frequency * 1000;Debug.Log($"反射 Invoke {iterations} 次耗時: {reflectionInvokeTime:F2} ms");// 3. Delegate.CreateDelegate 編譯后的委托Action<string> compiledDelegate = (Action<string>)Delegate.CreateDelegate(typeof(Action<string>), instance, methodInfo);startTime = System.Diagnostics.Stopwatch.GetTimestamp();for (int i = 0; i < iterations; i++){compiledDelegate("test");}endTime = System.Diagnostics.Stopwatch.GetTimestamp();double compiledDelegateTime = (double)(endTime - startTime) / System.Diagnostics.Stopwatch.Frequency * 1000;Debug.Log($"Delegate.CreateDelegate 委托 {iterations} 次耗時: {compiledDelegateTime:F2} ms");//你會發現:直接調用 > Delegate委托 > 反射Invoke。//Delegate.CreateDelegate創建委托的“一次性”開銷,是小于反射Invoke每次調用的開銷的。//尤其是在多次調用同一方法時,委托的性能優勢會非常明顯。}
}
運行上面的代碼,你會觀察到:
- 直接調用 的性能是最好的。
Delegate.CreateDelegate
創建并調用的委托 性能接近直接調用,遠好于Invoke
。MethodInfo.Invoke()
的性能是最差的。
這是為什么呢?
Delegate.CreateDelegate
在創建委托時,會執行一次性的編譯工作,將 MethodInfo
轉換為一個高效的委托。一旦這個委托被創建,后續的調用就和直接調用方法幾乎一樣快。而 MethodInfo.Invoke()
每次調用都需要進行一系列的運行時檢查和參數裝箱拆箱操作,開銷較大。
在你的 UIManager
腳本中,你正是利用了這種思想,只不過你用的是更強大、更靈活的 表達式樹 來完成這個“一次性編譯”的工作。表達式樹能夠更細粒度地控制委托的生成,實現更復雜的動態調用邏輯。
總結與展望
委托和事件是 C# 中實現回調和解耦的重要機制。
- 委托 讓你能夠像操作變量一樣操作方法,實現了代碼的動態綁定。
- 事件 在委托之上提供了一層封裝,構建了安全、可靠的發布/訂閱通信模型,這在 Unity 中尤其適用于 UI、游戲狀態管理和模塊間通信。
了解并熟練運用它們,將極大地提升你代碼的靈活性、可維護性和擴展性。
然而,當我們需要在運行時根據類型信息動態生成復雜的代碼邏輯,并追求極致的性能時,僅僅依靠 Delegate.CreateDelegate
就不夠了。這就是 表達式樹 大展身手的地方。
在下一篇教程中,我們將深入探索 表達式樹,理解它如何讓我們在運行時像寫代碼一樣“構建代碼”,并將其編譯成高性能的委托,最終揭示我的框架中的 UIManager
中 CacheInitDelegate
方法的原理。
動態編程入門第一節:C# 反射 - Unity 開發者的超級工具箱
動態編程入門第二節:委托與事件 - Unity 開發者的高級回調與通信藝術