C# 中事件访问器的定义、使用和注意事项
一、先复习:普通事件是什么
在 C# 中,事件用于表示“某件事情发生了”,然后通知外部订阅者执行相应代码。
例如:
class Button
{
public event Action Click;
public void Press()
{
Console.WriteLine("按钮被按下");
Click?.Invoke();
}
}
使用时:
Button button = new Button();
button.Click += () =>
{
Console.WriteLine("处理按钮点击");
};
button.Press();
这里:
Click是事件。+=表示订阅事件。Click?.Invoke()表示触发事件。
这类写法叫“字段式事件”,也是最常见、最容易理解的事件写法。
二、什么是事件访问器
事件访问器,指的是事件中的 add 和 remove 代码块。
它们的作用是:
自定义事件在订阅和取消订阅时要执行什么逻辑。
普通事件写法:
public event Action Click;
带事件访问器的写法:
private Action click;
public event Action Click
{
add
{
click += value;
}
remove
{
click -= value;
}
}
这里的:
add
{
click += value;
}
表示有人写:
button.Click += SomeMethod;
时,会执行 add 代码块。
这里的:
remove
{
click -= value;
}
表示有人写:
button.Click -= SomeMethod;
时,会执行 remove 代码块。
一句话理解:
add管订阅,remove管取消订阅。
三、事件访问器的基本语法
事件访问器的一般格式如下:
private 委托类型 字段名;
public event 委托类型 事件名
{
add
{
// 订阅事件时执行
}
remove
{
// 取消订阅事件时执行
}
}
例如:
private Action changed;
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
注意这里有一个特殊关键字:
value
在 add 和 remove 中,value 表示外部传进来的事件处理方法。
比如:
obj.Changed += OnChanged;
这时 add 里面的 value 就是 OnChanged。
再比如:
obj.Changed -= OnChanged;
这时 remove 里面的 value 也是 OnChanged。
四、事件访问器和属性访问器很像
学习事件访问器时,可以类比属性访问器。
属性访问器:
private string name;
public string Name
{
get
{
return name;
}
set
{
name = value;
}
}
事件访问器:
private Action changed;
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
可以这样对比:
| 类型 | 访问器 | 作用 |
|---|---|---|
| 属性 | get |
读取属性时执行 |
| 属性 | set |
设置属性时执行 |
| 事件 | add |
订阅事件时执行 |
| 事件 | remove |
取消订阅事件时执行 |
所以:
属性访问器控制“读写属性”的行为,事件访问器控制“订阅和取消订阅事件”的行为。
五、完整示例:自定义事件访问器
下面写一个完整例子。
using System;
class Notifier
{
private Action notified;
public event Action Notified
{
add
{
Console.WriteLine("有人订阅了 Notified 事件");
notified += value;
}
remove
{
Console.WriteLine("有人取消订阅了 Notified 事件");
notified -= value;
}
}
public void Notify()
{
Console.WriteLine("准备触发事件");
notified?.Invoke();
}
}
class Program
{
static void Main()
{
Notifier notifier = new Notifier();
notifier.Notified += HandleNotified;
notifier.Notify();
notifier.Notified -= HandleNotified;
notifier.Notify();
}
static void HandleNotified()
{
Console.WriteLine("收到通知");
}
}
输出:
有人订阅了 Notified 事件
准备触发事件
收到通知
有人取消订阅了 Notified 事件
准备触发事件
分析:
notifier.Notified += HandleNotified;
会执行 add。
notifier.Notified -= HandleNotified;
会执行 remove。
notified?.Invoke();
才是真正触发事件。
六、为什么需要事件访问器
普通事件已经够用了,为什么还需要事件访问器?
因为有些时候,我们不只是想简单地添加或移除事件处理方法,还想在订阅和取消订阅时做一些额外控制。
常见用途包括:
- 订阅时记录日志。
- 限制订阅者数量。
- 防止重复订阅。
- 订阅第一个处理器时启动资源。
- 取消最后一个处理器时释放资源。
- 把事件处理器保存到特殊的数据结构中。
- 实现接口事件时自定义内部逻辑。
七、使用场景一:记录订阅和取消订阅日志
有时我们想知道什么时候有人订阅或取消订阅事件。
using System;
class MessageCenter
{
private Action<string> messageReceived;
public event Action<string> MessageReceived
{
add
{
Console.WriteLine("添加了一个消息监听器");
messageReceived += value;
}
remove
{
Console.WriteLine("移除了一个消息监听器");
messageReceived -= value;
}
}
public void SendMessage(string message)
{
messageReceived?.Invoke(message);
}
}
使用:
MessageCenter center = new MessageCenter();
center.MessageReceived += message =>
{
Console.WriteLine("收到消息:" + message);
};
center.SendMessage("你好");
事件访问器让我们可以在 += 和 -= 发生时插入自己的逻辑。
八、使用场景二:防止重复订阅
普通事件允许重复订阅同一个方法。
例如:
notifier.Notified += HandleNotified;
notifier.Notified += HandleNotified;
如果事件触发,HandleNotified 会执行两次。
有时候这不是我们想要的,可以使用事件访问器做限制。
using System;
class Notifier
{
private Action notified;
public event Action Notified
{
add
{
notified -= value;
notified += value;
}
remove
{
notified -= value;
}
}
public void Notify()
{
notified?.Invoke();
}
}
关键代码:
notified -= value;
notified += value;
意思是:
先移除一次,再添加一次。
这样即使外部重复订阅,也尽量保证同一个处理器只保留一份。
教学时可以这样说:
先把旧的同名处理器拿掉,再重新放进去,就像名单里只保留一个名字。
九、使用场景三:限制最多只能有一个订阅者
有些场景下,我们希望一个事件最多只允许一个订阅者。
using System;
class SingleSubscriberNotifier
{
private Action notified;
public event Action Notified
{
add
{
if (notified != null)
{
throw new InvalidOperationException("Notified 事件只允许一个订阅者");
}
notified += value;
}
remove
{
notified -= value;
}
}
public void Notify()
{
notified?.Invoke();
}
}
使用:
SingleSubscriberNotifier notifier = new SingleSubscriberNotifier();
notifier.Notified += () =>
{
Console.WriteLine("第一个订阅者");
};
// 再订阅第二个时会抛出异常
notifier.Notified += () =>
{
Console.WriteLine("第二个订阅者");
};
这种写法不常用,但能说明事件访问器可以控制订阅规则。
十、使用场景四:第一个订阅者出现时启动资源
有些对象只有在有人订阅事件时,才需要开始工作。
例如一个温度监控器:
- 没人关心温度时,不需要启动监控。
- 有人订阅温度变化事件时,开始监控。
- 所有人都取消订阅后,停止监控。
using System;
class TemperatureMonitor
{
private Action<int> temperatureChanged;
private int subscriberCount;
public event Action<int> TemperatureChanged
{
add
{
if (subscriberCount == 0)
{
StartMonitor();
}
temperatureChanged += value;
subscriberCount++;
}
remove
{
temperatureChanged -= value;
subscriberCount--;
if (subscriberCount == 0)
{
StopMonitor();
}
}
}
public void ChangeTemperature(int temperature)
{
temperatureChanged?.Invoke(temperature);
}
private void StartMonitor()
{
Console.WriteLine("启动温度监控");
}
private void StopMonitor()
{
Console.WriteLine("停止温度监控");
}
}
这类写法适合管理资源,比如:
- 定时器
- 网络监听
- 文件监控
- 传感器监听
- 后台任务
不过要注意,这个简单示例还没有处理重复订阅和多线程问题,真实项目中需要写得更严谨。
十一、使用场景五:实现接口中的事件
接口中可以声明事件:
interface INotifier
{
event Action Notified;
}
实现接口时,可以使用普通事件:
class Notifier : INotifier
{
public event Action Notified;
}
也可以使用事件访问器:
class Notifier : INotifier
{
private Action notified;
public event Action Notified
{
add
{
Console.WriteLine("接口事件被订阅");
notified += value;
}
remove
{
Console.WriteLine("接口事件被取消订阅");
notified -= value;
}
}
public void Notify()
{
notified?.Invoke();
}
}
当我们需要在接口事件的订阅过程中做额外处理时,事件访问器就很有用。
十二、使用 EventHandler 的事件访问器
事件访问器不只可以配合 Action,也可以配合标准事件委托 EventHandler。
using System;
class Door
{
private EventHandler opened;
public event EventHandler Opened
{
add
{
Console.WriteLine("有人关注门打开事件");
opened += value;
}
remove
{
Console.WriteLine("有人取消关注门打开事件");
opened -= value;
}
}
public void Open()
{
Console.WriteLine("门打开了");
opened?.Invoke(this, EventArgs.Empty);
}
}
使用:
Door door = new Door();
door.Opened += Door_Opened;
door.Open();
static void Door_Opened(object sender, EventArgs e)
{
Console.WriteLine("处理门打开事件");
}
如果事件需要传递额外数据,也可以使用 EventHandler<TEventArgs>。
class ScoreChangedEventArgs : EventArgs
{
public int NewScore { get; }
public ScoreChangedEventArgs(int newScore)
{
NewScore = newScore;
}
}
class ScoreManager
{
private EventHandler<ScoreChangedEventArgs> scoreChanged;
public event EventHandler<ScoreChangedEventArgs> ScoreChanged
{
add
{
scoreChanged += value;
}
remove
{
scoreChanged -= value;
}
}
public void SetScore(int score)
{
scoreChanged?.Invoke(this, new ScoreChangedEventArgs(score));
}
}
十三、事件访问器中的 value 是什么
value 是事件访问器里非常重要的一个隐式参数。
在 add 中:
add
{
changed += value;
}
value 表示外部想添加进来的事件处理器。
对应代码:
obj.Changed += Handler;
这里的 value 就是 Handler。
在 remove 中:
remove
{
changed -= value;
}
value 表示外部想移除的事件处理器。
对应代码:
obj.Changed -= Handler;
这里的 value 也是 Handler。
可以把 value 理解成:
当前正在被添加或移除的那个方法。
十四、事件访问器和自动事件的区别
普通事件:
public event Action Changed;
这是最常见的写法,编译器会在背后自动帮我们保存事件处理器。
带访问器的事件:
private Action changed;
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
这时编译器不会再自动帮我们保存事件处理器。
因为我们已经自己接管了 add 和 remove。
所以必须自己准备一个字段:
private Action changed;
否则事件处理器没有地方保存。
对比:
| 写法 | 是否需要自己写字段 | 是否能自定义订阅逻辑 |
|---|---|---|
public event Action Changed; |
不需要 | 不能 |
带 add / remove 的事件 |
需要 | 能 |
十五、事件访问器不能直接被外部调用
外部代码不能这样写:
obj.Changed.add(Handler); // 错误
obj.Changed.remove(Handler); // 错误
外部只能使用:
obj.Changed += Handler;
obj.Changed -= Handler;
也就是说:
- 外部写
+=,内部执行add。 - 外部写
-=,内部执行remove。
add 和 remove 是事件语法的一部分,不是普通方法。
十六、事件访问器中如何触发事件
事件访问器只负责订阅和取消订阅,不负责触发事件。
触发事件仍然要调用我们保存的委托字段。
例如:
private Action changed;
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
public void OnChanged()
{
changed?.Invoke();
}
注意:
Changed?.Invoke(); // 这种写法在自定义访问器事件中通常不能这样用
因为 Changed 是事件,对外只暴露订阅和取消订阅能力。
我们真正保存处理器的是字段:
changed
所以应该触发:
changed?.Invoke();
十七、常见错误一:忘记在 add 中保存 value
错误写法:
private Action changed;
public event Action Changed
{
add
{
Console.WriteLine("有人订阅");
}
remove
{
changed -= value;
}
}
问题是:
add
{
Console.WriteLine("有人订阅");
}
这里只记录了日志,但没有写:
changed += value;
所以订阅者没有真正被保存下来。
结果就是事件触发时,订阅的方法不会执行。
正确写法:
add
{
Console.WriteLine("有人订阅");
changed += value;
}
十八、常见错误二:remove 中写成了 +=
错误写法:
remove
{
changed += value;
}
这会导致取消订阅时,反而又订阅了一次。
正确写法:
remove
{
changed -= value;
}
教学时可以提醒学生:
add里面通常是+= value,remove里面通常是-= value。
十九、常见错误三:递归调用事件自身
错误写法:
public event Action Changed
{
add
{
Changed += value;
}
remove
{
Changed -= value;
}
}
这段代码非常危险。
在 add 里面写:
Changed += value;
又会触发 add。
然后 add 里面又执行 Changed += value。
这样会不断调用自己,最终导致栈溢出。
正确做法是使用一个私有字段保存处理器:
private Action changed;
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
记住:
事件访问器内部不要对事件本身
+=或-=,应该操作背后的私有委托字段。
二十、常见错误四:订阅计数不准确
前面我们写过这个例子:
private Action<int> temperatureChanged;
private int subscriberCount;
public event Action<int> TemperatureChanged
{
add
{
if (subscriberCount == 0)
{
StartMonitor();
}
temperatureChanged += value;
subscriberCount++;
}
remove
{
temperatureChanged -= value;
subscriberCount--;
if (subscriberCount == 0)
{
StopMonitor();
}
}
}
这段代码在教学中容易理解,但真实项目中要注意:
- 同一个方法可能被重复订阅。
- 外部可能取消一个从来没有订阅过的方法。
- 多线程环境下订阅和取消可能同时发生。
这些都会导致 subscriberCount 不准确。
更稳妥的方式是根据实际委托列表判断,或者使用集合管理订阅者。
对于初学者来说,先知道这个风险即可。
二十一、常见错误五:匿名函数取消订阅失败
事件访问器无法改变匿名函数本身的规则。
下面代码看起来像是取消了订阅:
notifier.Notified += () =>
{
Console.WriteLine("收到通知");
};
notifier.Notified -= () =>
{
Console.WriteLine("收到通知");
};
但实际上通常取消不了。
因为这两个匿名函数虽然代码一样,但它们是两个不同的函数对象。
正确做法:
Action handler = () =>
{
Console.WriteLine("收到通知");
};
notifier.Notified += handler;
notifier.Notified -= handler;
这点和普通事件完全一样。
二十二、常见错误六:在事件访问器里写太复杂的逻辑
事件访问器是订阅和取消订阅时自动执行的代码。
如果里面逻辑太复杂,会让代码难以理解,也可能带来隐藏问题。
不推荐:
public event Action Changed
{
add
{
// 做数据库操作
// 发网络请求
// 执行很耗时的计算
// 再保存 value
}
remove
{
// 做大量清理工作
}
}
更推荐:
public event Action Changed
{
add
{
changed += value;
LogSubscription();
}
remove
{
changed -= value;
LogUnsubscription();
}
}
原则:
事件访问器可以做额外控制,但不要变成复杂业务逻辑的仓库。
二十三、线程安全问题
在多线程环境中,多个线程可能同时订阅或取消订阅事件。
简单写法:
private Action changed;
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
在普通教学和简单程序中可以理解为足够使用。
但在多线程场景下,可能需要加锁:
private readonly object lockObj = new object();
private Action changed;
public event Action Changed
{
add
{
lock (lockObj)
{
changed += value;
}
}
remove
{
lock (lockObj)
{
changed -= value;
}
}
}
触发时也可以先复制一份:
Action handler;
lock (lockObj)
{
handler = changed;
}
handler?.Invoke();
这属于进阶内容。
课堂上可以先告诉学生:
单线程程序先掌握基本写法,多线程程序要考虑同时订阅和取消订阅的问题。
二十四、事件访问器的访问修饰符
事件本身可以有访问修饰符:
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
这里的 public 表示外部可以订阅和取消订阅这个事件。
也可以是:
private event Action Changed;
protected event Action Changed;
internal event Action Changed;
不过 add 和 remove 本身通常不单独写访问修饰符。
初学阶段记住:
事件的访问级别写在
event前面,外部能不能订阅由事件本身的访问修饰符决定。
二十五、事件访问器和属性访问器的区别
虽然事件访问器和属性访问器长得像,但它们控制的对象不同。
| 对比项 | 属性访问器 | 事件访问器 |
|---|---|---|
| 关键字 | get / set |
add / remove |
| 外部操作 | 读取或赋值 | 订阅或取消订阅 |
| 外部符号 | obj.Name / obj.Name = value |
obj.Event += handler / obj.Event -= handler |
内部 value |
set 中表示要设置的新值 |
add / remove 中表示事件处理器 |
| 常见用途 | 控制属性读写 | 控制事件订阅和取消订阅 |
可以这样讲:
属性访问器管数据,事件访问器管通知名单。
二十六、事件访问器什么时候不该用
事件访问器是进阶语法,并不是所有事件都要写成这样。
下面这种普通事件已经很清楚:
public event Action Changed;
如果你没有特殊需求,不需要写成:
private Action changed;
public event Action Changed
{
add
{
changed += value;
}
remove
{
changed -= value;
}
}
因为第二种写法更长,也更容易写错。
适合使用事件访问器的情况:
- 需要记录订阅和取消订阅日志。
- 需要限制订阅规则。
- 需要防止重复订阅。
- 需要在第一个订阅者出现时启动资源。
- 需要在最后一个订阅者离开时释放资源。
- 需要把订阅者存到自定义集合或弱引用结构里。
不适合使用事件访问器的情况:
- 只是普通通知。
- 没有特殊订阅逻辑。
- 初学阶段只想写简单事件。
一句话:
没有特殊订阅控制时,用普通事件;需要接管
+=和-=行为时,再用事件访问器。
二十七、课堂讲解建议
讲事件访问器时,可以按照下面顺序:
- 先复习普通事件:
event Action Changed; - 说明外部的
+=和-=本质上对应内部的add和remove。 - 用属性的
get/set类比事件的add/remove。 - 讲
value表示当前被添加或移除的事件处理方法。 - 讲为什么需要私有委托字段保存处理器。
- 最后讲常见用途和易错点。
可以用这句话帮助学生理解:
普通事件像自动管理的报名表;事件访问器就是你自己接管报名和退报名的过程。
二十八、完整综合示例:下载器事件访问器
下面用一个下载器示例,演示事件访问器的完整使用。
功能:
- 有人订阅进度事件时,输出日志。
- 有人取消订阅时,输出日志。
- 下载过程中触发进度事件。
- 防止同一个处理器重复订阅。
using System;
class Downloader
{
private Action<int> progressChanged;
public event Action<int> ProgressChanged
{
add
{
Console.WriteLine("添加进度监听器");
// 防止重复订阅
progressChanged -= value;
progressChanged += value;
}
remove
{
Console.WriteLine("移除进度监听器");
progressChanged -= value;
}
}
public void Download()
{
Console.WriteLine("开始下载");
for (int progress = 0; progress <= 100; progress += 25)
{
progressChanged?.Invoke(progress);
}
Console.WriteLine("下载完成");
}
}
class Program
{
static void Main()
{
Downloader downloader = new Downloader();
downloader.ProgressChanged += ShowProgress;
downloader.ProgressChanged += ShowProgress;
downloader.Download();
downloader.ProgressChanged -= ShowProgress;
downloader.Download();
}
static void ShowProgress(int progress)
{
Console.WriteLine($"当前进度:{progress}%");
}
}
可能输出:
添加进度监听器
添加进度监听器
开始下载
当前进度:0%
当前进度:25%
当前进度:50%
当前进度:75%
当前进度:100%
下载完成
移除进度监听器
开始下载
下载完成
虽然订阅了两次:
downloader.ProgressChanged += ShowProgress;
downloader.ProgressChanged += ShowProgress;
但是因为 add 中写了:
progressChanged -= value;
progressChanged += value;
所以最终只保留了一份 ShowProgress。
二十九、练习题
练习 1:记录事件订阅日志
定义一个 Alarm 类,有一个 Ring 事件。要求使用事件访问器,在订阅时输出“有人订阅了闹钟事件”,取消订阅时输出“有人取消订阅了闹钟事件”。
参考答案:
using System;
class Alarm
{
private Action ring;
public event Action Ring
{
add
{
Console.WriteLine("有人订阅了闹钟事件");
ring += value;
}
remove
{
Console.WriteLine("有人取消订阅了闹钟事件");
ring -= value;
}
}
public void Start()
{
Console.WriteLine("闹钟响了");
ring?.Invoke();
}
}
练习 2:防止重复订阅
定义一个 Notifier 类,有一个 Notified 事件。要求同一个处理方法重复订阅时,只执行一次。
参考答案:
using System;
class Notifier
{
private Action notified;
public event Action Notified
{
add
{
notified -= value;
notified += value;
}
remove
{
notified -= value;
}
}
public void Notify()
{
notified?.Invoke();
}
}
练习 3:限制一个订阅者
定义一个 SingleEventSource 类,它的 Happened 事件最多只能有一个订阅者。
参考答案:
using System;
class SingleEventSource
{
private Action happened;
public event Action Happened
{
add
{
if (happened != null)
{
throw new InvalidOperationException("只能有一个订阅者");
}
happened += value;
}
remove
{
happened -= value;
}
}
public void Raise()
{
happened?.Invoke();
}
}
练习 4:使用 EventHandler 写事件访问器
定义一个 Door 类,有一个 Opened 事件,要求使用 EventHandler 和事件访问器。
参考答案:
using System;
class Door
{
private EventHandler opened;
public event EventHandler Opened
{
add
{
opened += value;
}
remove
{
opened -= value;
}
}
public void Open()
{
Console.WriteLine("门打开了");
opened?.Invoke(this, EventArgs.Empty);
}
}
三十、总结
事件访问器是 C# 事件的进阶用法,用来控制事件订阅和取消订阅的过程。
可以记住下面几句话:
- 事件访问器包括
add和remove。 - 外部写
+=时,会执行add。 - 外部写
-=时,会执行remove。 value表示当前被添加或移除的事件处理器。- 自定义事件访问器时,通常需要一个私有委托字段保存事件处理器。
- 触发事件时,应该调用私有委托字段,而不是在访问器里递归操作事件本身。
- 事件访问器可以用来记录日志、防止重复订阅、限制订阅者、管理资源。
- 没有特殊需求时,普通事件写法更简单、更推荐。
一句话概括:
事件访问器就是给事件的
+=和-=加上自定义规则,让我们可以接管“谁能订阅、怎么订阅、取消订阅时做什么”。