用 dotMemory 定位 C# WPF 客户端内存泄露(附最小可复现示例)

用 dotMemory 定位 C# WPF 客户端内存泄露(附最小可复现示例)

前言

WPF(Windows Presentation Foundation)运行在 .NET 托管环境中,理论上由 GC(垃圾回收器)自动回收内存。但"自动管理"并不等于"不会泄露"——在 WPF 开发中,事件订阅、数据绑定、静态引用等很容易让对象"本该被回收却回收不掉",导致内存持续上涨、界面越来越卡,最终崩溃。

很多开发者以为托管环境没有内存泄露,直到线上客户端内存占用涨到几个 GB 才意识到问题。本文介绍如何用 JetBrains 的 dotMemory 定位 WPF 应用的内存泄露,并给出一个最小可复现的示例。

一、WPF 内存泄露的常见原因

  1. 事件订阅后未取消订阅(最常见):窗口/控件订阅了静态对象或单例的事件,关闭时忘了 -=
  2. 静态字段持有实例引用:把窗口、控件或大对象塞进 static 集合后不清理。
  3. 数据绑定未实现 INotifyPropertyChanged:绑定到普通对象且 Mode 不是 OneTime,WPF 会通过反射/属性描述符持有引用。
  4. DispatcherTimer / 后台线程未停止。
  5. 未释放非托管资源:BitmapFontBrushImageSource 等。
  6. lambda / 闭包捕获了长生命周期的外部变量。
  7. ICommand / 事件聚合器(Event Aggregator)的循环引用。

其中事件订阅泄露是最典型、最好复现、也最能体现 dotMemory 价值的场景,本文就以它为例。

二、dotMemory 简介

dotMemory 是 JetBrains 出品的 .NET 内存分析工具,属于 dotUltimate 套件,也可以集成在 Rider 中使用。它的核心能力:

  1. 获取内存快照(Snapshot)。
  2. 对比多个快照,找出"新增对象 / 存活对象 / 已回收对象"。
  3. 查看对象的保留路径(Retention Paths / Key Retention Paths),回答"到底是谁拽着这个对象不让 GC 回收"。
  4. 分析支配树(Dominators)、大对象堆(LOH)等。

本文以独立版 dotMemory 为例,界面术语会同时给出中文说明。

三、准备一个会泄露的示例

我们先写一个"故意泄露"的 WPF 应用,用来演示定位过程。

3.1 一个全局事件源(单例)

// EventService.cs
using System;

namespace LeakDemo
{
    /// <summary>
    /// 全局事件源:进程存活期间一直存在(静态单例)。
    /// </summary>
    public sealed class EventService
    {
        public static readonly EventService Instance = new EventService();

        // 注意:这是一个"实例事件",订阅它的对象会被事件源反向持有引用
        public event EventHandler? DataChanged;

        public void RaiseDataChanged()
        {
            DataChanged?.Invoke(this, EventArgs.Empty);
        }
    }
}

3.2 一个会泄露的窗口

// LeakyWindow.xaml.cs
using System;
using System.Windows;

namespace LeakDemo
{
    public partial class LeakyWindow : Window
    {
        public LeakyWindow()
        {
            InitializeComponent();

            // 订阅单例的事件,但关闭窗口时【没有取消订阅】
            EventService.Instance.DataChanged += OnDataChanged;
        }

        private void OnDataChanged(object? sender, EventArgs e)
        {
            Title = $"收到事件:{DateTime.Now:HH:mm:ss.fff}";
        }
    }
}
<!-- LeakyWindow.xaml -->
<Window x:Class="LeakDemo.LeakyWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="LeakyWindow" Height="200" Width="300">
    <TextBlock Text="这是一个会泄露的窗口" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Window>

3.3 主窗口触发打开/关闭

// MainWindow.xaml.cs
using System.Windows;

namespace LeakDemo
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void OpenLeakyWindow_Click(object sender, RoutedEventArgs e)
        {
            var win = new LeakyWindow();
            win.Show();
        }

        private void RaiseEvent_Click(object sender, RoutedEventArgs e)
        {
            EventService.Instance.RaiseDataChanged();
        }
    }
}
<!-- MainWindow.xaml -->
<Window x:Class="LeakDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="200" Width="400">
    <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
        <Button Content="打开 LeakyWindow" Click="OpenLeakyWindow_Click" Margin="0,0,0,10"/>
        <Button Content="触发事件" Click="RaiseEvent_Click"/>
    </StackPanel>
</Window>

泄露原理EventService.Instance.DataChanged 是一个委托链,每打开一次 LeakyWindow 就往链里追加一个指向 OnDataChanged 的委托,而 OnDataChangedLeakyWindow 的实例方法,委托会持有 this(即窗口实例)。只要静态单例 EventService.Instance 还活着(整个进程存活期间它都活着),所有被订阅过的窗口实例就都无法被 GC 回收。

四、用 dotMemory 定位泄露

4.1 编译并运行示例

用 Visual Studio 编译运行上面的 WPF 应用(Debug 或 Release 均可,建议 Release 更贴近真实环境)。

4.2 附加到进程

  1. 打开 dotMemory,选择 Profiling 页签。
  2. 点击 Attach to Running Process(附加到运行中的进程),在列表中找到你的 WPF 进程(进程名通常与项目名一致,如 LeakDemo.exe)。
  3. 附加成功后,主界面会显示内存概览和 Get Snapshot(获取快照)按钮。

4.3 获取基线快照

  1. 先点击 Force GC(强制 GC),把"可回收但还没回收"的对象先清掉,排除假象。
  2. 点击 Get Snapshot,得到快照 #1(基线)。

4.4 制造泄露

  1. 回到应用,反复点击"打开 LeakyWindow",然后关闭窗口,重复 20 次(次数多一点对比更明显)。
  2. 回到 dotMemory,再次 Force GC,再 Get Snapshot,得到快照 #2

4.5 对比快照

  1. 在快照列表里勾选快照 #1#2
  2. 点击 Compare(对比),进入对比视图。
  3. 对比视图通常分为 New objects(新增对象)、Dead objects(已回收对象)、Survived objects(存活对象)几类。

4.6 搜索泄露的类型

  1. New objects 视图中,用搜索框输入 LeakyWindow(或它的完整类型名 LeakDemo.LeakyWindow)。
  2. 你会看到 20 个 LeakyWindow 实例仍然存活——而我们早就把窗口关掉了,理想情况下应该是 0 个

4.7 查看保留路径,找到根因

  1. 选中任意一个 LeakyWindow 实例。
  2. 查看 Key Retention Paths(关键保留路径),会看到类似这样的引用链:
LeakyWindow
  ← EventHandler (LeakDemo.LeakyWindow.OnDataChanged)
    ← EventService.DataChanged
      ← EventService.Instance (static field)

这条路径清晰地告诉我们:静态单例 EventService.Instance 通过 DataChanged 事件订阅,反向持有了每一个 LeakyWindow 实例,所以它们永远无法被回收。根因就此定位。

五、修复方案

针对"事件订阅未取消"这类泄露,常见有三种修法,按推荐程度排列。

方案一:关闭时取消订阅(最简单直接)

public partial class LeakyWindow : Window
{
    public LeakyWindow()
    {
        InitializeComponent();
        EventService.Instance.DataChanged += OnDataChanged;

        // 窗口关闭时取消订阅
        Closed += OnWindowClosed;
    }

    private void OnDataChanged(object? sender, EventArgs e)
    {
        Title = $"收到事件:{DateTime.Now:HH:mm:ss.fff}";
    }

    private void OnWindowClosed(object? sender, EventArgs e)
    {
        EventService.Instance.DataChanged -= OnDataChanged;
    }
}

方案二:使用 WeakEventManager(弱事件模式)

当订阅方生命周期不确定、容易漏写取消订阅时,用 WPF 自带的 WeakEventManager 让事件源不持有强引用

public partial class LeakyWindow : Window
{
    public LeakyWindow()
    {
        InitializeComponent();

        WeakEventManager<EventService, EventArgs>.AddHandler(
            EventService.Instance,
            nameof(EventService.DataChanged),
            OnDataChanged);

        Closed += (s, e) => WeakEventManager<EventService, EventArgs>.RemoveHandler(
            EventService.Instance,
            nameof(EventService.DataChanged),
            OnDataChanged);
    }

    private void OnDataChanged(object? sender, EventArgs e)
    {
        Title = $"收到事件:{DateTime.Now:HH:mm:ss.fff}";
    }
}

需要给 EventService 添加一个对应的 static 事件(WeakEventManager 要求事件源提供静态事件),或改用社区成熟的弱事件库。

方案三:WeakReference 包装

WeakReference 持有订阅方,事件触发时再判断目标是否存活。逻辑较繁琐,一般优先选前两种。

六、验证修复效果

修复后,重复第四节的流程:

  1. 重新编译运行。
  2. 附加 dotMemory,Force GC 后取基线快照。
  3. 反复打开/关闭窗口 20 次。
  4. Force GC 后再取快照,对比 New objects

这次搜索 LeakyWindow,实例数量应为 0(已被回收),说明泄露已修复。

七、总结与最佳实践

  1. 凡订阅了长生命周期对象(静态字段、单例、App 级服务)的事件,务必在关闭/释放时取消订阅。
  2. 对生命周期不确定的订阅,优先使用 WeakEventManager 等弱事件方案。
  3. 定时器、后台线程、非托管资源要显式停止/释放。
  4. 数据绑定目标对象应实现 INotifyPropertyChanged,避免绑定到普通对象产生隐式引用。
  5. 把内存检查纳入开发流程:功能开发阶段定期用 dotMemory 做快照对比,把"打开/关闭窗口后对象应回收"作为验收项。

内存泄露的本质不是"内存不够用",而是该释放的对象被一条看不见的引用链拽住了。dotMemory 的价值,就是把这根看不见的链子清晰地画出来。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值