前置知识:
C#委托
学习事件之前,最好先对委托有个大致的认识,因为事件是基于委托的,这说明事件和委托有些共同点,事件又有它额外的一些特点。关于事件和委托的联系也会在本篇博客进行介绍。
掌握了委托和事件后,相当于跨过了 C# 语言学习的一个坎,合理地使用委托和事件,能够大大地提高开发效率。
本篇参考了B 站 up 主 BeaverJoe 对 C# 事件视频讲解,我也向大家强烈推荐这位 up 主,每一个视频质量都是挺不错的。
B 站链接:
https://www.bilibili.com/video/BV1sz4y1f7Un
https://www.bilibili.com/video/BV19D4y1d7f1
还有刘铁猛老师的经典 C# 教程:
https://www.bilibili.com/video/BV13b411b7Ht
📕事件概念
简要地来说,C# 的事件带有通知机制。也就是一件事发生后会去通知其他事物。
不过这么理解显得概念还是比较泛,因此我们需要进一步拓展,在把握“通知”这一个要点时,还需要关注两点:
- 通知了谁
- 通知以后会具体发生什么事
举个例子:
一个 CSDN 博主被好几个粉丝关注。每当博主发了一条新的博客,关注博主的粉丝们就会收到“博主博客更新”这一通知,然后这些粉丝就会做出他们各自的响应(比如浏览博文,点赞之类的)。
这句话有2个主要的对象:博主和粉丝
此外还有几个关键词:
“关注” 代表了博主和粉丝之间的关系(也可以叫做订阅)
“通知” 由博主发布,通知发给了订阅了博主的粉丝
“响应” 粉丝收到博主的通知后做的事
那么现在其实就可以总结出事件比较完整的概念。这里用程序的话语来概括:
一个类或者对象中的事件发生后会通知订阅了这个事件的其他类、其他对象。别的类、对象在接收到这个通知之后就会纷纷作出他们各自的响应。
这里需要注意的是事件是类的成员。可以理解为事件会隶属于一个主体,比如发博客这件事隶属于博主,只有博主才拥有“发博客”这个事件。 C# 可以作为面向对象编程语言,面向对象编程本身就是对现实世界的模拟,将现实世界具体的东西抽象成类,比如博主就可以抽象成博主类,因为博主能发博文,用代码表示就是博主类有个“发博客”的事件。事件作为成员起到通知别的类和成员的作用。博主发博客的事件触发后就会通知到关注他的粉丝。
与委托不同,委托是种类,而事件是类里面的一个成员。
由此我们引出“事件模型”的概念,帮助大家更好地理解事件。
🤖事件模型
🔍五个步骤
- 我(类)要有一个事件成员
- 一群别的类关注、订阅我的事件
- 我的事件发生了
- 关心的类被一次性通知到,通知顺序就是订阅事件的顺序
- 被通知到的人,拿着事件参数(也可以没有参数),作出响应
🔍五个组成部分
- 事件的拥有者 [类]
- 事件 [ event 关键字修饰 ]
- 事件响应者 [ 类)
- 事件处理器 (事件响应者的成员方法)
- 事件订阅(+=操作符)
基本和之前描述的事件概念可对应,大家可以注意到在“五个步骤“中有一个事件参数的概念,这个其实也很好理解,有时候事件发生时需要传给订阅者一些信息,从程序的角度来说就是传递参数。
举一个游戏中的例子:
购物&出售系统 : 隶属于玩家(事件的拥有者)的购买事件触发了之后,购买事件会通知关注这个事件的类(商店老板,也就是事件的响应者),然后商店老板根据玩家购买的内容(事件参数)去采取相应的行动(响应、处理事件),将玩家选中的物品添加到玩家的背包中(这个行为被封装在老板的一个方法中,这个方法也叫做事件处理器 EventHandler)
总结来说:事件订阅者会为事件配备上事件触发后的响应方法,这个响应方法也叫事件处理器,然后当事件发生时事件的拥有者将事件参数(事件相关信息)通知给事件的订阅者,订阅者随之作出响应。
当然,通知也可以不带任何事件参数。这时在订阅者看来,就是纯粹的一件事情发生了。
🔍订阅详解
作为事件模型的组成部分之一,订阅关系是很重要的。它规定了以下几点:
1) 一个事件发生后通知的一定是订阅了此事件的对象们。没订阅此事件的对象则不会被通知到。
2)具体哪个事件处理器会处理这个事件。事件处理器是事件响应者的成员方法,那一个类可能会有很多个成员方法,怎么在事件发生时调用我们想触发的那个方法呢?我们就要把对应的响应方法提供给事件。
3)约束了事件与事件处理器之间的关系。一个事件的发生过程中有两个对象:事件拥有者和事件响应者。那么这个约束既规定了事件拥有者能发送什么样的事件给事件的响应者,也规定了事件响应者能提供什么样的事件处理器来响应收到的消息。也就是说事件响应者的事件处理器必须和这个约束匹配上,才能订阅事件。
这一点是不是和委托有点类似,委托和方法也要遵循一定的约束才能匹配。那事件和委托的关系究竟是什么样的呢?
🔍事件是基于委托的
事件是基于委托的原因有2点:
1)就是刚刚说的,事件要和事件发生时所触发的方法(也叫事件处理器)遵循一定的约束才能匹配。这跟委托和方法要类型兼容是类似的。所以事件需要使用委托类型来做一个约束,约束了和规定的委托相匹配(返回值和参数列表一样)的事件处理器才能订阅事件。
2)一个事件的触发会伴随多个响应。响应就是事件订阅者的事件处理器,也叫成员方法,那么总要有一个地方来储存这些事件处理器。而委托正好能封装方法。
所以实现事件需要用委托作为底层的支持,也就是说事件是基于委托的。
那为什么有了委托,C#还要有事件这一语法呢?我们通过事件的语法来解释这一问题。
😊事件的声明和使用(完整格式)
事件的声明有完整格式和简略格式。平时我们基本用的都是简略的格式,不过这里也介绍一下完整格式,为了帮助大家更好地理解委托与事件的关系。
初次学习事件的语法时,建议大家再好好地理解一下事件模型。因为对照事件模型,我们能够轻松地理解事件的语法。
这里我们用一个 unity 脚本来展示,简单地模拟商店系统,就是玩家支付后店家收到支付的金额。
因为事件是基于委托的,所以我们要先声明委托:
public delegate void OnOrderEventHandler(float price);
这个委托可以匹配无参,有1个 float 类型参数的方法,将金额作为参数传递。
小 tip:如果一个委托是为事件准备的,那么它有一个命名规范:在事件名后加上 EventHandler 。正如刚刚声明的,事件名是 OnOrder,那么为该事件准备的委托就命名为 OnOrderEventHandler
然后先声明委托类型的字段,再声明事件,声明事件需使用 event 关键字:
//声明一个委托字段
private OnOrderEventHandler onOrderEventHandler;
//声明事件的完整格式。可以看出事件是委托字段的一个包装器,正如属性是字段的包装器一样
//因为要让其他类能够访问这个私有委托,所以事件声明为public
public event OnOrderEventHandler OnOrder
{
add //添加事件处理器
{
onOrderEventHandler += value; //value是关键字,指代之后传进来的事件处理器
}
remove //移除事件处理器
{
onOrderEventHandler -= value;
}
}
可以看到,事件的声明有点像属性的声明。只不过属性的声明是为一个字段提供 get 和 set 构造器,而事件的声明是为委托字段提供 add 和 remove 构造器,相当于提供添加和移除方法的功能。也就是说,事件相当于委托的包装器。这个待会儿解释。
下面是完整的脚本:
public delegate void OnOrderEventHandler(float price);
//事件拥有者
public class Customer
{
//声明一个委托字段
private OnOrderEventHandler onOrderEventHandler;
//声明事件的完整格式。可以看出事件是委托字段的一个包装器,正如属性是字段的包装器一样
public event OnOrderEventHandler OnOrder
{
add //添加事件处理器
{
onOrderEventHandler += value; //value是关键字,指代之后传进来的事件处理器
}
remove //移除事件处理器
{
onOrderEventHandler -= value;
}
}
public void Pay(float price)
{
if (onOrderEventHandler != null)
{
onOrderEventHandler(price); //通过委托触发事件
}
}
}
//事件响应者
public class Merchant
{
//事件处理器
public void ReceiveMoney(float price)
{
Debug.Log($"收到{price}元");
}
}
public class PurchaseSimulator : MonoBehaviour
{
Customer customer = new Customer();
Merchant merchant = new Merchant();
void Start()
{
customer.OnOrder += merchant.ReceiveMoney; //Merchant的事件处理器订阅了Customer的OnOrder事件
customer.Pay(10);//触发事件
}
}
在 Start 方法中进行事件注册,然后触发事件。
运行结果:
😮事件相较于委托的独特之处
之前说了,事件是委托字段的包装器。包装器为委托提供了保护和限制作用。我们拿到了一个委托字段后,可以在任何地方去绑定与之类型兼容的方法,也可以在任何地方调用。可是将委托封装进事件之后,事件就只对外提供添加和移除的操作。也就是其他类只能通过 “+=” 和 "-="来为包装的委托添加和移除方法,但是不再有权利去调用这个委托了。像刚刚代码中的 Start 方法中:
customer.OnOrder += merchant.ReceiveMoney;
OnOrder 是 Customer 类的一个事件,这个事件包装着私有的 onOrderEventHandler 委托字段。那么我可以在另一个类 PurchaseSimulator中通过 “+=” 为 Customer 的 OnOrder 事件添加事件处理器,相当于把方法添加给包装的委托字段。但是我无法这么做:
customer.OnOrder(10);
会报这个错:
因此我无法在别的类去调用 Customer 类的事件。可如果是单纯的委托字段是可以实现在别的类去调用当前类的委托。比如我把刚刚的委托字段改为 public ,那么这种写法是不报错的:
customer.onOrderEventHandler(10);
这就说明事件为委托字段提供了保护功能,使得在类的外部不能主动触发当前类的事件,而是只能为包装的委托添加或者移除事件处理器。
而且使用多播委托时还有一个缺点,就是我为委托添加方法时如果一不小心将“+=” 写成了 “=”,会将这个委托之前所封装的所有方法全覆盖掉,可是事件不允许用 “=” 把方法传给事件,它只提供了 “+=” 和 “-=”
这便是使用事件的优点。
😄事件的声明和使用(简略格式)
其实事件的声明有种简略的声明格式,不过绝大多数人就是使用简略格式,完整格式反而鲜为人知。
原先声明事件是这样的:
//声明一个委托字段
private OnOrderEventHandler onOrderEventHandler;
//声明事件的完整格式。可以看出事件是委托字段的一个包装器,正如属性是字段的包装器一样
public event OnOrderEventHandler OnOrder
{
add //添加事件处理器
{
onOrderEventHandler += value; //value是关键字,指代之后传进来的事件处理器
}
remove //移除事件处理器
{
onOrderEventHandler -= value;
}
}
这一步可以直接浓缩成一行:
public event OnOrderEventHandler OnOrder;
因为现在没有手动声明委托类型的字段了,所以对 Customer 类的代码做些小修改:
//事件拥有者
public class Customer
{
//声明事件的简略格式。像字段一样声明
public event OnOrderEventHandler OnOrder;
public void Pay(float price)
{
if (OnOrder != null)
{
OnOrder(price); //通过委托触发事件
}
}
}
这样声明,会让人有种“事件就是一种特殊的委托类型的字段,只不过加了个 event 关键字”的错觉。像我之前就是直接学习事件的简略声明格式,导致也会产生这种认知。
但当我们学了完整格式后,其实就知道事件只是委托的包装器,它不是特殊的委托类型的字段。简略格式的声明呢,只是让事件像一个字段(field-like),这是C#官方文档的原词。可它毕竟是“像”,但不能等同于“是”。虽然声明形式更加简洁了,但是我们要在心里清楚事件的底层还是装了一个委托,只是此时我们并不用手动去声明这个委托,因为系统在背后会帮我们准备好。
然后还有一点,大家还记得这张截图吗:
这里说事件只能出现在 += 和 -= 的左边,但是在刚刚的代码里我们在判断事件是否为 null 时用了 != 。这个只能是“迫不得已”的做法,根据刘铁猛老师的说法,这是微软在设计 C# 语法糖时出现的矛盾,所以应该是官方的锅,大家不必过于纠结。😂
在 BeaverJoe 和 刘铁锰老师的视频中,用了 EventHandler 作为事件包装的委托,还有 EventHandler 所相关联的参数类 EventArgs。这两个是 C# 已经为我们提供好的了,可以涵盖所有类型的方法,具体的用法我放到了《 unity 事件管理中心》这一章去说明。链接:unity 事件管理中心
不过在看这篇文章之前,建议大家先看看观察者设计模式:观察者模式(结合C# unity)
委托与事件系列:
C#委托(结合 Unity)
C#事件(结合 Unity)
观察者模式(结合C# Unity)
Unity 事件管理中心
事件番外篇:UnityEvent
Unity 事件番外篇:事件管理中心(另一种版本)