目 录CONTENT

文章目录

CSharp(四十三) C# 中事件访问器的定义、使用和注意事项

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() 表示触发事件。

这类写法叫“字段式事件”,也是最常见、最容易理解的事件写法。


二、什么是事件访问器

事件访问器,指的是事件中的 addremove 代码块。

它们的作用是:

自定义事件在订阅和取消订阅时要执行什么逻辑。

普通事件写法:

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

addremove 中,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();

才是真正触发事件。


六、为什么需要事件访问器

普通事件已经够用了,为什么还需要事件访问器?

因为有些时候,我们不只是想简单地添加或移除事件处理方法,还想在订阅和取消订阅时做一些额外控制。

常见用途包括:

  1. 订阅时记录日志。
  2. 限制订阅者数量。
  3. 防止重复订阅。
  4. 订阅第一个处理器时启动资源。
  5. 取消最后一个处理器时释放资源。
  6. 把事件处理器保存到特殊的数据结构中。
  7. 实现接口事件时自定义内部逻辑。

七、使用场景一:记录订阅和取消订阅日志

有时我们想知道什么时候有人订阅或取消订阅事件。

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;
    }
}

这时编译器不会再自动帮我们保存事件处理器。

因为我们已经自己接管了 addremove

所以必须自己准备一个字段:

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

addremove 是事件语法的一部分,不是普通方法。


十六、事件访问器中如何触发事件

事件访问器只负责订阅和取消订阅,不负责触发事件。

触发事件仍然要调用我们保存的委托字段。

例如:

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 里面通常是 += valueremove 里面通常是 -= 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;

不过 addremove 本身通常不单独写访问修饰符。

初学阶段记住:

事件的访问级别写在 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;
    }
}

因为第二种写法更长,也更容易写错。

适合使用事件访问器的情况:

  • 需要记录订阅和取消订阅日志。
  • 需要限制订阅规则。
  • 需要防止重复订阅。
  • 需要在第一个订阅者出现时启动资源。
  • 需要在最后一个订阅者离开时释放资源。
  • 需要把订阅者存到自定义集合或弱引用结构里。

不适合使用事件访问器的情况:

  • 只是普通通知。
  • 没有特殊订阅逻辑。
  • 初学阶段只想写简单事件。

一句话:

没有特殊订阅控制时,用普通事件;需要接管 +=-= 行为时,再用事件访问器。


二十七、课堂讲解建议

讲事件访问器时,可以按照下面顺序:

  1. 先复习普通事件:event Action Changed;
  2. 说明外部的 +=-= 本质上对应内部的 addremove
  3. 用属性的 get / set 类比事件的 add / remove
  4. value 表示当前被添加或移除的事件处理方法。
  5. 讲为什么需要私有委托字段保存处理器。
  6. 最后讲常见用途和易错点。

可以用这句话帮助学生理解:

普通事件像自动管理的报名表;事件访问器就是你自己接管报名和退报名的过程。


二十八、完整综合示例:下载器事件访问器

下面用一个下载器示例,演示事件访问器的完整使用。

功能:

  • 有人订阅进度事件时,输出日志。
  • 有人取消订阅时,输出日志。
  • 下载过程中触发进度事件。
  • 防止同一个处理器重复订阅。
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# 事件的进阶用法,用来控制事件订阅和取消订阅的过程。

可以记住下面几句话:

  1. 事件访问器包括 addremove
  2. 外部写 += 时,会执行 add
  3. 外部写 -= 时,会执行 remove
  4. value 表示当前被添加或移除的事件处理器。
  5. 自定义事件访问器时,通常需要一个私有委托字段保存事件处理器。
  6. 触发事件时,应该调用私有委托字段,而不是在访问器里递归操作事件本身。
  7. 事件访问器可以用来记录日志、防止重复订阅、限制订阅者、管理资源。
  8. 没有特殊需求时,普通事件写法更简单、更推荐。

一句话概括:

事件访问器就是给事件的 +=-= 加上自定义规则,让我们可以接管“谁能订阅、怎么订阅、取消订阅时做什么”。

0
博主关闭了当前页面的评论