十四、事件
Covers:委托与事件
概念
事件 在真实世界中无处不在,你可能不知道 C# 中事件的概念,但你一定了解这个词的本义。我们抛开编程不谈,先考察一下“事件”的逻辑性质。
你的朋友发来一条信息,约你去打球。关于这一事件,我们能总结出事件 3 个要素:
- 你的朋友是 事件的发起者,这件事是由他引起的,换言之,在事件发生之前,你的朋友就是一个潜在的事件发生器;
- 你是事件的 接收者,这个事件对你产生了作用;
- 事件的内容 是“下午约你去打球”,换言之,这个事件 携带的数据 是这个消息。
这个事件还隐含着两个未在字面上体现的要素:
- 你在收到信息之后,可能接受邀请收拾行装,或拒绝邀请 —— 但总之你会有一定的后续行为,换言之,你会对事件作出 响应;
- 如果手机没有开机,或者你在睡觉,或者家里 WiFi 坏了……如果没有 一定条件确保消息能够到达接收者,那么事件即使发生了(朋友把消息发出去了),该事件也是无效事件。
小到水龙头中落下一滴水,大到洲际导弹发射、太阳熄灭、超新星爆发,任何事件要发生并有意义,至少要满足 5 个要素(当然还有更多,但至少是这 5 个):
- 发起者 —— 我们称为 事件源,虽然潜在的事件发生者可以有多个,但对于某一个正在发生的事件而言,其事件源总是唯一的;
- 接收者 —— 我们称为 订阅者,显然可以有多个;
- 事件的内容 —— 我们称为 事件参数;
- 对事件的响应 —— 我们称为 事件处理器;
- 确保事件有效传达的先决条件 —— 我们称为 订阅关系。
C# 的事件机制就建立在这 5 个要素上,我们要学习的是将这五要素有效建模的一种“八股文”。
事件定义语法
事件参数
我们首先定义的是事件参数,因为它依赖的其它类最少,只要从 System.EventArgs 类派生一个就行了:
public class EVENT_ARGS : EventArgs {BODY}
这里定义了一个派生类 EVENT_ARGS,命名惯例是“事件种类+EventArgs”,如“ComputerBlowUpEventArgs”、“HeroSlayEventArgs”、“SunExtinguishEventArgs”(我怎么不想点好的);BODY 是其带有的字段,我们一般不在事件参数中定义方法。
订阅关系
隆重介绍 System.EventHandler,它是一个委托类型,接受一个任意 Object 和一个 EventArgs 类型的数据“包”。你可以猜到订阅关系是如何传播的 —— 利用委托的多播性质,当 EventHandler 被执行时,所有绑定到它的事件处理器就都被调用。
我们可以从自己定义一个“事件种类+EventHandler”,也可以直接使用原生的 System.EventHandler,自己定义的格式是:
public delegate void EVENT_HNDL(object sender, EVENT_ARGS e);
这里第一个参数名约定俗成为 sender
,第二个参数的类型是刚才定义的 EVENT_ARGS
,参数名约定为 e
。
事件源
事件源是一个类(不需要继承自某个系统类),它必须具有 3 个成员:
- 一个 EventHandler(或自定义)实例字段;
- 一个事件成员;
- 一个调用(是指“函数调用”)了前述 EventHandler 字段的方法。
所以一个事件源类大概是这样的:
public class EVENT_SOURCE {
EVENT_HNDL _eventHandler;
public event EVENT_HNDL EVENT {
add {ADD_BODY}
remove {REMOVE_BODY}
}
void EVENT_EMIT(PARAMS) {BODY}
public void ENTRY...
}
上面的 event
关键字用于定义一个事件,其后紧接着的是 EVENT_HNDL 类名(这不是在声明字段或属性!只是一种约定的语法),然后是事件名 EVENT,最后是两个访问器组成的定义体,类似于属性的访问器,但使用了不同的关键字。
add
后接语句块定义该事件的 订阅器,用于将一个事件处理器绑定到事件委托对象 _eventHandler;remove
后接语句块定义该事件的 解除器,用于将一个事件处理器从事件委托对象中解除绑定。一般这样写:
public event EVENT_HNDL EVENT {
add {_eventHandler += value;}
remove {_eventHandler -= value;}
}
EVENT_EMIT 方法的编写、参数都是自由的,只要在其中有这么一句就行:
if (_eventHandler != null) {
_eventHandler(this, EVENT_ARGS_OBJ);
}
或其等效形式(这里的 ?.
其实就是 .
,只是多一层逻辑:如果委托为空绑定,则什么都不做):
_eventHandler?.Invoke(this, EVENT_ARGS_OBJ);
一般来说我们要把 EVENT_EMIT 方法写成私有的,这样做是出于安全考虑,不让不清楚情况的人随便直接 Invoke 造成混乱。因此我们一般还要再写一个暴露出来的方法 ENTRY,用来给别人调用。
订阅者和事件处理器
订阅者也是一个类,它是事件的归宿,负责在事件到来时(前面的 _eventHandler 被 Invoke 时)做出一定的动作)。其定义是自由的,只要在其中包括一个事件处理器方法即可。
事件处理器是一个方法,必须满足 EVENT_HNDL 委托类型对函数签名的定义,比如用 System.EventHandler 时,其签名就应该是:
public void EVENT_PROC(object sender, EVENT_ARGS e)
事件处理器函数体根据功能需求写即可。
调用代码
调用代码(比如 Main)中需要 初始化事件源实例(虽然你可以把事件源声明为静态的,但我们一般不这么做),如果订阅者类 EVENT_SUBSCRIBER 是非静态的,在调用处的代码中还需要初始化这个类的实例;如果是静态类则不需要;接着,我们需要为事件绑定处理器方法,事件是事件源的一个成员,它和委托一样可以用 +=
运算符;最后,我们调用一下事件源类暴露出的入口方法,开始等待事件发生即可。
综合一下写出来大概是这样的:
...
var source = new EVENT_SOURCE();
source.EVENT += EVENT_SUBSCRIBER.EVENT_PROC;
source.ENTRY;
事件编写八股
现在五要素备齐,我们总结一下编写一个完整的事件发生-处理逻辑需要的关键环节:
- 事件定义(design)阶段:
- 编写事件参数类(不携带任何自定义数据时可省略,用 System.EventArgs);
- 编写事件委托类(可省略,用 System.EventHandler);
- 编写事件源,需要编写前述“3 个成员”(如有必要,可编写多个,一般没必要)
- 事件应用(use,这个词好 low,我应该换个 apply 什么的?)阶段:
- 编写事件订阅者类,为其编写事件处理器方法(如有必要,可编写多个);
- 编写调用代码。
看晕了不要紧,我们写一个例子来用上上面所有的八股套路,它实现的功能是每当用户按下键盘任意键(除 ESC)时,在控制台上打印一个感叹号:
using System;
namespace LearnEvents
{
// Design-1. Define what message is carried when event is invoked:
public class KeyPressEventArgs : EventArgs
{
public ConsoleKeyInfo Key { get; set; }
}
// Design-2. Define event handler delegate type:
public delegate void KeyPressEventHandler(object sender, KeyPressEventArgs e);
// Design-3. Define an event source:
public class ConsoleSession
{
// Design-3a. Event handler delegate field:
KeyPressEventHandler _keyPressEventHandler;
// Design-3b. Define the event:
public event KeyPressEventHandler KeyPress
{
add { _keyPressEventHandler += value; }
remove { _keyPressEventHandler -= value; }
}
// Design-3c. Define a PRIVATE (for safety) invoker of the event handler:
void OnKeyPress(KeyPressEventArgs args)
{
_keyPressEventHandler?.Invoke(this, args);
}
// Design-3d. Expose an "entry" method that is will call the invoker:
public void Start()
{
var args = new KeyPressEventArgs();
for (;;)
{
args.Key = Console.ReadKey(true);
// If ESC key is pressed, exit the session:
if (args.Key.GetHashCode() == 27) return;
OnKeyPress(args);
}
}
}
// Use-1. Define event subscriber, who has an event processor method:
public static class ConsoleKeyListener
{
public static void KeyPressEventProcessor(object sender, KeyPressEventArgs e)
{
Console.Write("!");
}
}
// Use-2. Application code:
class Program
{
static void Main()
{
// Use-2a. Initialize an event source object:
var session = new ConsoleSession();
// Use-2b. Add a processor to the event handler:
session.KeyPress += ConsoleKeyListener.KeyPressEventProcessor;
// Use-2c. Finish, call the entry and wait for an event:
session.Start();
}
}
}
事件源的简写
实际编程中,事件源的事件定义部分几乎总是一样的,而委托字段又实在是没什么好写的。因此 C# 为我们提供了一个语法糖,我们可以只定义事件成员,不写访问器,也不写委托实例:
public event EVENT_HNDL EVENT;
上面的一行相当于隐式定义了一个委托字段,又定义了一个事件 EVENT,它具有默认的访问器。
用这种写法简写上例 design 的第三步:
...
// Design-3. Define an event source:
public class ConsoleSession
{
// [!!] Simplified:
public event KeyPressEventHandler KeyPress;
void OnKeyPress(KeyPressEventArgs args)
{
KeyPress?.Invoke(this, args);
}
public void Start()
{
var args = new KeyPressEventArgs();
for (;;)
{
args.Key = Console.ReadKey(true);
if (args.Key.GetHashCode() == 27) return;
OnKeyPress(args);
}
}
}
...
进一步,我们还可以直接使用 System.EventHandler 简化 design 阶段。
上面的例子使用默认 EventHandler 简写:
...
class KeyPressEventArgs : EventArgs
{
public ConsoleKeyInfo Key { get; set; }
}
class ConsoleSession
{
public event EventHandler KeyPress;
void OnKeyPress(EventArgs args)
{
KeyPress?.Invoke(this, args);
}
public void Start()
{
var args = new KeyPressEventArgs();
for (;;)
{
args.Key = Console.ReadKey(true);
if (args.Key.GetHashCode() == 27) return;
OnKeyPress(args);
}
}
}
public static class ConsoleKeyListener
{
public static void KeyPressEventProcessor(object sender, EventArgs e)
{
Console.Write("!");
}
}
...
你可能会问,我们定义了 KeyPressEventArgs,却在事件处理器中将其作为 EventArgs 接受,要怎么再拿出其中的时间参数呢?
还记得我们之前学过的拆箱吗:
...
public static void KeyPressEventProcessor(object sender, EventArgs e)
{
var key = e as KeyPressEventArgs;
Console.WriteLine(key?.Key.GetHashCode());
}
...
这里我们将 e 拆箱,“升级”成 KeyPressEventArgs 对象,由于 KeyPressEventArgs 派生自 EventArgs,且我们能够确定传入的 e 一定是 KeyPressEventArgs 对象在 OnKeyPress 被调用时装箱而成的,所以这个操作不会抛出异常;我们在访问 key 之前还对其进行了“防 null 检查”。
事件驱动
至此我们已经掌握了 C# 事件的大部分必要知识,也是整部笔记中最难的知识。
其实在实际开发中,我们更多的是用这些知识来调用平台已经为我们写好的事件,我们要做的只是编写合适的事件处理器,并绑定到事件上(相当于平台的开发者已经为我们完成了 design 阶段,我们只需要做 use 阶段的事情)。
我们程序员经常与“黑窗户”控制台打交道,但游戏是一个图形界面。图形界面是复杂的,没办法保证用户总是“先点击这个,再点击那个”,因此像控制台一样做“流程式”交互的思路是行不通的;我们必须定义“如果用户点这个,我们怎样响应”,再把实际的交互工作交给 .NET 中生生不息的“事件泵”去完成,我们称之为 事件驱动 的程序开发理念。
鉴于有的朋友还压根儿没下载好 Unity,只是来打语言基础的,我们在这里不涉及具体的 Unity API。但我想写一个小例子来简单体验一下事件驱动编程,我对 Winform 开发不感兴趣,因此也不会太过认真地去写了。
首先我们添加对 Windows.Forms 的依赖,Rider 中是这样操作的:
接着搜索“Fonts”,勾选并添加 System.Windows.Forms 依赖:
然后敲代码:
using System.Windows.Forms;
namespace LearnEvents
{
class ClickMe : Form
{
Label _lbl;
Button _btn;
int _remainingClicks;
public ClickMe()
{
_lbl = new Label();
_btn = new Button();
Controls.Add(_lbl);
Controls.Add(_btn);
Text = "Click Game";
Height = 180;
Width = 250;
FormBorderStyle = FormBorderStyle.FixedSingle;
_lbl.Top = 40;
_lbl.Left = 75;
_lbl.Height = 20;
_lbl.Width = 100;
_lbl.Text = "↓ Click it!";
_btn.Top = 60;
_btn.Left = 75;
_btn.Height = 50;
_btn.Text = "No, don't!";
_remainingClicks = 10;
_btn.Click += (sender, e) =>
{
--_remainingClicks;
if (_remainingClicks <= 0)
{
_btn.Enabled = false;
_btn.Text = $"I'm dead";
_lbl.Text = " Good job!!";
}
else
{
_btn.Text = $"Remaining clicks: {_remainingClicks}";
}
};
}
}
class Program
{
static void Main()
{
var form = new ClickMe();
form.ShowDialog();
}
}
}
这是一个无聊的点按钮小程序:
本节就先到这吧。
T.B.C.