第 35 节 · 第四部分 进阶特性

事件(Event)

事件是装在发布器身上的一串方法;外面只能用 += 挂上、-= 摘下,只有发布器自己能触发,触发时按挂上的顺序一个个调用。

时长 8:33授课计划第 14 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1事件是什么

用户按键、点击、移动鼠标,系统提示“下载完成”,都是事件,事件发生时程序要做出响应。在 C# 里,事件是类的一种成员,负责把“发生了什么”通知给订阅它的对象,这种做法叫观察者模式:发通知的一方不用知道对方的细节。事件建在上一节的委托上面。

用事件分四步:

  1. 声明委托,规定处理方法长什么样。
  2. 用 event 关键字声明事件。
  3. 在合适的时候触发事件,通知所有订阅者。
  4. 别的类用 += 订阅,用 -= 取消订阅。

包含事件、负责触发的类叫发布器(publisher),提供处理方法的类叫订阅器(subscriber)。视频里把事件比成一条挂钩栏:订阅器把处理方法挂上去,发布器一拉拉杆,挂着的方法就被一个个调用。委托类型可以写在发布器里,也可以写在类外面。

2声明事件

C# · 第 22-25 行
22public delegate void BoilerLogHandler(string status);// 委托 BoilerLogHandler:处理方法不返回值,接收 string
23
24// 基于上面的委托定义事件
25public event BoilerLogHandler BoilerEventLog;// 用 event 声明这种委托类型的事件 BoilerEventLog

触发事件的时候,挂在它上面的方法会被调用。

3第一个完整实例

发布器 ProcessBusinessLogic 有事件 ProcessCompleted,由 OnProcessCompleted 负责触发:

C# · 第 15-17 行
15protected virtual void OnProcessCompleted(EventArgs e)// protected virtual:派生类能重写这个方法
16{// OnProcessCompleted 开始
17 ProcessCompleted?.Invoke(this, e);// ?. :有人订阅才调用,没人订阅就什么也不做

订阅器的 Subscribe 方法用 += 挂上处理方法;Main 先创建发布器和订阅器,再订阅,最后调用 StartProcess:它先输出一行,再触发事件。

C# · 第 37 行
37process.ProcessCompleted += Process_ProcessCompleted;// 用 += 把处理方法挂到事件上
查看完整代码(62 行)
C#
1using System;// 引入 System 命名空间
2
3namespace EventDemo// 定义命名空间 EventDemo
4{// 命名空间开始
5 // 定义一个委托类型,用于事件处理程序
6 public delegate void NotifyEventHandler(object sender, EventArgs e);// 委托:参数 sender(谁发的)和 e(附加信息)
7
8 // 发布者类
9 public class ProcessBusinessLogic// 发布器类 ProcessBusinessLogic
10 {// 发布器类开始
11 // 声明事件
12 public event NotifyEventHandler ProcessCompleted;// 用 event 声明事件 ProcessCompleted
13
14 // 触发事件的方法
15 protected virtual void OnProcessCompleted(EventArgs e)// 触发事件的方法,protected virtual:派生类能重写
16 {// OnProcessCompleted 开始
17 ProcessCompleted?.Invoke(this, e);// 有人订阅才调用:把 this 和 e 交给处理方法
18 }// OnProcessCompleted 结束
19
20 // 模拟业务逻辑过程并触发事件
21 public void StartProcess()// StartProcess:模拟一段业务
22 {// StartProcess 开始
23 Console.WriteLine("Process Started!");// 先输出一行
24
25 // 这里可以加入实际的业务逻辑
26
27 // 业务逻辑完成,触发事件
28 OnProcessCompleted(EventArgs.Empty);// 业务完成,触发事件;EventArgs.Empty 表示没有附加信息
29 }// StartProcess 结束
30 }// 发布器类结束
31
32 // 订阅者类
33 public class EventSubscriber// 订阅器类 EventSubscriber
34 {// 订阅器类开始
35 public void Subscribe(ProcessBusinessLogic process)// Subscribe:接收一个发布器
36 {// Subscribe 开始
37 process.ProcessCompleted += Process_ProcessCompleted;// 用 += 挂上处理方法
38 }// Subscribe 结束
39
40 private void Process_ProcessCompleted(object sender, EventArgs e)// 处理方法:签名和委托对得上
41 {// 处理方法开始
42 Console.WriteLine("Process Completed!");// 收到通知,输出一行
43 }// 处理方法结束
44 }// 订阅器类结束
45
46 class Program// 定义 Program 类
47 {// Program 类开始
48 static void Main(string[] args)// 程序入口 Main 方法
49 {// Main 开始
50 ProcessBusinessLogic process = new ProcessBusinessLogic();// 创建发布器 process
51 EventSubscriber subscriber = new EventSubscriber();// 创建订阅器 subscriber
52
53 // 订阅事件
54 subscriber.Subscribe(process);// 订阅事件
55
56 // 启动过程
57 process.StartProcess();// 启动过程,事件被触发
58
59 Console.ReadLine();// 等待回车(现在的 VS 不写也会停住)
60 }// Main 结束
61 }// Program 类结束
62}// 命名空间结束
运行结果
Process Started!
Process Completed!

实际写代码通常不自己定义委托,直接用现成的 EventHandler(.NET 准备好的事件委托),或者带事件数据的版本 EventHandler<TEventArgs>。处理方法的两个参数里,sender 是发出通知的对象(这里是 this),e 是 EventArgs 类型、装着附加信息(EventArgs.Empty 表示没有)。

VS 会在事件名下面画绿色波浪线(警告 CS8618):没人订阅时事件的值可以为空,要你写明白。在委托类型后面加个问号,警告就没了,输出不变:

C# · 第 12 行
12public event NotifyEventHandler? ProcessCompleted;// NotifyEventHandler? :问号表示这个事件可以为空

4没人订阅时直接调用

做个实验:删掉订阅那一行,再把 ?. 也删掉。

C# · 第 17 行
17ProcessCompleted.Invoke(this, e);// 没有 ?. ,直接调用事件
运行结果
Process Started!
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at EventDemo.ProcessBusinessLogic.OnProcessCompleted(EventArgs e) in 事件/nocheck/Program.cs:line 17
   at EventDemo.ProcessBusinessLogic.StartProcess() in 事件/nocheck/Program.cs:line 28
   at EventDemo.Program.Main(String[] args) in 事件/nocheck/Program.cs:line 56

第一行照常输出,接着抛出空引用异常:没有任何方法挂在事件上,事件的值是空的,空值不能调用。所以触发之前要检查,?. 是最省事的写法。

5在类外面直接触发

再做一个实验:在 Main 里直接调用发布器的事件。

C# · 第 59 行
59process.ProcessCompleted(process, EventArgs.Empty);// 在发布器外面直接调用事件
编译报错
Program.cs(59,21): error CS0070: 事件“ProcessBusinessLogic.ProcessCompleted”只能出现在 += 或 -= 的左边(从类型“ProcessBusinessLogic”中使用时除外)

这就是 event 的作用:外面的类只能挂上、摘下处理方法,只有发布器自己能触发。

6实例 1:值变了才触发

发布器 EventTest 有私有字段 value,还有委托和事件 ChangeNum。

C# · 第 15-19 行
15if ( ChangeNum != null )// 先检查事件是不是空值
16{// if 开始
17 ChangeNum(); /* 事件被触发 */// 不是空值就触发:调用挂着的方法
18}else {// if 结束,else 开始
19 Console.WriteLine( "event not fire" );// 是空值就输出 event not fire

SetValue 只在新值和旧值不同时,才更新并触发事件;构造函数里调用了 SetValue(5)。

C# · 第 32-35 行
32if ( value != n )// 新值和旧值不同才往下走
33{// if 开始
34 value = n;// 更新 value
35 OnNumChanged();// 触发事件
C# · 第 56-60 行
56EventTest e = new EventTest(); /* 实例化对象,第一次没有触发事件 */// 创建发布器:输出 event not fire
57subscribEvent v = new subscribEvent(); /* 实例化对象 */// 创建订阅器
58e.ChangeNum += new EventTest.NumManipulationHandler( v.printf ); /* 注册 */// 注册:把订阅器的 printf 挂到 ChangeNum 上
59e.SetValue( 7 );// 7 和旧值 5 不同:触发,输出 event fire
60e.SetValue( 11 );// 11 和 7 不同:再触发一次
查看完整代码(63 行)
C#
1using System;// 引入 System 命名空间
2namespace SimpleEvent// 定义命名空间 SimpleEvent
3{// 命名空间开始
4 using System;// 命名空间里面也可以写 using(这一句和第 1 行重复)
5 /***********发布器类***********/// /* */ 是块注释,这里标出发布器类
6 public class EventTest// 发布器类 EventTest
7 {// EventTest 类开始
8 private int value;// 私有字段 value,默认是 0
9
10 public delegate void NumManipulationHandler();// 委托 NumManipulationHandler:不接收参数,不返回值
11
12 public event NumManipulationHandler ChangeNum;// 事件 ChangeNum
13 protected virtual void OnNumChanged()// 触发事件的方法
14 {// OnNumChanged 开始
15 if ( ChangeNum != null )// 有人注册吗?
16 {// if 开始
17 ChangeNum(); /* 事件被触发 */// 触发事件
18 }else {// if 结束,else 开始
19 Console.WriteLine( "event not fire" );// 没人注册:输出 event not fire
20 Console.ReadKey(); /* 回车继续 */// 每输出一行停下等按键
21 }// else 结束
22 }// OnNumChanged 结束
23
24 public EventTest()// 构造函数
25 {// 构造函数开始
26 int n = 5;// n = 5
27 SetValue( n );// SetValue(5):0 变成 5,但还没人注册
28 }// 构造函数结束
29
30 public void SetValue( int n )// SetValue
31 {// SetValue 开始
32 if ( value != n )// 新值不同才处理
33 {// if 开始
34 value = n;// 更新 value
35 OnNumChanged();// 触发事件
36 }// if 结束
37 }// SetValue 结束
38 }// EventTest 类结束
39
40 /***********订阅器类***********/// 块注释:订阅器类
41
42 public class subscribEvent// 订阅器类 subscribEvent
43 {// subscribEvent 类开始
44 public void printf()// 处理方法 printf
45 {// printf 开始
46 Console.WriteLine( "event fire" );// 输出 event fire
47 Console.ReadKey(); /* 回车继续 */// 每输出一行停下等按键
48 }// printf 结束
49 }// subscribEvent 类结束
50
51 /***********触发***********/// 块注释:触发
52 public class MainClass// MainClass 类
53 {// MainClass 类开始
54 public static void Main()// 程序入口 Main 方法
55 {// Main 开始
56 EventTest e = new EventTest(); /* 实例化对象,第一次没有触发事件 */// 创建发布器:构造函数里输出 event not fire
57 subscribEvent v = new subscribEvent(); /* 实例化对象 */// 创建订阅器
58 e.ChangeNum += new EventTest.NumManipulationHandler( v.printf ); /* 注册 */// 注册(new 委托 的写法)
59 e.SetValue( 7 );// 触发:输出 event fire
60 e.SetValue( 11 );// 再触发:输出 event fire
61 }// Main 结束
62 }// MainClass 类结束
63}// 命名空间结束
运行结果
event not fire

event fire

event fire

第一行是 event not fire:创建对象时还没人注册;后面两次都改了值,各触发一次。每输出一行,程序都停下来等你按一下键,输出里的空行是按下的回车回显出来的。注册用的是老写法,也可以直接写 += 加方法名,效果一样:

C# · 第 58 行
58e.ChangeNum += v.printf; /* 注册 */// 不写 new,直接 += 方法名

7实例 2:锅炉日志

Boiler 类记着温度 100 和压力 12。发布器的 LogProcess 检查这两个数:温度在 80 到 150 之间、压力在 12 到 15 之间就是正常。

C# · 第 34-37 行
34if (temp > 150 || temp < 80 || pressure < 12 || pressure > 15)// 超出范围吗?|| 是“或者”,有一个成立就算
35{// if 开始
36 remarks = "Need Maintenance";// 超出就改成 Need Maintenance
37}// if 结束

检查完把结果拼成一段信息,交给触发方法去触发事件。这次有两个订阅者,都挂到同一个事件上:

C# · 第 82-84 行
82boilerEvent.BoilerEventLog += Logger;// 订阅者一:输出到屏幕
83boilerEvent.BoilerEventLog += fileLogger.Logger;// 订阅者二:写进文件
84boilerEvent.LogProcess();// 触发一次,两个订阅者按顺序运行
查看完整代码(90 行)
C#
1using System;// 引入 System 命名空间
2using System.IO;// 引入 System.IO,文件读写用
3
4namespace BoilerEventAppl// 定义命名空间 BoilerEventAppl
5{// 命名空间开始
6 // Boiler 类
7 class Boiler// Boiler 类
8 {// Boiler 类开始
9 public int Temp { get; private set; }// 温度:外面只能读,private set 只有类自己能写
10 public int Pressure { get; private set; }// 压力:同样外面只读
11
12 public Boiler(int temp, int pressure)// 构造函数
13 {// 构造函数开始
14 Temp = temp;// 存温度
15 Pressure = pressure;// 存压力
16 }// 构造函数结束
17 }// Boiler 类结束
18
19 // 事件发布器
20 class DelegateBoilerEvent// 发布器类 DelegateBoilerEvent
21 {// 发布器类开始
22 public delegate void BoilerLogHandler(string status);// 委托:接收 string,不返回值
23
24 // 基于上面的委托定义事件
25 public event BoilerLogHandler BoilerEventLog;// 事件 BoilerEventLog
26
27 public void LogProcess()// LogProcess:检查锅炉状态
28 {// LogProcess 开始
29 string remarks = "O.K.";// 备注默认 O.K.
30 Boiler boiler = new Boiler(100, 12);// 温度 100、压力 12
31 int temp = boiler.Temp;// 读温度
32 int pressure = boiler.Pressure;// 读压力
33
34 if (temp > 150 || temp < 80 || pressure < 12 || pressure > 15)// 超出正常范围?
35 {// if 开始
36 remarks = "Need Maintenance";// 改成 Need Maintenance
37 }// if 结束
38
39 OnBoilerEventLog($"Logging Info:\nTemperature: {temp}\nPressure: {pressure}\nMessage: {remarks}");// 拼好信息去触发事件
40 }// LogProcess 结束
41
42 protected void OnBoilerEventLog(string message)// 触发事件的方法
43 {// OnBoilerEventLog 开始
44 BoilerEventLog?.Invoke(message);// 有人订阅才调用
45 }// OnBoilerEventLog 结束
46 }// 发布器类结束
47
48 // 该类保留写入日志文件的条款
49 class BoilerInfoLogger : IDisposable// 写文件的类,IDisposable 表示它能用 using 自动收尾
50 {// BoilerInfoLogger 类开始
51 private readonly StreamWriter _streamWriter;// 写入器,readonly 表示只在构造函数里赋值
52
53 public BoilerInfoLogger(string filename)// 构造函数,收文件名
54 {// 构造函数开始
55 _streamWriter = new StreamWriter(new FileStream(filename, FileMode.Append, FileAccess.Write));// 以追加方式打开文件
56 }// 构造函数结束
57
58 public void Logger(string info)// 处理方法 Logger:写一行到文件
59 {// Logger 开始
60 _streamWriter.WriteLine(info);// 写入信息
61 }// Logger 结束
62
63 public void Dispose()// Dispose(释放资源的方法):关闭文件
64 {// Dispose 开始
65 _streamWriter?.Close();// 关闭写入器
66 }// Dispose 结束
67 }// BoilerInfoLogger 类结束
68
69 // 事件订阅器
70 public class RecordBoilerInfo// 订阅器类 RecordBoilerInfo
71 {// 订阅器类开始
72 static void Logger(string info)// 处理方法 Logger:输出到屏幕
73 {// Logger 开始
74 Console.WriteLine(info);// 输出信息
75 }// Logger 结束
76
77 static void Main(string[] args)// 程序入口 Main 方法
78 {// Main 开始
79 using (BoilerInfoLogger fileLogger = new BoilerInfoLogger("e:\\boiler.txt"))// 创建文件日志对象(e 盘路径)
80 {// using 开始
81 DelegateBoilerEvent boilerEvent = new DelegateBoilerEvent();// 创建发布器
82 boilerEvent.BoilerEventLog += Logger;// 挂上屏幕日志
83 boilerEvent.BoilerEventLog += fileLogger.Logger;// 挂上文件日志
84 boilerEvent.LogProcess();// 触发事件
85 }// using 结束:自动关闭文件
86
87 Console.ReadLine();// 等待回车(现在的 VS 不写也会停住)
88 }// Main 结束
89 }// 订阅器类结束
90}// 命名空间结束
运行结果
Logging Info:
Temperature: 100
Pressure: 12
Message: O.K.

同一次触发,文件里也写进了同样的四行。程序把文件写到 E 盘,电脑上没有 E 盘就会出错。

8用 -= 取消订阅

教程提到了 -= 但没给例子,我们自己写了一个闹钟:

C# · 第 30-36 行
30Alarm alarm = new Alarm();// 创建闹钟
31alarm.Rang += Light;// 挂上 Light
32alarm.Rang += Music;// 挂上 Music
33alarm.Ring();// 第一次响:按挂上的顺序运行两个方法
34
35alarm.Rang -= Light;// 用 -= 摘掉 Light
36alarm.Ring();// 第二次响:Alarm rings,只剩 Music plays
查看完整代码(39 行)
C#
1using System;// 引入 System 命名空间
2
3namespace EventDemo// 定义命名空间 EventDemo
4{// 命名空间开始
5 public class Alarm// Alarm 类(发布器)
6 {// Alarm 类开始
7 public event EventHandler? Rang;// 事件 Rang,类型直接用现成的 EventHandler,? 表示可以为空
8
9 public void Ring()// Ring:响铃
10 {// Ring 开始
11 Console.WriteLine("Alarm rings");// 先输出一行
12 Rang?.Invoke(this, EventArgs.Empty);// 再触发事件
13 }// Ring 结束
14 }// Alarm 类结束
15
16 class Program// Program 类
17 {// Program 类开始
18 static void Light(object? sender, EventArgs e)// 处理方法 Light;object? 的 ? 表示可以为空
19 {// Light 开始
20 Console.WriteLine("Light on");// 输出 Light on
21 }// Light 结束
22
23 static void Music(object? sender, EventArgs e)// 处理方法 Music
24 {// Music 开始
25 Console.WriteLine("Music plays");// 输出 Music plays
26 }// Music 结束
27
28 static void Main(string[] args)// 程序入口 Main 方法
29 {// Main 开始
30 Alarm alarm = new Alarm();// 创建闹钟
31 alarm.Rang += Light;// 挂上 Light
32 alarm.Rang += Music;// 挂上 Music
33 alarm.Ring();// 响一次
34
35 alarm.Rang -= Light;// 摘掉 Light
36 alarm.Ring();// 再响一次
37 }// Main 结束
38 }// Program 类结束
39}// 命名空间结束
运行结果
Alarm rings
Light on
Music plays
Alarm rings
Music plays

一共五行:-= 只摘掉 Light 这一个,Music 照常运行。

9事件不是线程通信

事件不是用来做线程间通信的(教程的说法不对;线程是程序里的一条执行路径,最后一节讲)。我们打印一下线程编号:

C# · 第 14-17 行
14Done += OnDone;// 挂上 OnDone
15Console.WriteLine("raise thread " + Environment.CurrentManagedThreadId);// 打印触发事件的线程编号
16Done?.Invoke(null, EventArgs.Empty);// 触发事件:OnDone 打印它所在的线程编号
17Console.WriteLine("after raise");// 处理方法运行完,才走到这一句
查看完整代码(19 行)
C#
1using System;// 引入 System 命名空间
2
3class Program// Program 类
4{// Program 类开始
5 static event EventHandler? Done;// 静态事件 Done
6
7 static void OnDone(object? sender, EventArgs e)// 处理方法 OnDone
8 {// OnDone 开始
9 Console.WriteLine("handler thread " + Environment.CurrentManagedThreadId);// 打印处理方法所在的线程编号
10 }// OnDone 结束
11
12 static void Main()// 程序入口 Main 方法
13 {// Main 开始
14 Done += OnDone;// 挂上 OnDone
15 Console.WriteLine("raise thread " + Environment.CurrentManagedThreadId);// 打印触发事件的线程编号
16 Done?.Invoke(null, EventArgs.Empty);// 触发事件
17 Console.WriteLine("after raise");// 处理方法运行完,才走到这一句
18 }// Main 结束
19}// Program 类结束
运行结果
raise thread 1
handler thread 1
after raise

触发和处理都在线程 1 上,处理方法运行完才输出 after raise。所以事件不是线程通信,也不是中断,只是把方法一个个调用完。

10小结

  • 先声明委托,规定处理方法的样子,再用 event 声明事件。
  • 订阅者用 += 挂上,用 -= 摘下。
  • 发布器用 ?.Invoke 触发,挂着的方法按顺序运行。
  • 外面的类只能挂和摘,不能直接触发。

下一节:数组长度一开始就定死了,想随时往里加、往外删数据,用什么?

检查题

5 道,选完马上告诉你对不对。

  1. 1教程第一个实例(EventDemo)运行输出几行?

  2. 2删掉订阅那行,并把 ?.Invoke 改成 .Invoke,运行?

  3. 3在 Main 里写 process.ProcessCompleted(process, EventArgs.Empty);?

  4. 4Alarm 例子:挂 Light、Music,响一次;再 -= Light,又响一次。第二次响铃后输出?

  5. 5教程说“C# 中使用事件机制实现线程间的通信”。实测处理方法在哪个线程运行?