CodeGym /课程 /C# SELF /事件驱动编程示例 ( event)

事件驱动编程示例 ( event)

C# SELF
第 54 级 , 课程 4
可用

1. 介绍

典型新手可能会问:「事件在实际中什么时候用?难道类与类之间的交互都应该用事件,而不是直接调用方法吗?」答案很简单:事件和委托不是万能钥匙,但没有它们,现代应用很快会变成「强耦合」(tightly coupled),一个组件的改动会牵连很多其他东西。用事件和委托可以让系统更灵活、可扩展、易维护。

  • UI 编程(WinForms, WPF, Xamarin, MAUI):处理点击、悬停、文本输入和其他用户动作。
  • 异步操作:文件下载完成、网络数据到达、定时器触发等。
  • 插件和可扩展系统架构:允许在不改动主代码的情况下接入新模块。
  • 信号/广播系统:让多个关心的组件收到发生的事件通知。
  • 状态变更的观察(“观察者”):对新消息、数据变化、UI 刷新等作出反应。

那我们开始吧!

2. 架构骨架

假设你要做一个小型控制台应用 —— 公司内部小聊聊(或者练手用)。我们有实体:UserChat,可能还有 Bot。当用户发消息时,聊天室要通知所有在线的用户和机器人,让他们在屏幕上显示这条消息,或者机器人生成自动回复。这是经典场景:发布者触发事件(event),处理器订阅 EventHandler/EventHandler<TEventArgs>,处理方法像 OnMessageReceived 会响应该通知。

// 用户类(订阅者)
public class User
{
    public string Name { get; }

    public User(string name)
    {
        Name = name;
    }

    // 将作为事件处理器的方法
    public void OnMessageReceived(object? sender, MessageEventArgs e)
    {
        Console.WriteLine($"[{Name}] 看到新消息: {e.MessageText}");
    }
}

// 事件参数
public class MessageEventArgs : EventArgs
{
    public string MessageText { get; }

    public MessageEventArgs(string text) => MessageText = text;
}

// 聊天室类(事件发布者)
public class ChatRoom
{
    public event EventHandler<MessageEventArgs>? MessageReceived;

    public void SendMessage(string text)
    {
        // 触发事件(通知所有订阅者)
        MessageReceived?.Invoke(this, new MessageEventArgs(text));
    }
}

使用示例:

var chat = new ChatRoom();
var user1 = new User("安东");
var user2 = new User("玛丽亚");

chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;

chat.SendMessage("大家好! 😊");

控制台会看到两条消息 —— 两个用户都收到了新消息通知。

3. 动态订阅和退订

现实中用户可能会退出聊天室,不想再收到消息。我们给 User 加个正确的退订方法(用 -= 操作符):

// 在 User 类中可以添加方法 "退出聊天室"
public void Unsubscribe(ChatRoom chat)
{
    chat.MessageReceived -= OnMessageReceived;
}

扩展示例:

var chat = new ChatRoom();
var user1 = new User("安东");
var user2 = new User("玛丽亚");

chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;

chat.SendMessage("第一条消息");

user2.Unsubscribe(chat); // 玛丽亚退出聊天室

chat.SendMessage("玛丽亚将看不到这条消息");

动态订阅/退订很常见:窗口关闭、选项卡关闭、临时服务从全局事件源退订。别忘了 —— 没退订的订阅者会变成僵尸,应用就会内存泄漏!

4. 消息处理与生成回复

现在加个机器人,机器人对每条消息都做出反应。如果消息里带有关键词 "机器人",机器人就说 "你好!"。同时演示多重订阅和多播委托。

public class Bot
{
    public string Name { get; }

    public Bot(string name) => Name = name;

    public void OnMessageReceived(object? sender, MessageEventArgs e)
    {
        // 机器人根据关键字做出反应
        if (e.MessageText.Contains("机器人", StringComparison.OrdinalIgnoreCase))
        {
            if (sender is ChatRoom chatRoom)
            {
                Console.WriteLine($"[机器人 {Name}]: 你好!我能帮啥?");
                // 机器人发送回复
                chatRoom.SendMessage($"机器人 {Name} 准备帮助你。");
            }
        }
    }
}

使用:

var chat = new ChatRoom();
var user = new User("叶甫根尼");
var bot = new Bot("助手");

chat.MessageReceived += user.OnMessageReceived;
chat.MessageReceived += bot.OnMessageReceived;

// 叶甫根尼发了一条会触发机器人的消息
chat.SendMessage("你好,机器人,最近怎么样?");

控制台可能会输出(注意:调用顺序不保证):

[叶甫根尼] 看到新消息: 你好,机器人,最近怎么样?
[机器人 助手]: 你好!我能帮啥?
[叶甫根尼] 看到新消息: 机器人 助手 准备帮助你。
[机器人 助手]: 你好!我能帮啥?
[叶甫根尼] 看到新消息: 机器人 助手 准备帮助你。
[机器人 助手]: 你好!我能帮啥?
...

谁注意到潜在的 bug 了?没错 —— 无限循环:机器人对自己的消息也会产生反应(因为里面有关键词 "机器人")。其中一种避免办法是在处理器里加个简单检查:

public void OnMessageReceived(object? sender, MessageEventArgs e)
{
    // 机器人不对自己的消息作反应
    if (e.MessageText.Contains("机器人", StringComparison.OrdinalIgnoreCase) &&
        !e.MessageText.Contains(Name))
    {
        if (sender is ChatRoom chatRoom)
        {
            Console.WriteLine($"[机器人 {Name}]: 你好!我能帮啥?");
            chatRoom.SendMessage($"机器人 {Name} 准备帮助你。");
        }
    }
}

实际中遇到这种情况时,值得考虑循环事件的处理、用 lambda 表达式做过滤,或者实现取消后续处理器的机制(参见后面的章节)。

5. 异步操作和回调

事件经常用来通知异步操作完成。比如从网络加载数据或长时间计算:通过 Completed 事件通知结束,而 RunLongOperationAsync 执行耗时操作。

public class LongRunner
{
    // 完成事件
    public event EventHandler<EventArgs>? Completed;

    public async Task RunLongOperationAsync()
    {
        Console.WriteLine("长操作开始...");
        await Task.Delay(2000); // 模拟耗时工作
        Console.WriteLine("操作完成,通知订阅者。");
        Completed?.Invoke(this, EventArgs.Empty);
    }
}

客户端代码:

var runner = new LongRunner();

// 订阅完成事件
runner.Completed += (sender, e) =>
{
    Console.WriteLine("收到通知:操作完成!");
};

await runner.RunLongOperationAsync();

这就是异步交互的基础 —— UI 框架里经常用事件来表示加载结束、动画结束、用户点击等。

6. 信号系统

再看一个常见任务:通知系统(比如电商里商品打折)。所有关心的人会通过 SaleOccurred 事件收到通知:

public class SaleNotifier
{
    public event EventHandler<SaleEventArgs>? SaleOccurred;

    public void AnnounceSale(string product, decimal newPrice)
    {
        SaleOccurred?.Invoke(this, new SaleEventArgs(product, newPrice));
    }
}

public class SaleEventArgs : EventArgs
{
    public string Product { get; }
    public decimal NewPrice { get; }
    public SaleEventArgs(string product, decimal price)
    {
        Product = product; NewPrice = price;
    }
}

public class Customer
{
    public string Name { get; }
    public Customer(string name) => Name = name;

    public void OnSale(object? sender, SaleEventArgs e)
    {
        Console.WriteLine($"[{Name}] 收到通知:{e.Product} 现在价格是 {e.NewPrice.ToString(\"F2\")} 单位!");
    }
}

使用:

var notifier = new SaleNotifier();
var c1 = new Customer("安德烈");
var c2 = new Customer("奥尔加");

notifier.SaleOccurred += c1.OnSale;
notifier.SaleOccurred += c2.OnSale;

notifier.AnnounceSale("水壶", 999.99m);

如果某个客户不再感兴趣,就退订:

notifier.SaleOccurred -= c2.OnSale;
notifier.AnnounceSale("搅拌机", 1999.99m);

这个模式用于通知广播(notification, pub/sub),在现代架构中非常重要。

7. 实现取消事件链的机制

有时候某个处理器需要「阻止」后续通知。通常用继承自 EventArgs 的取消标志来实现。下面示例手动遍历 GetInvocationList(),当 Cancel = true 时停止执行。

public class CancelEventArgs : EventArgs
{
    public bool Cancel { get; set; }
}

public class EventSource
{
    public event EventHandler<CancelEventArgs>? SomethingHappened;

    public void DoSomething()
    {
        var args = new CancelEventArgs();
        // 经典的遍历订阅者循环(当你需要控制调用顺序时)
        var handlers = SomethingHappened?.GetInvocationList();
        if (handlers != null)
        {
            foreach (var handler in handlers)
            {
                ((EventHandler<CancelEventArgs>)handler)(this, args);
                if (args.Cancel)
                {
                    Console.WriteLine("消息链已中断。");
                    break;
                }
            }
        }
    }
}

使用:

var source = new EventSource();
source.SomethingHappened += (s, e) =>
{
    Console.WriteLine("第一个处理器");
};
source.SomethingHappened += (s, e) =>
{
    Console.WriteLine("第二个处理器取消事件。");
    e.Cancel = true;
};
source.SomethingHappened += (s, e) =>
{
    Console.WriteLine("这个处理器不会被调用。");
};

source.DoSomething();

结果:

第一个处理器
第二个处理器取消事件。
消息链已中断。

这种机制常用于校验、在窗口关闭前的处理、权限检查等需要能够说「停!别继续处理」的场景。

8. 线程安全和订阅者管理

在多线程应用中,订阅者可能在事件触发时被添加或移除,这时需要线程安全的模式(参见 官方文档)。可以手动实现事件的 add/remove 访问器,并用 lock 来保护访问:

public class CustomEvent
{
    private EventHandler? _handlers;

    public event EventHandler SomethingHappened
    {
        add
        {
            lock (this) // 线程安全
            {
                _handlers += value;
            }
        }
        remove
        {
            lock (this)
            {
                _handlers -= value;
            }
        }
    }

    protected void RaiseEvent()
    {
        // 使用副本
        EventHandler? handler;
        lock (this)
        {
            handler = _handlers;
        }
        handler?.Invoke(this, EventArgs.Empty);
    }
}

这个方式不常用(通常内置机制已经够用),但在高并发系统里有时需要手动控制。

9. UI 中的事件 — WinForms/WPF/MAUI

WinForms:

private void button1_Click(object sender, EventArgs e)
{
    MessageBox.Show("按钮被按下!");
}

背后是这样的订阅:

button1.Click += button1_Click;

WPF/MAUI: 常见的是 PropertyChanged 这类事件(模型属性变更通知):

public class MyViewModel : INotifyPropertyChanged
{
    private string _value;
    public string Value
    {
        get => _value;
        set
        {
            if (_value != value)
            {
                _value = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value));
            }
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
}

框架大量使用事件来构建响应式界面(MVVM 风格)。

10. 常见错误和踩坑

错误 #1:造成内存泄漏。
这是最常见的问题。如果短生命周期的对象(比如窗口或临时服务)订阅了长生命周期对象(比如全局缓存或静态类)的事件,且在销毁时没有退订,它会一直被引用,永远不会被回收。务必在 Dispose 或合适的生命周期点退订。

错误 #2:造成无限事件循环。
订阅者在处理事件时可能触发同样的事件,导致无限递归并最终抛出 StackOverflowException。总要检查条件,避免处理器对自己或自己触发的事件作出反应。

错误 #3:依赖订阅者调用顺序。
不要指望事件处理器会按照订阅顺序被调用。C# 规范和 CLR 实现并不保证这一点。如果顺序重要,就手动通过 GetInvocationList() 以你需要的顺序调用订阅者。

错误 #4:在多线程环境下对事件的不安全操作。
如果在一个线程中添加或移除订阅者,而在另一个线程中触发事件,可能会出现竞态条件。经典做法是在调用前把委托复制到局部变量:

var handler = MyEvent;
handler?.Invoke(this, EventArgs.Empty);

或者在手动实现 add/remove 时使用 lock 来保护。

2
任务
C# SELF, 第 54 级, 课程 4
已锁定
促销通知系统
促销通知系统
1
调查/小测验
委托常见错误第 54 级,课程 4
不可用
委托常见错误
事件编程最佳实践
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION