C# 中的 Event(事件)详解及其在 Unity 中的衍生

C# 中的 Event(事件)详解及其在 Unity 中的衍生
Everett Rain观察者模式 - 游戏设计模式 Design Patterns Revisited
随便打开电脑中的一个应用,很有可能它就使用了MVC架构, 而究其根本,是因为观察者模式。 观察者模式应用广泛,Java甚至将其放到了核心库之中(java.util.Observer),而C#直接将其嵌入了语法(event关键字)。
观察者模式是应用最广泛和最广为人知的 GoF 模式,但是游戏开发世界与世隔绝,所以对你来说,它也许是全新的。
前言
上面这一段摘自 Bob Nystrom 的《游戏编程模式》一书的“观察者模式”章节。
什么是观察者模式?可以这么认为:在软件设计的世界里,观察者模式如同一座桥梁,连接着系统中高度松耦合的组件。
通过“发布-订阅”的思想,观察者让对象间无需直接通信,仅通过状态变化的通知即可协同工作——当某个对象(发布者)发生状态改变时,所有订阅其变化的观察者(订阅者)都能收到通知并作出响应。
这种设计模式在用户界面开发、游戏逻辑触发、分布式系统通信等领域中,都是解决“解耦”问题的黄金准则。
由于 C# 在设计之初,高度借鉴了 Java 的面向对象编程思想,因此在 C# 中,Event 事件不再是需要开发者手动实现的抽象概念,而是与类成员同级的“第一公民”。
它通过编译器的语法糖,自动处理了委托的组合与访问权限控制,甚至能通过运算符实现直观的订阅与取消订阅。这种高度集成的设计,让开发者得以专注于业务逻辑本身,而非事件机制的底层实现。
本文将会从 C# 中最简单的 Event 事件开始讲解,逐步了解 Event 与委托、Action 的关联,以及将 C# 原生事件与 Unity 编辑器深度绑定的 UnityEvent 类。
本教程测试环境
硬件与系统:MacOS Sequoia 15.3.2 MacBook Pro 2024
软件:Jetbrains Rider 2024.3.4、Unity 2022.3.48f1c1
基本的 Event 使用
首先,我们在 Unity 中新建一个场景,创建第一个脚本 TestingEvents.cs
,并将该脚本绑定在场景中。
在该脚本内,如果需要实现 Event 事件,必须引入 System 库,由于本文的测试方法需要使用到 Unity 的基本操作,因此需要继承自 MonoBehaviour
类,书写基本代码:
1 | using System; |
在上面的代码中,我们实现了以下功能:
-
声明了一个基于 EventHandler 委托的 event 事件,事件的名称为
OnSpacePressed
。 -
通过 Unity 自带的 Update() 函数中,每帧持续检测是否按下空格键,如果按下则执行 if 块内的操作。
-
按下空格键后,执行定义的
OnSpacePressed
事件。
Event 的声明
注意到,我们在这里使用了下面这样一句来声明新 Event:
1 | public event EventHandler OnSpacePressed; |
其中,event
关键字表明这是一个 Event 事件,而 EventHandler
其实是一个 System 库自带的预定义委托类型,其定义如下:
1 | public delegate void EventHandler(object sender, EventArgs e); |
这个委托需要传递两个形参:事件发送者 sender
和一个 EventArgs 类或派生类,后面也会进一步讲解如何利用这个类。
在我们的代码中,我们给 OnSpacePressed
传递其自身 this
为事件的发送者,携带的类为 EventArgs.Empty
(即不携带任何 EventArgs 类):
1 | OnSpacePressed(this, EventArgs.Empty); |
Event 的调用
启动场景,按下空格,会发现出现了 NullReferenceException
报错:
NullReferenceException: Object reference not set to an instance of an object
这表明我们的事件还并没有任何订阅者(观察者),该事件被触发后,没有对应的订阅者调用任何函数。
所以我们需要在调用前进行判断,如果没有订阅者,则不触发该事件。C# 6.0 也为这一操作提供了更加方便的判断方式:
1 | private void Update() |
通过问号 ?
进行判断,关键字 Invoke
调用,其具体实现类似:
1 | private void Update() |
再次启动场景,按下空格,这次就不会出现空引用的报错了。
Event 的监听
进行修改后,虽然代码没有报错,但我们的 OnSpacePressed 事件也完全没有被触发。
如果需要触发该 Event,就要为该事件添加一个订阅者。当事件触发后,订阅者接受到信号,才能执行程序设定好的函数。这里我们在 Start
函数中添加订阅者:
1 | private void Start() |
直接使用 +=
为事件添加一个函数作为订阅者,当事件被触发时,函数 Event_OnSpacePressed
执行。
现在进入场景后,按下空格,就可以看到 SpacePressed!
的 Debug 信息了,由于订阅者在添加后持续存在,所以每次按下空格,都会出现一次 Debug 提示。
当然,对订阅者的编辑操作可以出现在任何地方,比如订阅者触发的函数内部:
1 | private void Start() |
在触发的函数内部再次添加一个订阅者,即该 Event 每触发一次,每个订阅者都会再次添加一个订阅者,每按下一次空格,SpacePressed!
的出现次数都是上次的 2 倍。
也可以通过在触发的函数内部删除自身订阅者,从而实现事件只能被触发一次的功能:
1 | private void Start() |
这里按下一次空格后,程序会执行一次输出,同时 Event_OnSpacePressed
会被删除,接下来再次按下空格不会再次触发该事件。
Event 的跨类使用
前面体现的功能,实际上也可以直接通过函数来实现,那 Event 的特点在哪里?我们创建另一个脚本 TestingSubscriber.cs
,将其与 TestingEvent
挂载在场景中同一个物体下:
1 | using UnityEngine; |
这里从 TestingEvent
类中获取到定义的 OnSpacePressed
事件,并在新脚本中实现一个订阅者,监测到事件触发时,执行 TestingEvents_OnSpacePressed
函数。
同时,将原本的 TestingEvent
中调用的方法删去:
1 | using System; |
现在,两个脚本互相没有连接,定义事件的 TestingEvents
类自身并没有实现任何订阅者,也不知道是否存在订阅者,但还是会在玩家按下空格时发出事件被触发的信号。
同时 TestingSubscriber
只“知道”一个事件名,它只会持续监听这个事件,如果事件被触发,它会执行 TestingEvents_OnSpacePressed
函数。
现在进入场景,按下空格,还是得到了 Subscriber: SpacePressed!
的信息。
这才是 Event 的真正作用:一个事件可以有多个监听者(订阅者),也可以完全没有监听者。它要做的只是在玩家按下空格时告诉所有脚本:“呃,我不知道有谁感兴趣,但是玩家刚刚按下了空格。做你想做的事吧。”
同时,其他监听者可以在听到这句话后做出任何事情,包括实现跳跃、飞行、冲刺或者任何按下空格后会发生的事情,而完全不需要知道是谁发出的信号,也不需要知道其他监听者的存在。这样,就实现了各个模块的完全解耦,也由此出现了事件驱动的开发模式:不同模块之间互不引用,只通过事件的监听和发送来相互沟通。
在事件驱动的模式下,你完全可以直接删除一整个模块,而不需要担心其他模块会出现问题。其他模块依旧会持续监听 / 激活某个事件,哪怕这个事件已经不再会被触发 / 被监听。
EventArgs 的应用
上文提到,EventHandler
是一个 System 库自带的预定义委托类型,这个委托需要传递两个形参:事件发送者 sender
和一个 EventArgs 类或派生类。
这代表,我们可以创建一个自定义类,通过让自定义类继承自 EventArgs 类,实现在 EventHandler 事件中调用自己的类来传递参数。
我们在之前的代码基础上继续修改,添加类 OnSpacePressedEventArgs
并继承自 EventArgs,通过一个公共变量 pressCount
记录按下 Space 的次数:
1 | using System; |
这里我们通过泛型版本的 EventHandler,将自定义的数据类传入,就可以在 Invoke 函数中传入自己的数据类。
在触发事件时,通过创建对象的方式将数据嵌入到事件对象中,这意味着监听者在接收到事件时,可以通过事件内嵌的对象获取到其他脚本提供的数据,从而实现数据的远程接收:
1 | using UnityEngine; |
在监听脚本中,同样将原本接收的 EventArgs.Empty
改为自定义的数据类对象,并在 Debug 信息中使用对象中实现的 pressCount
。
现在进入场景,每次按下空格后,Debug 显示的按下次数都会自增,这样你就掌握了最基本的事件驱动的信息传递。
事件与委托
现在,你实际上已经知道如何使用事件、定义订阅者、通过 EventArgs 派生类来在项目的各个角落之间传递数据。但这一切是怎么实现的?为什么不添加订阅者时激活事件会报错?为什么订阅者要带有两个如此奇怪的形参?恐怕你还是一头雾水。现在我们继续深入,仔细探究 Event 事件系统的实现原理。
当你声明一个事件时,实际上是声明了一个类型为委托的成员变量,这个委托定义了事件处理方法的签名。想要清楚地理解事件与委托的关系,我们就得从委托本身开始。
什么是委托
委托是 C# 面向对象编程的重要组成部分,可以被看作是对方法的引用。换句话说,它是一种类型安全的回调函数,允许你将一个或多个方法作为参数传递给其他方法,并在适当的时机调用这些方法。定义一个委托就像是定义了一个方法签名模板,任何符合这个签名的方法都可以被分配给该委托类型的变量。
注意:在 C# 中,通常将其他编程语言中的 “函数” 称作 “方法”
可能你还是觉得有点模糊,那么看看下面这两个例子。第一个例子是委托的声明与赋值。
1 | public static class Example |
先声明一个不需要传入任何变量的委托 NormalDelegate
,随后通过 normalDelegate
来实现这个委托,就像在一个类里实例化另一个类一样。
然后,你就可以将任何与委托传入值类型相同(在这里是不传入任何变量)的方法赋值给这个委托。
可能你还是不够熟悉?其实,委托是可以指向多个不同的方法的,比如:
1 | private void Main() |
是不是觉得熟悉了一些?没错,这就是向事件添加订阅者的写法:
1 | private void Start() |
第二个例子是通过委托实现回调方法。
1 | public static class Example |
在第二个例子中,首先声明一个名为 Callback 的委托,不传入任何参数。然后将该委托作为参数传入任何方法中,并在方法中使用这个委托。
在这个过程中,UseCallback
方法不知道传入的方法是什么,可能是实现一些算式,也可能是输出一些 Debug 信息。但它完全不需要知道这个传入的方法的具体实现细节,只需要知道这个方法应该被执行。
接下来就可以随意调用这个方法,并传入任意方法作为形参:
1 | private void Start() |
在这个例子中,我们通过 lambda 表达式,在调用这个方法时定义了 Callback()
的具体内容,可以看成:
1 | public void Callback() |
那么 UseCallback()
就变成了:
1 | public static void UseCallback(Callback function) |
当然,你也可以在声明委托时指定委托需要传入的形参:
1 | public static class Example |
在这个情况下,提前定义的委托传入两个 int 变量 1 和 2,但它不需要实现具体的操作,而是将具体的实现交给调用者。在调用 UseCallback
方法时,必须构造或传入一个方法作为参数,我们在这里传入的方法是通过 Debug 信息输出两个整型变量的和。
有了委托,我们便可以将整个方法像参数一般传入另一个方法,大大提高使用者在调用函数时的可操作性。
可能你还是对委托的调用有些陌生,那么我们用另一种写法重写一下 UseCallback
方法:
1 | public static void UseCallback(Callback function) |
这下是不是变得眼熟了?没错,就是我们的 OnSpacePressed
事件的触发方法:
1 | if(Input.GetKeyDown(KeyCode.Space)) |
泛型委托 EventHandler
看完上面的小节,可能你已经看出 OnSpacePressed
与委托的关系了,我们再来看看之前定义的两种事件,分别是使用 Empty 和自定义类的两种事件:(并非完整代码,只是方便进行对比)
1 | // 事件 1 |
不难看出,在调用基于 EventHandler 的事件时,必须传入一个 this
作为事件发送者,以及一个继承自 EventArgs 的类作为方法类。这意味着 EventHandler 本身就是一个委托:
1 | public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e); |
那么我们创建的 OnSpacePressed
也就是一个需要在调用时传入的方法。既然这个方法的声明基于委托,那就必须带有委托的两个参数 object sender
和 TEventArgs e
。
而我们定义、通过 +=
添加给事件的订阅者,正是这个传入的、通过 Invoke
进行调用的方法。
这也就解释了在未添加订阅者时,尝试触发事件会报错 NullReferenceException
的原因:委托不能独立执行,必须传入一个构造好的方法作为参数,就像你不能调用一个需要形参的方法而不传入任何变量。
基于委托的事件
现在我们知道了 EventHandler 就是一个 System 库自带的委托,那我们是不是能直接使用自己的委托来实现一个 event?答案是肯定的:
1 | using System; |
在 TestingEvents
中,我们先声明一个带有一个 float 参数的委托 FloatEventDelegate
,再用这个委托创建一个事件。玩家按下空格时,触发这个事件,同时传递一个参数 5.6。
然后我们在 TestingSubscriber
中传入一个方法作为订阅者,监听事件的触发:
1 | using UnityEngine; |
在这里,FloatEvent
接收到传递的参数后,直接通过 Debug 信息打印出参数。
进入场景,按下空格,Debug 即会输出在订阅者中实现的 Float event received:
和在事件中定义的值 5.6
。
简化委托 Action
好了,你已经学会通过委托来创建事件,并且可以传递任何你想传递的参数,但这样还是显得有些麻烦。
在创建事件之前,你还需要提前声明这个委托,并且需要给委托指定传入的参数和参数名称,这样在参数较多的情况下会略显复杂:
1 | public delegate void MassEventDelegate( |
好在 C# 已经给我们提供了一个更加简单的声明委托的方法:Action<T>
。
Action<T>
是 .NET 框架提供的预定义委托类型之一,用于简化无需返回值的方法引用或匿名方法的使用。
其代表一个不带参数且没有返回值的方法的委托。其最简单的形式是 Action
,它对应于一个无参数且返回类型为 void 的方法。而 Action<T1, T2, ..., Tn>
则是带有参数但没有返回值的方法的委托,其中 T1 到 Tn 表示参数的类型。
可能有点难听明白,我们举一个代码作为例子,将上面的 OnMassEvent
进行简化:
1 | public event Action<float, float, int, int, bool, string, string, double, double> OnMassEvent; |
没错,只需要这样一句,并且不需要指定变量名称。
现在我们使用 Action 来实现我们的事件,给事件传入三个不同类型的参数:
1 | using System; |
同时创建一个订阅者,负责通过 Debug 输出三个变量的值:
1 | using UnityEngine; |
我想你已经知道会输出什么了:
Action event received: true , float = 5.6 , int = 3
UnityEvent 类
在上面的教程中,虽然我们一直在使用 Unity 进行测试,但实际上测试的 Event 事件均为 C# 的原生 Event。事实上,Unity 编辑器内部也集成有一个完整的事件系统,也就是 UnityEvent 类。
UnityEvent 类基于 Event 实现,在实现上面的基础功能的前提下,与 Unity 编辑器实现高度集成,对开发者而言使用更加方便,现在我们来实现一个 UnityEvent。
要实现 UnityEvent 事件的创建,必须要先引入 UnityEngine.Events 库。书写代码:
1 | using UnityEngine; |
这里定义了一个 OnUnityEvent
,同时设定其在按下空格时触发。
现在转到编辑器,查看我们的 TestingEvents
脚本,会发现长得不一样了:
这就是 UnityEvent 的实用之处,现在我们在 TestingSubscriber
中写几个方法来供其使用:
1 | using UnityEngine; |
不需要任何其他的函数来将这些订阅者函数绑定至事件,因为这些都可以由我们手动完成。
现在回到编辑器,点击 On Unity Event()
右下角的加号,手动添加一个订阅者,并将我们的 TestingSubscriber
拖到左下角的框内。
此时,点击 No Function
,并在其中找到我们拖进去的 TestingSubscriber
,你就能找到刚刚创建的所有方法:
凭喜好添加刚刚创建的方法并设定参数:
进入场景,按下空格,就会发现刚刚添加的方法被全部激活:
总结
C# 的事件系统是一种强大的工具,它可以极大地提高代码的可读性、可维护性和可扩展性,为事件驱动编程提供了充足的实现基础。
通过深入学习和灵活运用 C# 事件,我们可以更加高效地实现类与对象间的通信和交互。也希望本文提供的知识和示例能够激发读者对 C# 事件和 UnityEvent 类的兴趣,并在实际开发中有所应用。