WinForms 学习笔记(三):事件模型——老式 event 与内存泄漏陷阱

写在前面

承接篇2。本篇讲 WinForms 的事件驱动模型:老式 .NET event、订阅退订的讲究,以及最经典的坑——没退订导致的内存泄漏。

WinForms 是事件驱动的:用户点按钮 → 触发 Click 事件 → 你的处理函数跑。整个 UI 编程就是"挂事件、写处理函数"。但 .NET 的 event 模型有个反直觉的特性:订阅了不退订,会内存泄漏。这一篇把这个坑讲透。


一、.NET event 基础回顾

1
2
3
4
5
6
7
8
9
// 声明
public event EventHandler<EventArgs> SomethingHappened;

// 触发
SomethingHappened?.Invoke(this, EventArgs.Empty);

// 订阅 / 退订
obj.SomethingHappened += Handler;
obj.SomethingHappened -= Handler;

event 本质是封装的委托(只能 +=/-=,不能外部直接赋值或触发)。EventHandler 标准签名:(object sender, EventArgs e)

WinForms 控件的事件都是这个模型:Button.ClickTextBox.TextChangedForm.FormClosing……


二、WinForms 里怎么挂事件

方式一:设计器挂接(最常用)

在 VS 属性面板的"事件"页,双击事件 → 生成处理函数 + 在 .Designer.cs+=

1
2
3
4
5
// .Designer.cs
this.button1.Click += button1_Click;

// Form1.cs
private void button1_Click(object sender, EventArgs e) { ... }

方式二:代码挂接

1
2
3
button1.Click += Button1_Click;
// 或 lambda
button1.Click += (s, e) => MessageBox.Show("clicked");

lambda 挂接的坑:lambda 没名字,无法 -= 退订(后面内存泄漏会讲)。要退订的事件,别用 lambda。


三、订阅与退订:为什么要退订

关键认知:事件订阅是引用链——事件源(被 += 的对象)持有订阅者(处理函数的 target)的引用。

1
2
3
4
事件源.button1.Click (委托链)
   → 持有 → Form 处理函数的 target(通常 this / Form 实例)

  即:button1 活着,就持有 Form,Form 不能被 GC

这在 Form 内部通常没事(Form 和 button1 同寿)。但跨对象订阅时,泄漏就来了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 泄漏场景:短寿命的 DetailForm 订阅长寿命的 EventBus 事件
public class DetailForm : Form
{
    public DetailForm(EventBus bus)
    {
        bus.DataChanged += OnDataChanged;   // 订阅
    }
    void OnDataChanged(object s, EventArgs e) { ... }
    // DetailForm 关闭时,忘了 bus.DataChanged -= OnDataChanged
}
1
2
3
4
5
6
7
8
泄漏链:
  EventBus(长寿命,singleton)
    .DataChanged (委托链)
      → 持有 → DetailForm 实例(闭包 target)

  DetailForm 关闭后,本该被 GC
  但 EventBus 的事件链还持有它 → 永远活着 → 泄漏
  每开一次 DetailForm 泄漏一个,内存蹭蹭涨

修复:DetailForm 关闭时退订:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class DetailForm : Form
{
    private readonly EventBus _bus;
    public DetailForm(EventBus bus) { _bus = bus; _bus.DataChanged += OnDataChanged; }

    void OnDataChanged(object s, EventArgs e) { ... }

    protected override void OnFormClosed(FormClosedEventArgs e)
    {
        _bus.DataChanged -= OnDataChanged;   // 关键:退订
        base.OnFormClosed(e);
    }
}

规则:谁订阅谁退订。订阅的逆操作必须在订阅者生命周期结束时执行(Dispose / FormClosed)。


四、内存泄漏的三种典型场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
场景 1:短寿命对象订阅长寿命对象事件(最常见)
  → 上面 DetailForm + EventBus 例子
  → 修复:短寿命对象销毁时退订

场景 2:lambda 捕获 this
  longLived.Event += () => this.DoSomething();
  → lambda 闭包持有 this,longLived 活着就持有 this
  → 修复:别用 lambda 捕获 this 订阅长寿命事件;用方法 + 显式 -=

场景 3:忘了退订的事件处理函数持有大对象
  → 处理函数 target 持有大 Bitmap/Form,不退订就一直不释放
  → 修复:同上,规则退订

五、弱事件:让事件源不持有订阅者

如果订阅者生命周期不可控(插件、动态加载的 Form),手动退订麻烦,弱事件模式让事件源不阻止订阅者被 GC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 用 WeakReference 包装订阅者(简化版弱事件)
public class WeakEvent<TArgs> where TArgs : EventArgs
{
    private readonly List<WeakReference> _handlers = new();
    public void Add(EventHandler<TArgs> h) => _handlers.Add(new WeakReference(h));

    public void Raise(object sender, TArgs args)
    {
        for (int i = _handlers.Count - 1; i >= 0; i--)
        {
            if (_handlers[i].Target is EventHandler<TArgs> h)
                h(sender, args);
            else
                _handlers.RemoveAt(i);   // 已被 GC 的清理掉
        }
    }
}

生产别自己造:WPF 有内置 WeakEventManager,WinForms 没原生弱事件,但可用 WeakReference 封装或第三方库。原则:长寿命事件源 + 不可控订阅者用弱事件。


六、Form 与 Application 级事件

WinForms 几个常用生命周期事件:

事件触发时机典型用途
Form.Load窗体首次显示前初始化数据、读配置
Form.FormClosing关闭前(可取消)“确定退出?“弹窗、e.Cancel = true
Form.FormClosed关闭后退订事件、释放资源
Application.Idle消息队列空时偶尔做的后台轻任务(已少用)
Application.ApplicationExit进程退出全局清理
1
2
3
4
5
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
    if (MessageBox.Show("确定退出?", "确认", MessageBoxButtons.YesNo) == DialogResult.No)
        e.Cancel = true;   // 阻止关闭
}

FormClosing(ing,可取消)vs FormClosed(ed,已关闭,不可逆)——命名一致:ing 在前可干预,ed 在后已发生。


七、自定义事件

写自己的控件/组件时,常要发自定义事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class MyTimer : Component
{
    // 1. 声明事件
    public event EventHandler<TickEventArgs> Tick;

    // 2. 触发(受保护的虚方法,子类可重写)
    protected virtual void OnTick(TickEventArgs e) => Tick?.Invoke(this, e);

    internal void Fire() => OnTick(new TickEventArgs { Count = ++_count });
    private int _count;
}

public class TickEventArgs : EventArgs
{
    public int Count { get; set; }
}

要点:

  • 事件用 EventHandler<TArgs> 标准签名((object sender, TArgs e))。
  • EventArgs 子类装事件数据;无数据的用 EventArgs.Empty
  • 触发用受保护虚方法 OnXxx,子类可重写拦截。

八、小结

1
2
3
4
5
6
7
WinForms 事件核心:
  ✓ event 是封装的委托;+= 订阅、-= 退订
  ✓ 事件订阅是引用链:源持有订阅者 → 阻止 GC
  ✓ 规则:谁订阅谁退订(FormClosed/Dispose 里 -=)
  ✓ lambda 订阅无法退订 → 慎用于长寿命事件
  ✓ Form.Load/FormClosing/FormClosed 是常用生命周期事件
  ✓ 自定义事件用 EventHandler<TArgs> + OnXxx 虚方法

下一篇:GDI+ 绘制——GraphicsOnPaint、双缓冲、自定义控件,以及 GDI+ 对象的 Dispose。