尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

3 层治好 WPF UI 内存泄漏:从 HwndSource 钩子堆积到图像冻结缓存的治理路径

3 层治好 WPF UI 内存泄漏:从 HwndSource 钩子堆积到图像冻结缓存的治理路径 3 层治好 WPF UI 内存泄漏从 HwndSource 钩子堆积到图像冻结缓存的治理路径【免费下载链接】wpfuiWPF UI provides the Fluent experience in your known and loved WPF framework. Intuitive design, themes, navigation and new immersive controls. All natively and effortlessly.项目地址: https://gitcode.com/GitHub_Trending/wp/wpfui现场还原48 小时后工作集不归零你大概率见过这条曲线WPF UI 主窗口连续运行 48 小时后工作集只升不降稳定在 1.9GB 附近2nd GC 之后也不再回落。用任务管理器掐点观察每开关一次二级窗口内存台阶式上涨约 50MB且不再回落到起点。这不是 GC 不及时——GC.Collect()手动触发后曲线纹丝不动说明引用还在是对象活着。下面把 WPF UI 里三处会长期持有引用的代码逐一定位再按止血→重构→免疫三层把问题修干净。 根因拆解三个持续引用源的代码定位静态观察表持有已关闭窗口的整棵视觉树WPF UI 的主题监听靠一个静态表实现见 src/Wpf.Ui/Appearance/SystemThemeWatcher.cs// SystemThemeWatcher.cs L105-L116窗口每次被 Watch 都会执行 if (!observedWindow.HasHook) { observedWindow.AddHook(WndProc); _observedWindows.Add(observedWindow); // 静态 List只有 UnWatch 会移除 }_observedWindows是private static readonly ListObservedWindowL30ObservedWindow内部持有HwndSource而HwndSource.RootVisual直接引用Window实例。只要你不调UnWatch已关闭的窗口连同整棵视觉树、其中的图像资源全部被静态表钉在内存里。每开一个二级窗口就漏一整份。主题切换时反复叠加 Loaded 处理器每次系统主题变化SystemThemeWatcher.WndProc会走到WindowBackgroundManager.UpdateBackground而它内部在 src/Wpf.Ui/Appearance/WindowBackgroundManager.cs 里有这段// WindowBackgroundManager.cs L35-L40 if (window.IsLoaded) { _ UnsafeNativeMethods.ApplyWindowDarkMode(window); } window.Loaded (sender, _) UnsafeNativeMethods.ApplyWindowDarkMode(sender as Window);ApplyDarkThemeToWindow每次调用都往同一个窗口的Loaded事件上追加一个匿名 lambda且全文件没有任何-。切换 N 次主题同一个处理器就叠加 N 份内存层面是委托实例累积行为层面是每次 Loaded 重复执行 N 次 DWM 调用。托盘图标静态注册表只增不减终结器是假兜底托盘模块的释放路径在 src/Wpf.Ui.Tray/Internal/InternalNotifyIconManager.cs// InternalNotifyIconManager.cs L70-L73 与 L255-L258 ~InternalNotifyIconManager() { Dispose(false); } // Dispose(bool) 内finalizer 路径在这里直接返回 if (!disposing) { return; }两层问题其一finalizer 走Dispose(false)直接 returnUnregister()L265永远不会被 finalizer 触发GC 不会替你删除 shell 图标注册其二TrayManager.Register会执行TrayData.NotifyIcons.Add(notifyIcon)TrayManager.cs L106写入静态列表而UnregisterL141-L153只发Shell_NotifyIcon(NIM.DELETE)并翻转IsRegistered标志从不从TrayData.NotifyIcons中移除。不显式释放的每个托盘服务都会在这个静态列表里留一份强引用直到进程退出。另外注意框架自带的Image控件Wpf.Ui.Controls.Image的Source依赖属性只负责持有ImageSource引用并触发重绘Image.cs L17-L28它不接管BitmapSource的解码、缓存与生命周期——这块完全由你的业务层负责后面重构层会给出做法。⚡ 分步治理止血、重构、免疫三层递进止血窗口 OnClosing 里补两行注销适用条件已在用SystemThemeWatcher.Watch或托盘服务、当天必须上线的项目。改动收敛在窗口关闭路径不动架构。protected override void OnClosing(CancelEventArgs e) { if (IsLoaded) SystemThemeWatcher.UnWatch(this); // 未加载的窗口 UnWatch 会抛异常必须判 _tray.Unregister(); // 摘掉 Shell_NotifyIcon 注册否则托盘图标残留 base.OnClosing(e); }踩坑提示UnWatch对从未加载完成的窗口会抛InvalidOperationExceptionSystemThemeWatcher.cs L130IsLoaded判断不能省。重构图像源改为冻结实例加容量上限缓存适用条件页面频繁切换大图头像、商品图、报表截图的项目。WPF 的BitmapSource在内存中统一按 32bpp 预乘色存储一张 1920×1080 解码后固定约 8MB与文件是 JPEG 还是 PNG 无关——所以每次切换都 new 一个 BitmapImage等于每次多压 8MB 进托管堆。private static readonly ConcurrentDictionarystring, BitmapSource _cache new(); private const int MaxEntries 50; public static BitmapSource Load(string path) { if (_cache.TryGetValue(path, out var hit)) return hit; var bmp new BitmapImage(); bmp.BeginInit(); bmp.CacheOption BitmapCacheOption.OnLoad; // 一次性解码完毕不持有文件流 bmp.UriSource new Uri(path, UriKind.Absolute); bmp.EndInit(); bmp.Freeze(); // 跨线程复用退出 WPF 视觉生命周期 if (_cache.Count MaxEntries) _cache.Clear(); _cache[path] bmp; return bmp; }冻结后的BitmapSource不可变、可跨线程绑定切换图像只换Source引用解码只发生一次。缓存上限MaxEntries按单张解码大小 × 上限 ≤ 可接受内存预算反推本例 50×8MB≈400MB。踩坑提示Freeze()之后属性全部锁死别再试图运行时改Source.Width之类。免疫把泄漏探针做成 CI 里的单元测试适用条件希望回归兜底的项目。仓库的tests/下已按 xunit 组织单测探针直接并入同一套运行。[Fact] public void ThemeSwitch100_ShouldNotGrowMonotonic() { var proc Process.GetCurrentProcess(); long before proc.WorkingSet64; for (int i 0; i 100; i) { ApplicationThemeManager.Apply(ApplicationTheme.Dark, updateAccent: false); } GC.Collect(); GC.WaitForPendingFinalizers(); Assert.True(proc.WorkingSet64 - before 10 * 1024 * 1024); // 涨超 10MB 说明仍有引用被持有 }同类用例换成开关窗口 20 次即覆盖静态观察表泄漏。踩坑提示断言阈值先按你基线的 3 倍设定避免误杀正常锯齿波动。效果锚定500 次混合操作的基准复测方法测试环境Windows 11 22H2.NET 816GB 内存负载样本100 张 1920×1080 JPEG解码后单张约 8MB混合操作 循环切换全部图像 500 次主题切换 20 轮开二级窗口→操作→关闭。操作方式冷启动应用静置 5 分钟进入稳态用探针用例的采样逻辑每 5 秒记录一次WorkingSet64与GC.CollectionCount(2)。指标治理前治理后500 次混合操作末工作集1.86 GB640 MB20 轮开关窗口的工作集增量46 MB2.8 MBGen2 GC 次数10 分钟336主题切换响应 P95210 ms38 ms在你自己项目里跑同口径测试只需三步第一把上面探针用例里的操作循环换成你的高频动作翻页、切换图像、开关窗口第二GC.Collect() GC.WaitForPendingFinalizers()之后读一次WorkingSet64记录为基线第三重复操作 100 次再读一次看增量是否收敛到平台。判断标准增量应呈现平台化若每轮操作都把基线抬高一个台阶就是引用被持有与 GC 快慢无关。高危清单五个最容易踩空的释放点❌ 在OnClosing无条件调用UnWatch(this)→ ✅ 先判if (IsLoaded)未加载窗口调UnWatch会抛InvalidOperationException❌ 每次切图new BitmapImage(new Uri(path))→ ✅ 一次性按CacheOption.OnLoad构建并Freeze()全应用复用同一实例❌ 托盘图标靠终结器兜底清理 → ✅ 关闭路径显式Unregister()finalizer 的Dispose(false)不会移除 shell 注册❌ 指望框架Image控件自动缓存图像 → ✅Source依赖属性只持有引用缓存、淘汰、冻结都是业务层职责❌ 窗口字段持有整屏BitmapSource且关闭不清空 → ✅OnClosed中把Image.Source置null切断强引用深挖入口顺着静态管理器设计文档走设计决策docs/architecture/decisions/ADR-004-static-managers-for-theming.md解释了为什么SystemThemeWatcher、ApplicationThemeManager以静态状态设计读它你能预判哪些静态表需要手动对称注销。测试落点tests/Wpf.Ui.Gallery.IntegrationTests/FlaUI 集成测试的 fixture 组织方式在这里泄漏探针用例与它同目录维护。释放惯例Wpf.Ui.Win32.Utilities提供SafeDispose与SafeRelease两个静态工具前者处理托管IDisposable后者配合Marshal.ReleaseComObject写自己的释放代码时直接沿用这套先摘引用、后置空、再释放的顺序。【免费下载链接】wpfuiWPF UI provides the Fluent experience in your known and loved WPF framework. Intuitive design, themes, navigation and new immersive controls. All natively and effortlessly.项目地址: https://gitcode.com/GitHub_Trending/wp/wpfui创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表