C# 事件(Event)详解:从概念到实战

1. 什么是 C# 事件?

在 C# 中,**事件(Event)**是一种特殊的委托类型成员,用于实现发布-订阅(Publisher-Subscriber)模式。它允许一个对象(发布者)在特定动作发生时通知其他对象(订阅者),从而实现对象间的松耦合通信。

事件的核心思想是:"发生了某件事"。例如,按钮被点击、文件下载完成、温度超过阈值等。事件的典型应用场景包括 GUI 编程(如 WinForms、WPF)、异步通知、插件系统等。

2. 事件的核心要素

一个完整的事件机制包含以下三个关键部分:

  • 事件发布者(Publisher):定义事件并触发事件的对象。
  • 事件订阅者(Subscriber):注册事件处理程序并响应事件的对象。
  • 事件处理程序(EventHandler):当事件被触发时执行的方法。

3. 声明与使用事件的语法

3.1 使用内置 EventHandler 委托

这是最常用、最标准的方式。.NET 提供了 EventHandlerEventHandler<TEventArgs> 委托。

csharp 复制代码
// 发布者类
public class Button
{
    // 1. 声明事件(使用 EventHandler 委托)
    public event EventHandler Clicked;

    // 2. 触发事件的方法(通常命名为 OnXXX,并遵循 protected virtual 约定)
    protected virtual void OnClicked(EventArgs e)
    {
        // 使用空条件运算符安全调用
        Clicked?.Invoke(this, e);
    }

    // 模拟按钮被点击
    public void SimulateClick()
    {
        Console.WriteLine("按钮被点击了!");
        OnClicked(EventArgs.Empty);
    }
}

// 订阅者类
public class Logger
{
    // 事件处理程序:签名必须匹配 EventHandler(object sender, EventArgs e)
    public void LogButtonClick(object sender, EventArgs e)
    {
        Console.WriteLine($"[{DateTime.Now}] 按钮点击事件已记录。发送者:{sender}");
    }
}

// 使用示例
class Program
{
    static void Main()
    {
        Button btn = new Button();
        Logger logger = new Logger();

        // 订阅事件:使用 += 运算符
        btn.Clicked += logger.LogButtonClick;

        // 触发事件
        btn.SimulateClick();

        // 输出:
        // 按钮被点击了!
        // [2023-10-27 10:30:00] 按钮点击事件已记录。发送者:Button
    }
}

3.2 自定义事件参数(EventArgs)

如果需要传递更多数据,可以创建自定义事件参数类,继承自 EventArgs

csharp 复制代码
// 自定义事件参数
public class TemperatureChangedEventArgs : EventArgs
{
    public double OldTemperature { get; }
    public double NewTemperature { get; }

    public TemperatureChangedEventArgs(double oldTemp, double newTemp)
    {
        OldTemperature = oldTemp;
        NewTemperature = newTemp;
    }
}

// 温度传感器(发布者)
public class TemperatureSensor
{
    private double _currentTemp;

    // 使用泛型 EventHandler<TEventArgs>
    public event EventHandler<TemperatureChangedEventArgs> TemperatureChanged;

    public double CurrentTemperature
    {
        get => _currentTemp;
        set
        {
            if (Math.Abs(_currentTemp - value) > 0.1) // 温度变化超过0.1度时触发
            {
                var oldTemp = _currentTemp;
                _currentTemp = value;
                OnTemperatureChanged(oldTemp, value);
            }
        }
    }

    protected virtual void OnTemperatureChanged(double oldTemp, double newTemp)
    {
        TemperatureChanged?.Invoke(this, new TemperatureChangedEventArgs(oldTemp, newTemp));
    }
}

// 订阅者:温度显示器
public class TemperatureDisplay
{
    public void OnTemperatureChanged(object sender, TemperatureChangedEventArgs e)
    {
        Console.WriteLine($"温度变化:{e.OldTemperature:F1}°C → {e.NewTemperature:F1}°C");
    }
}

3.3 自定义委托类型的事件(传统方式)

在 C# 1.0 时代常见,现在推荐使用 EventHandler,但为了兼容旧代码仍需了解。

csharp 复制代码
// 自定义委托类型
public delegate void MyCustomEventHandler(object sender, string message);

public class Publisher
{
    // 声明基于自定义委托的事件
    public event MyCustomEventHandler CustomEvent;

    public void DoSomething()
    {
        CustomEvent?.Invoke(this, "自定义事件被触发!");
    }
}

4. 事件访问器(add/remove)

默认情况下,事件的 +=-= 操作由编译器自动生成。但你可以像属性一样,提供自定义的添加/移除逻辑。

csharp 复制代码
public class EventWithAccessor
{
    private EventHandler _myEvent;

    public event EventHandler MyEvent
    {
        add
        {
            Console.WriteLine($"添加事件处理程序:{value.Method.Name}");
            _myEvent += value;
        }
        remove
        {
            Console.WriteLine($"移除事件处理程序:{value.Method.Name}");
            _myEvent -= value;
        }
    }

    public void RaiseEvent()
    {
        _myEvent?.Invoke(this, EventArgs.Empty);
    }
}

5. 事件使用的最佳实践

  • 命名约定 :事件名使用动词或动词短语(如 ClickedDataReceived)。触发事件的方法通常命名为 OnEventName
  • 线程安全:在多线程环境中,应先将事件引用复制到局部变量再触发,避免竞态条件。
  • 空条件运算符 :始终使用 ?.Invoke() 检查事件是否为 null。
  • EventArgs.Empty :当不需要传递额外数据时,使用 EventArgs.Empty 而不是 new EventArgs()
  • 及时取消订阅 :避免内存泄漏,当订阅者不再需要接收事件时,使用 -= 取消订阅。

6. 事件 vs 委托 vs 回调

特性 事件 委托 回调(如 Action/Func)
封装性 高(外部只能 +=/-=) 中(可赋值、调用) 低(完全公开)
多播 支持 支持 单播(但可通过委托实现)
典型用途 发布-订阅模式 方法指针、回调基础 简单回调、Lambda 表达式
安全性 外部不能直接触发 外部可随意调用 外部可随意调用

7. 实战示例:简单的订单系统

csharp 复制代码
// 订单事件参数
public class OrderPlacedEventArgs : EventArgs
{
    public string OrderId { get; }
    public decimal TotalAmount { get; }
    public DateTime OrderTime { get; }

    public OrderPlacedEventArgs(string orderId, decimal totalAmount)
    {
        OrderId = orderId;
        TotalAmount = totalAmount;
        OrderTime = DateTime.Now;
    }
}

// 订单服务(发布者)
public class OrderService
{
    public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

    public void PlaceOrder(string orderId, decimal amount)
    {
        // 业务逻辑...
        Console.WriteLine($"订单 {orderId} 已创建,金额:{amount:C}");

        // 触发事件
        OnOrderPlaced(new OrderPlacedEventArgs(orderId, amount));
    }

    protected virtual void OnOrderPlaced(OrderPlacedEventArgs e)
    {
        OrderPlaced?.Invoke(this, e);
    }
}

// 订阅者1:库存服务
public class InventoryService
{
    public void UpdateInventory(object sender, OrderPlacedEventArgs e)
    {
        Console.WriteLine($"[库存] 订单 {e.OrderId} 已处理,开始扣减库存...");
    }
}

// 订阅者2:邮件服务
public class EmailService
{
    public void SendConfirmationEmail(object sender, OrderPlacedEventArgs e)
    {
        Console.WriteLine($"[邮件] 已向客户发送订单 {e.OrderId} 的确认邮件");
    }
}

// 使用
class Program
{
    static void Main()
    {
        OrderService orderService = new OrderService();
        InventoryService inventory = new InventoryService();
        EmailService email = new EmailService();

        // 订阅事件
        orderService.OrderPlaced += inventory.UpdateInventory;
        orderService.OrderPlaced += email.SendConfirmationEmail;

        // 下订单
        orderService.PlaceOrder("ORD-20231027-001", 299.99m);

        // 输出:
        // 订单 ORD-20231027-001 已创建,金额:¥299.99
        // [库存] 订单 ORD-20231027-001 已处理,开始扣减库存...
        // [邮件] 已向客户发送订单 ORD-20231027-001 的确认邮件
    }
}

8. 常见问题与注意事项

8.1 内存泄漏问题

事件订阅会保持对订阅者对象的引用,如果订阅者是短生命周期对象而发布者是长生命周期对象,可能导致订阅者无法被垃圾回收。

解决方案:在适当的时候取消订阅(如 Dispose 方法中)。

8.2 异常处理

事件处理程序中抛出异常会中断后续处理程序的执行。建议在每个处理程序内部处理异常。

csharp 复制代码
// 不安全的做法
public event EventHandler MyEvent;

// 安全的做法:封装调用
protected virtual void OnMyEvent()
{
    var handlers = MyEvent?.GetInvocationList();
    if (handlers != null)
    {
        foreach (EventHandler handler in handlers)
        {
            try
            {
                handler.Invoke(this, EventArgs.Empty);
            }
            catch (Exception ex)
            {
                // 记录异常,但不中断其他处理程序
                Console.WriteLine($"事件处理程序异常:{ex.Message}");
            }
        }
    }
}

8.3 事件与接口

事件可以作为接口成员声明,实现类必须提供该事件。

csharp 复制代码
public interface IObservable
{
    event EventHandler SomethingHappened;
}

public class MyObservable : IObservable
{
    public event EventHandler SomethingHappened;
}

9. 总结

C# 事件是 .NET 事件驱动编程的核心机制,它:

  1. 提供了一种标准化的发布-订阅实现方式
  2. 通过委托实现类型安全的多播
  3. 支持自定义事件参数传递数据
  4. 遵循 .NET 框架设计指南,具有良好的互操作性

掌握事件的使用,能够让你编写出更加松耦合、可扩展的 C# 应用程序。在实际开发中,结合异步编程(async/await)和 Reactive Extensions(Rx.NET)等现代技术,可以构建出更加强大的事件驱动系统。

相关推荐
Terra.K1 小时前
Java异常学习[特殊字符]
java·开发语言·学习
rockey6273 小时前
C#脚本引擎之AScript与Flee对比
c#·.net·script·eval·动态脚本
葡萄城技术团队3 小时前
InfluxDB 2\.x 深度解析:核心架构、Flux 函数与制造业落地指南(三)
java·开发语言·架构
counting money3 小时前
Java IO流详解:从InputStream到文件操作实战
java·开发语言·python
wuyk5553 小时前
98.C语言易混难点:字符数组与字符串指针的底层差异
c语言·开发语言·c++·stm32·嵌入式硬件·算法
峥无4 小时前
从0到1手撕红黑树:封装实现 my_map 与 my_set(SGI-STL 源码级深度解析)
开发语言·c++·笔记·算法·stl
波特率1152004 小时前
C++新特性---属性说明符与标准属性
开发语言·c++
程序员小八7774 小时前
上海百度B端java后端日常实习一面
java·开发语言
wp123_14 小时前
IPX8 防水 Type‑C连接器:安费诺 124018792112A 与 TONEVEE TY48087‑24A 技术梳理
c语言·开发语言