2.7 委托 (Delegate) 与事件 (Event)


文档摘要

2.7 委托 (Delegate) 与事件 (Event) 二、C# 脚本编程父章节领域 - 2.7 委托 (Delegate) 与事件 (Event) 在 Unity 3D 游戏开发中,C# 脚本扮演着至关重要的角色。它驱动着游戏逻辑、交互行为以及各种系统运作。为了构建灵活、可维护和可扩展的游戏架构,深入理解 C# 的高级特性至关重要。在本章节中,我们将聚焦于 C# 中两个强大的概念:委托 (Delegate) 与 事件 (Event)。它们是实现解耦、事件驱动编程以及构建模块化系统的关键工具。 2.7.1 委托 (Delegate):方法的回调与灵活的调用 2.7.1.

2.7 委托 (Delegate) 与事件 (Event)

二、C# 脚本编程父章节领域 - 2.7 委托 (Delegate) 与事件 (Event)

在 Unity 3D 游戏开发中,C# 脚本扮演着至关重要的角色。它驱动着游戏逻辑、交互行为以及各种系统运作。为了构建灵活、可维护和可扩展的游戏架构,深入理解 C# 的高级特性至关重要。在本章节中,我们将聚焦于 C# 中两个强大的概念:委托 (Delegate)事件 (Event)。它们是实现解耦、事件驱动编程以及构建模块化系统的关键工具。

2.7.1 委托 (Delegate):方法的回调与灵活的调用

2.7.1.1 委托的概念:函数指针的类型安全升级

在 C 和 C++ 等语言中,函数指针允许我们将函数作为参数传递给其他函数,或者存储在数据结构中并在稍后调用。这种机制提供了极大的灵活性,但同时也存在类型安全方面的隐患。C# 的 委托 (Delegate) 正是为了解决这个问题而诞生的。

委托本质上是一种类型安全的函数指针。 它可以看作是一个指向一个或多个方法的引用,并且它定义了这些方法的签名(参数列表和返回类型)。这意味着,当你声明一个委托类型时,你实际上是在定义一种“方法类型”,任何符合该签名的方法都可以被赋值给这个委托类型的变量。

我们可以将委托想象成一个“合同”或者“协议”,它规定了可以接受哪些类型的方法。一旦我们创建了一个委托实例,我们就可以像调用普通方法一样调用它,而实际上,我们是在调用委托实例所引用的方法。

2.7.1.2 委托的声明与实例化

1. 委托的声明

委托的声明类似于方法的声明,但它使用 delegate 关键字,并且不包含方法体。委托的声明定义了委托类型,指定了它能够引用的方法的签名。

// 声明一个委托类型,名为 MyDelegate,它接受一个 int 参数,没有返回值 delegate void MyDelegate(int number); // 声明另一个委托类型,名为 CalculateDelegate,它接受两个 float 参数,返回一个 float 值 delegate float CalculateDelegate(float a, float b);

语法结构:

delegate 返回类型 委托名称(参数列表);

2. 委托的实例化

声明委托类型后,我们需要创建委托类型的实例才能使用它。委托的实例化可以通过以下两种方式进行:

  • 使用方法名直接赋值: 当方法签名与委托类型匹配时,可以直接将方法名赋值给委托实例。
public class DelegateExample : MonoBehaviour { void Start() { // 实例化 MyDelegate 委托,引用 PrintNumber 方法 MyDelegate myDel = PrintNumber; myDel(10); // 调用委托,实际上会执行 PrintNumber(10) // 实例化 CalculateDelegate 委托,引用 Add 方法 CalculateDelegate calcDel = Add; float result = calcDel(5.0f, 3.0f); // 调用委托,实际上会执行 Add(5.0f, 3.0f) Debug.Log("计算结果: " + result); } void PrintNumber(int num) { Debug.Log("打印数字: " + num); } float Add(float a, float b) { return a + b; } }
  • 使用匿名方法或 Lambda 表达式: 委托还可以引用匿名方法或 Lambda 表达式,这在需要简洁地定义回调逻辑时非常有用。
public class DelegateExample : MonoBehaviour { void Start() { // 使用匿名方法实例化 MyDelegate MyDelegate anonymousDel = delegate(int num) { Debug.Log("匿名方法打印数字: " + num); }; anonymousDel(20); // 使用 Lambda 表达式实例化 CalculateDelegate CalculateDelegate lambdaDel = (a, b) => a * b; float lambdaResult = lambdaDel(2.5f, 4.0f); Debug.Log("Lambda 表达式计算结果: " + lambdaResult); } // ... (PrintNumber 和 Add 方法与之前相同) ... }

2.7.1.3 委托的调用与多播委托

1. 委托的调用

委托实例的调用方式与普通方法调用非常相似,直接使用委托实例名加上参数列表即可。

MyDelegate myDel = PrintNumber; myDel(30); // 调用委托实例 myDel

2. 多播委托 (Multicast Delegate)

一个委托实例可以引用多个方法,这被称为 多播委托。当调用多播委托时,它会按照添加顺序依次调用所有引用的方法。

可以使用 +=-= 运算符来添加和移除委托实例所引用的方法。

public class MulticastDelegateExample : MonoBehaviour { delegate void StringDelegate(string message); StringDelegate multicastDel; void Start() { multicastDel += MethodA; // 添加 MethodA 到委托链 multicastDel += MethodB; // 添加 MethodB 到委托链 multicastDel += MethodA; // 再次添加 MethodA multicastDel("Hello, Multicast Delegate!"); // 调用多播委托,会依次执行 MethodA, MethodB, MethodA multicastDel -= MethodB; // 移除 MethodB Debug.Log("移除 MethodB 后..."); multicastDel("After removing MethodB"); // 调用多播委托,只会执行 MethodA, MethodA } void MethodA(string msg) { Debug.Log("Method A: " + msg); } void MethodB(string msg) { Debug.Log("Method B: " + msg); } }

注意:

  • 多播委托的返回类型通常为 void。如果委托有返回值,只有最后一个被调用的方法的返回值会被返回给调用者。

  • 如果多播委托链中的某个方法抛出异常,后续的方法将不会被执行。为了避免这种情况,可以使用 GetInvocationList() 方法获取委托链中的方法列表,并逐个安全地调用。

2.7.1.4 Unity 中的委托应用场景

在 Unity 中,委托的应用非常广泛,尤其在以下场景中:

  • 事件系统: 事件是基于委托实现的,用于实现对象间的松耦合通信。我们将在下一节详细介绍事件。

  • UI 事件处理: Unity UI 系统中的按钮点击、滑动条值改变等事件,底层都是基于委托实现的。

  • 协程 (Coroutine) 的回调: 协程可以使用委托作为完成时的回调函数。

  • 自定义游戏事件: 可以使用委托创建自定义的游戏事件,例如角色死亡事件、关卡开始事件等。

  • 算法的灵活配置: 可以将算法的不同步骤或策略封装成委托,在运行时动态配置算法行为。

代码示例:使用委托实现简单的回调

假设我们有一个计时器类,需要在计时结束后通知调用者。可以使用委托来实现回调机制。

using UnityEngine; using System; public class Timer : MonoBehaviour { public delegate void TimerCallback(); // 定义委托类型,无参数,无返回值 public TimerCallback OnTimerComplete; // 声明委托变量 public float duration = 3f; private float timer = 0f; void Update() { timer += Time.deltaTime; if (timer >= duration) { timer = 0f; if (OnTimerComplete != null) // 确保委托不为空才调用 { OnTimerComplete(); // 调用委托,触发回调 } } } } public class TimerUser : MonoBehaviour { public Timer timerComponent; void Start() { timerComponent.OnTimerComplete += TimerFinishedCallback; // 订阅计时器完成事件 } void TimerFinishedCallback() { Debug.Log("计时器计时结束!"); // 在计时器结束后执行其他操作,例如播放音效、显示提示信息等 } }

在这个例子中,Timer 类定义了一个 TimerCallback 委托类型和一个 OnTimerComplete 委托变量。TimerUser 类通过 += 运算符将 TimerFinishedCallback 方法添加到 timerComponent.OnTimerComplete 委托链中,从而实现了计时器完成时的回调。

2.7.1.5 Mermaid 图示:委托的结构

图示解释:

  • Delegate Type Definition (委托类型定义): 定义委托的签名,包括返回类型和参数列表。

  • Represents a method type (代表一种方法类型): 委托类型本质上定义了一种方法类型。

  • Delegate Instance (委托实例): 委托类型的变量,可以持有对方法的引用。

  • Holds method references (持有方法引用): 委托实例内部维护着一个或多个方法的引用。

  • Can be Multicast (可以是多播的): 委托实例可以引用多个方法,形成多播委托。

  • Method (方法): 实际执行逻辑的代码块。

  • Implements logic (实现逻辑): 方法内部包含具体的业务逻辑。

  • Matches Delegate Signature (匹配委托签名): 方法的签名必须与委托类型定义的签名一致,才能被委托引用。

  • References (引用): 委托实例引用一个或多个方法。

  • Client Code (客户端代码): 使用委托的代码,例如 TimerUser 类。

  • Calls Delegate Instance (调用委托实例): 客户端代码像调用普通方法一样调用委托实例。

  • Invokes Referenced Methods (调用引用的方法): 委托实例在被调用时,会依次调用其引用的方法。

2.7.2 事件 (Event):发布-订阅模式的优雅实现

2.7.2.1 事件的概念:受控的委托访问与发布-订阅模式

事件 (Event) 是基于委托构建的,用于实现发布-订阅 (Publish-Subscribe) 设计模式。 事件提供了一种更安全、更封装的方式来管理委托的访问和调用。

在没有事件的情况下,如果直接暴露委托变量(例如上一节 Timer 例子中的 OnTimerComplete),外部代码可以直接赋值、清空甚至直接调用委托,这可能会破坏对象的内部状态和逻辑。事件通过限制对委托的直接访问,只允许外部代码进行订阅(添加方法到委托链)和取消订阅(移除方法),而事件的触发(调用委托)则只能由事件的声明者内部控制。

发布-订阅模式的核心思想是将事件的发布者 (Publisher) 和订阅者 (Subscriber) 解耦。 发布者负责触发事件,而订阅者负责监听事件并在事件发生时执行相应的处理逻辑。发布者不需要知道有哪些订阅者,订阅者也不需要知道发布者的具体实现细节,它们之间通过事件进行间接通信。

2.7.2.2 事件的声明与使用

1. 事件的声明

事件的声明使用 event 关键字,后面跟委托类型和事件名称。

public class EventPublisher : MonoBehaviour { // 声明一个事件,类型为 MyDelegate (之前定义的委托类型) public event MyDelegate OnValueChanged; private int value = 0; public int Value { get { return value; } set { if (value != value) { value = value; if (OnValueChanged != null) // 触发事件前检查是否有人订阅 { OnValueChanged(value); // 触发事件,通知订阅者 } } } } }

语法结构:

public event 委托类型 事件名称;

2. 事件的订阅与取消订阅

外部代码可以使用 += 运算符订阅事件,使用 -= 运算符取消订阅事件。

public class EventSubscriber : MonoBehaviour { public EventPublisher publisher; void Start() { publisher.OnValueChanged += HandleValueChanged; // 订阅 OnValueChanged 事件 } void OnDestroy() // 组件销毁时取消订阅,防止内存泄漏 { publisher.OnValueChanged -= HandleValueChanged; // 取消订阅 OnValueChanged 事件 } void HandleValueChanged(int newValue) { Debug.Log("Value changed to: " + newValue); // 在值改变时执行相应的处理逻辑 } }

3. 事件的触发 (Raise Event)

事件的触发(也称为引发事件或发布事件)只能在事件声明的类内部进行。触发事件的语法与调用委托实例相同,即使用事件名称加上参数列表。

在上面的 EventPublisher 例子中,当 Value 属性的 set 访问器被调用并且值发生改变时,会检查 OnValueChanged 事件是否为空(是否有订阅者),如果不为空,则调用 OnValueChanged(value) 触发事件,通知所有订阅者。

注意:

  • 事件只能在声明它的类内部触发。外部代码只能订阅和取消订阅事件,不能直接触发事件。

  • 事件的访问修饰符通常为 public,以便外部代码可以订阅。

  • 为了避免空引用异常,在触发事件前,应该始终检查事件是否为空(是否有订阅者)。

2.7.2.3 Unity 中的事件应用场景

事件在 Unity 中被广泛用于构建解耦的、可扩展的游戏系统。常见的应用场景包括:

  • UI 事件: Unity UI 系统中的 Button、Toggle、Slider 等组件的事件,例如 onClick, onValueChanged,都使用了事件机制。

  • 游戏对象交互事件: 可以使用事件来处理游戏对象之间的交互,例如碰撞事件、触发器事件、自定义交互事件等。

  • 状态管理事件: 可以使用事件来通知游戏状态的改变,例如游戏开始、游戏结束、玩家死亡、关卡切换等。

  • 自定义游戏事件系统: 可以构建自定义的游戏事件系统,用于在不同的游戏模块之间进行通信,实现模块间的解耦。

代码示例:使用事件实现简单的敌人死亡事件

假设我们有一个敌人类,当敌人死亡时,需要通知其他游戏对象(例如玩家、UI 管理器、音效管理器)。可以使用事件来实现敌人死亡事件。

using UnityEngine; using System; public class Enemy : MonoBehaviour { public event Action<Enemy> OnEnemyDeath; // 定义敌人死亡事件,参数为死亡的敌人对象 public float health = 100f; public void TakeDamage(float damage) { health -= damage; if (health <= 0) { Die(); } } void Die() { Debug.Log("敌人死亡!"); if (OnEnemyDeath != null) // 触发敌人死亡事件 { OnEnemyDeath(this); // 将自身作为参数传递给事件订阅者 } Destroy(gameObject); // 销毁敌人对象 } } public class Player : MonoBehaviour { public Enemy targetEnemy; void Start() { targetEnemy.OnEnemyDeath += HandleEnemyDeath; // 订阅敌人死亡事件 } void OnDestroy() { targetEnemy.OnEnemyDeath -= HandleEnemyDeath; // 取消订阅 } void HandleEnemyDeath(Enemy deadEnemy) { Debug.Log("玩家收到敌人死亡事件,死亡的敌人是: " + deadEnemy.gameObject.name); // 在敌人死亡后执行玩家相关的逻辑,例如增加分数、播放动画等 } } public class UIManager : MonoBehaviour { public Enemy targetEnemy; void Start() { targetEnemy.OnEnemyDeath += HandleEnemyDeathUI; // 订阅敌人死亡事件 } void OnDestroy() { targetEnemy.OnEnemyDeath -= HandleEnemyDeathUI; // 取消订阅 } void HandleEnemyDeathUI(Enemy deadEnemy) { Debug.Log("UI 管理器收到敌人死亡事件,更新 UI 显示!"); // 在敌人死亡后更新 UI 显示,例如显示击杀提示、更新敌人数量等 } }

在这个例子中,Enemy 类声明了一个 OnEnemyDeath 事件,类型为 Action<Enemy> 委托(表示接受一个 Enemy 类型参数,无返回值)。PlayerUIManager 类都订阅了 targetEnemyOnEnemyDeath 事件,并在事件发生时执行相应的处理逻辑。当敌人死亡时,Enemy 类内部触发 OnEnemyDeath 事件,通知所有订阅者。

2.7.2.4 Mermaid 图示:事件的结构与发布-订阅模式

图示解释:

  • Event Declaration (事件声明): 使用 event 关键字声明事件。

  • Based on Delegate (基于委托): 事件是基于委托类型构建的。

  • Controlled Delegate Access (受控的委托访问): 事件限制了对底层委托的直接访问,只允许订阅和取消订阅。

  • Event Publisher (事件发布者): 声明和触发事件的类,例如 Enemy 类。

  • Declares and Triggers Event (声明和触发事件): 发布者负责声明事件并在特定条件下触发事件。

  • Event Subscriber (事件订阅者): 订阅事件并处理事件通知的类,例如 PlayerUIManager 类。

  • Subscribes to Event (+ =) (订阅事件): 订阅者使用 += 运算符订阅事件。

  • Handles Event Notification (处理事件通知): 订阅者在事件发生时执行相应的处理逻辑。

  • Event Trigger (Raise) (事件触发): 发布者在内部触发事件,例如 Enemy.Die() 方法中触发 OnEnemyDeath 事件。

  • Event Subscription (+ =) (事件订阅): 订阅者通过 += 运算符订阅事件,建立订阅关系。

  • Notification (Delegate Invocation) (通知): 事件触发时,会调用委托链中的所有方法,通知所有订阅者。

2.7.3 委托与事件的对比与选择

特性 委托 (Delegate) 事件 (Event)
本质 类型安全的函数指针 基于委托的发布-订阅机制
访问控制 可以直接赋值、调用、清空委托实例,访问权限开放 只能在声明类内部触发,外部只能订阅和取消订阅,访问权限受限
用途 方法回调、算法配置、自定义逻辑封装等 实现发布-订阅模式、对象间解耦通信、事件驱动编程
安全性 相对较低,容易被外部代码误用或滥用 相对较高,提供更好的封装和安全性,防止外部代码破坏内部状态
适用场景 需要灵活的方法回调和配置,且对访问控制要求不高时 需要实现对象间解耦通信、构建事件驱动系统、需要控制委托访问权限时

如何选择:

  • 优先使用事件 (Event): 在大多数情况下,尤其是需要实现对象间通信、事件驱动编程以及需要控制委托访问权限的场景下,事件是更安全、更规范的选择。事件能够更好地封装委托,防止外部代码误用,并符合发布-订阅设计模式的原则。

  • 在特定场景下使用委托 (Delegate): 在一些特定的场景下,例如需要更灵活的方法回调、算法配置,或者对访问控制要求不高,且代码编写者能够确保委托的正确使用时,可以直接使用委托。例如,在某些工具类或库的内部实现中,为了追求更高的灵活性,可能会直接使用委托。

总结:

委托和事件都是 C# 中强大的特性,它们为我们提供了灵活的方法回调和事件驱动编程的能力。事件是在委托的基础上进行封装和改进的,提供了更好的安全性、封装性和设计模式支持。 在 Unity 游戏开发中,我们应该优先考虑使用事件来构建解耦、可维护和可扩展的游戏架构。理解委托是理解事件的基础,掌握它们的应用能够显著提升你的 C# 脚本编程能力,构建更加健壮和灵活的 Unity 游戏系统。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U