这篇文章的标题,是当时我在一套 WPF 上位机项目里连续加班一周后,在笔记里随手记下的。标题里的 WebBroser 拼错了,正确写法是 WebBrowser,之所以一直没改,是因为那周我确实被它折腾到看着控件名都想不起完整拼写。项目要在右侧区域嵌入摄像头的 Web 管理后台,页面里的按钮需要直接调 C# 方法去控制串口和 PLC,我原本以为 WPF 自带的 WebBrowser 拖进去就能用,结果从第一天起就被按在地上摩擦:页面渲染成 IE7 的样式、JS 死活调不到 C#、弹出层全被 WebBrowser 盖住、内存一路涨到 2GB 不回落。这篇不是系统教程,是我把这些问题逐个排查后沉淀下来的一份避坑清单。如果你正打算在 WPF 里嵌入网页做交互,或者已经在项目里被 WebBrowser 折磨、想找排查方向,这篇文章应该能帮到你。
1. 渲染内核被默认降级到 IE7:页面开裂的根源
1.1 现象与判断:Flex 布局失效、userAgent 露馅
把 WPF 的 WebBrowser 拖进窗口,加载一个稍微现代点的 HTML 页面,第一眼可能还觉得"挺好,显示了"。但再多看两眼就会发现不对劲:CSS 里写的display: flex没任何效果,布局乱成一团;用border-radius做的圆角按钮,有些生效有些直接变直角;甚至document.querySelector这种稍微新一点的 DOM API,也会时不时报 undefined。这在我那个前端同事看来简直是不可理喻——同样的页面扔进 Chrome 和 Edge 都正常,到了 WPF 内嵌控件里就变得像上个时代的网站。
最直接的确认方法,是在页面里输出一行navigator.userAgent。我当时用InvokeScript("eval", new object[] { "navigator.userAgent" })看了一眼,里面赫然写着MSIE 7.0。看到这个字符串,一切疑问就都有了答案:WPF WebBrowser 默认不是按系统里 IE 的最高版本渲染,而是掉到了 IE7 兼容性视图那个水平。后来我还试过在页面里加一段 ES6 箭头函数验证,效果更惨,直接语法错误,整个脚本全部不执行。如果你遇到的也是这种"页面能加载但样式脚本全线崩溃"的情况,八成就是内核仿真模式没设置。
1.2 根因:WPF WebBrowser 的底层到底是什么
WPF 里那个<WebBrowser>控件,本质上是对 WinForms WebBrowser 的一层 WPF 包装,底层是系统自带的 ActiveX 控件。它的渲染内核叫 MSHTML,也就是我们熟悉的 IE 内核,而不是 Edge 内核。这里有一个特别容易误导人的点:很多开发者以为"系统装了 Edge,浏览器控件就自动用 Edge",其实不会,WPF WebBrowser 始终走的是 IE 的路子。
而 IE 内核在工作时,有一个"浏览器仿真模式"的概念。微软为了照顾大量老系统,在默认情况下不会让这个 ActiveX 控件按最新版本 IE 解析页面,而是按一个保守的兼容视图来运行。具体落到我们接触到的行为上,就是"看起来像 IE7"。这跟我们手动在 Edge 里点击"在 IE 模式下重新加载"完全是两回事。如果你什么都不设置,那 WebBrowser 的行为就是 IE7 时代的行为,flex 布局、CSS 变量、ES6 语法、Fetch API,这些都是后来才有的东西,它不认识也正常。
要改变这个状态,唯一的正规手段就是通过注册表里的FEATURE_BROWSER_EMULATION来告诉 IE 内核:这个进程请按哪个版本的浏览器标准来渲染。这是个全局按进程名生效的设置,不设置就一直当 IE7,设置错了版本也不会有提示,所以很多项目最后"莫名其妙"地死在页面上,其实根源都在这里。
1.3 解法:启动时写入注册表,附 DWORD 对照表
这个注册表项写在HKEY_CURRENT_USER或HKEY_LOCAL_MACHINE的Software\Microsoft\Internet Explorer\Main\FeatureControl\FEATURE_BROWSER_EMULATION下,值的名称是你的程序集名字,比如MyApp.exe,值是一个 DWORD。
我建议在程序入口(App Startup 或 MainWindow 构造函数)第一时间调用,确保在创建任何 WebBrowser 之前生效。代码很简单:
using Microsoft.Win32; using System; using System.IO; public static class BrowserFeatureHelper { public static void EnableIe11Emulation() { const string featurePath = @"Software\Microsoft\Internet Explorer\Main\FeatureControl\FEATURE_BROWSER_EMULATION"; // 注意:这里用的是 AppDomain.CurrentDomain.FriendlyName。 // 调试时是 AppName.vshost.exe,发布后是 AppName.exe,刚好都覆盖 string appName = AppDomain.CurrentDomain.FriendlyName; using (var key = Registry.CurrentUser.CreateSubKey(featurePath)) { key?.SetValue(appName, 11001, RegistryValueKind.DWord); } } }常用 DWORD 值对应关系如下:
| DWORD 值 | 浏览器模式 |
|---|---|
| 11001 | IE11 标准模式(我一般无脑选这个) |
| 10001 | IE10 标准模式 |
| 9999 | IE9 标准模式 |
| 8888 | IE8 标准模式 |
几点经验:
- 不建议上来就设 11001,除非你的页面确认兼容 IE11。有些老旧的后台管理页面反而在 IE11 下布局崩溃,到那时再改成 9999 或 8888 逐个试。
- 注册表写在 HKCU 下就行,不需要管理员权限。网上很多教程让写 HKLM,但在公司离线下发软件时,HKLM 经常没权限,HKCU 反而一路畅通。
- 如果调试时发现设置没生效,先看看你是不是把程序写成了
AppName.vshost.exe。FriendlyName 在调试时会自动带上 vshost,发布后不会,所以用上面的写法是最省心的。
如果你能在部署机上用 regedit 手工看,也可以在HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\Main\FeatureControl下面确认键值是否写入成功。确认了这点,内核降级问题就算解决了。
2. 网页和 C# 互调:从 ObjectForScripting 到安全区域拦截
2.1 正向调用:C# 暴露 Bridge 给页面用
第二个让我加班的点,是"网页里的按钮要调 C# 方法"。典型场景是:页面加载以后,用户点击"开启设备",HTML 里的onclick需要触发 C# 的一个方法去打开相机通道。WPF WebBrowser 提供的官方通道是ObjectForScripting属性,把 C# 对象暴露给页面,页面通过window.external调用。
具体步骤分四步:
- 定义一个类,用
[ComVisible(true)]标记,想暴露的方法必须是 public。 - 把该类实例赋给
Browser.ObjectForScripting。 - 在 HTML 里用
window.external.方法名(参数)调用。 - 项目要允许托管代码访问 COM 互操作,在 AssemblyInfo.cs 里确认
[assembly: ComVisible(false)]之外,该类本身要[ComVisible(true)]。
代码看起来是这样:
using System.Runtime.InteropServices; using System.Windows; [ComVisible(true)] public class JsBridge { public void OpenDevice(string deviceId) { // 这里做真正的业务处理,比如打开串口 MessageBox.Show("Open device: " + deviceId); } }XAML 或代码里挂上:
Browser.ObjectForScripting = new JsBridge(); Browser.NavigateToString(htmlContent);HTML 里这样写:
<button onclick="window.external.OpenDevice('cam01')">打开设备</button>这个套路本身不难,真正难的是它带来的三个隐性坑,下面单独说。
2.2 反向调用:InvokeScript 的正确姿势
反过来的需求更常见:C# 往页面里塞数据、调用页面 JS 渲染图表。官方通道是InvokeScript,用法是在页面加载完成之后执行一段脚本。
Browser.LoadCompleted += (s, e) => { // 方式一:直接 eval object result = Browser.InvokeScript("eval", new object[] { "1 + 1" }); // 方式二:调用页面里定义的函数 Browser.InvokeScript("updateData", new object[] { "2025-05-01", "123.4" }); };需要注意几个点:
- 必须在页面加载完成后再调,否则
Document还没初始化,InvokeScript会抛异常。我这里用的是 WPF WebBrowser 的LoadCompleted事件。 InvokeScript的第一个参数如果是eval,那第二个参数就是一段 JS 代码字符串;如果直接传函数名,那第二个参数开始就是函数入参。入参只能是基础类型(string、int、double、bool 这类,或它们的数组),不能直接传 C# 的复杂对象。- 返回值是
object,页面返回 JS 字符串或数字时会自动装箱。如果想要稳定的类型,建议在 JS 里先把返回值变成字符串再去转换。
C# 和 JS 互调这种活儿,最怕的就是没人告诉你"必须在 LoadCompleted 之后"。我第一次做的时候,页面刚 NavigateToString 完就立刻 InvokeScript,结果异常弹了几次,我还以为是页面没写好。实际上是时机不对。
2.3 三个真正的坑:COM 可见性、vshost 键名、跨域安全
第一个坑,[ComVisible(true)]漏了。漏掉之后页面调用window.external.xxx不会报错,而是静默失败,很多人在前端 console 里看不到任何信息。排查方法很简单:在页面里先执行一句typeof window.external,看是不是undefined。如果这个类没标记 ComVisible,就会返回 undefined。很多人绕了半天,结果就是少了这行特性。
第二个坑,和第一节的注册表是同一个坑的变种。开发调试时互调一切正常,发布后客户一装就废。为什么?因为调试时把ObjectForScripting暴露给的是AppName.vshost.exe这个宿主进程,发布后主程序换成AppName.exe,注册表里 FEATURE_BROWSER_EMULATION 的键名不匹配,导致内核模式回退。而且更微妙的是,ObjectForScripting在某些权限组合下也会受影响。所以我后来养成一个习惯:在程序启动时立刻用AppDomain.CurrentDomain.FriendlyName写注册表,调试和发布各自命中各自的键名,两边都正常。
第三个坑是跨域安全。如果你的页面是通过NavigateToString或DocumentText加载的本地内容,通常属于about:blank域,window.external调用问题不大。但如果页面是从http://192.168.1.64/xxx这种远程地址加载的,IE 的安全策略可能直接拦截脚本访问外部对象,甚至弹"此页上的 ActiveX 控件和网页脚本代码可能不安全"之类的警告。
针对这个,我踩坑后的建议是这样的:如果页面可控,最好把交互方式反过来,改成 C# 用InvokeScript主动去页面上拉数据或推送数据,不要依赖页面主动调window.external;如果页面不可控(比如第三方设备后台),那与其跟IInternetSecurityManager死磕,不如趁早评估换 WebView2。后面我会专门讲这块。
3. 盖不住的 WebBrowser:Airspace 问题与三个应对思路
3.1 现象:Popup 被盖、透明窗体变黑
第三类问题是我个人认为最恶心的,因为它直接跟界面交互强相关。用 WPF 做界面,弹窗、下拉框、右键菜单、鼠标悬浮提示是再正常不过的交互。但一旦页面上有 WebBrowser,你会发现这些玩意儿全都失效了。
我当时遇到的具体场景:页面里有一个设备列表,用户选中设备后,旁边要弹一个设置面板。设置面板是用 WPF 的Popup写的,结果一打开,面板就被 WebBrowser 盖住,只能看到网页内容,看不到面板。后来试过Canvas.SetZIndex设到最大、试过Popup.StaysOpen,全都没用。还有一次测试窗体阴影效果,把窗口的AllowsTransparency开了,结果 WebBrowser 所在区域直接变成一块黑底,透明效果在它上面完全失效。
如果你是第一次接触这个问题,可能还会怀疑是不是自己 WPF 布局写错了。不用怀疑,这就是 WPF WebBrowser 的空域限制,业内管它叫 Airspace 问题。
3.2 根因:HwndHost 与空域机制
WPF 的最大特点之一是自己接管了渲染管道,整个界面是一棵可视化树,可以统一做动画、透明、裁剪、层级控制。但 WebBrowser 不是这样。WPF 中的 WebBrowser 继承自HwndHost,内部是一个真实的 Win32 窗口句柄。也就是说,在你的 WPF 窗口里,同时存在两套完全不同的渲染体系:一套是 WPF 自己的 retained mode,另一套是 Windows 传统的 HWND 直接绘制。
这两套体系不是透明的,它们在窗口上划分出一个"空域",各管各的区域。一旦 WebBrowser 占据某个矩形区域,这个区域内的 WPF 绘制就插不进去了——控件层级、ZIndex、Popup、透明都在这个区域失效,连普通的 Button 都盖不住它。这就是为什么 WPF 里 "盖在 WebBrowser 上面" 这个需求,从原理上就走不通。
3.3 应对一:把浮层挪进网页里
最简单有效的方案,是改变交互设计:把必须浮在页面上的东西,做成网页内部的 DOM 元素。比如设置面板、下拉菜单、悬浮提示,让前端同事在 HTML/CSS 层面实现。反正 WPF WebBrowser 渲染的就是页面内容,页面内部的绝对定位浮层没有空域限制,怎么放都行。
这个方案的缺点是,页面必须可控。如果你嵌入的是第三方设备的后台,你没法改它的 HTML,那只能用下一个方案。而且,当浮层变成网页内部元素之后,它和 WPF 业务代码的交互就要走第二节里说的互调通道,开发沟通成本会高一些。
3.4 应对二:用独立 Window 替代 Popup
如果页面不可控,或者那个浮层一定要是 WPF 原生控件(比如里面还要放 WPF 的 DataGrid),那就不要再纠结 Popup 了。Popup 在 WPF 里是一个独立但层级特殊的 HWND,遇到 WebBrowser 这种"空域强势"的控件时优先级不够。换成独立的Window,就是一个顶级窗口,Z 序上天然高于 WebBrowser 的子 HWND,可以正常显示。
我当时是把浮层改成了一个无边框小窗口,设置Owner指向主窗口,用代码控制位置和显隐,效果还行:
private Window _floatWindow; private void ShowFloatPanel() { _floatWindow?.Close(); _floatWindow = new Window { Width = 360, Height = 200, WindowStyle = WindowStyle.None, AllowsTransparency = false, Background = Brushes.White, Owner = this, ShowActivated = false }; _floatWindow.Left = Left + 400; _floatWindow.Top = Top + 100; _floatWindow.Show(); }这里有几个细节:
AllowsTransparency要设成 false,因为透明窗口走的是分层窗口机制,和 WebBrowser 叠加时会出黑底。ShowActivated = false是为了不让这个浮层抢主窗体的焦点,点击浮层内部时 WebBrowser 不会失焦闪烁。- 如果希望浮层点击外部时自动关闭,需要自己做全局鼠标事件监测,没有 Popup 那么好用。
这个方法配合 ZOrder 调整,能解决 90% 的"被盖住"问题。但要注意,它本质上是在"躲",不是"根治"。如果你的界面里到处需要覆盖 WebBrowser 的浮层,那迟早得换 WebView2。
4. 内存只增不减:长时间运行后的泄漏调查
4.1 现象与初步检查
WebBrowser 的第四个大坑,是内存泄漏。这在上位机、监控类项目里尤其致命,因为这些程序往往要求 7x24 小时运行。我的项目里,页面每隔几秒会刷新一次监控数据,跑了一个晚上之后,主进程内存从刚启动时的 200MB 涨到了 1.2GB,有时候直接到 2GB,然后整个程序开始卡顿,最后 windows 弹"虚拟内存不足"。
刚开始我以为是页面 JS 写得差,在浏览器里开了开发者工具观察,发现那个监控页面自己跑的时候内存还不到 100MB。说明问题出在 WebBrowser 控件和页面交互的边界上,而不是单纯的页面问题。
4.2 根因:进程内 COM、RCW 与页面引用链
WPF WebBrowser 和 WebView2 有本质区别:WebView2 的浏览器进程是独立的,崩溃和内存占用都在自己的进程里;而 WPF WebBrowser 的 mshtml 渲染引擎和 jscript 脚本引擎,全部跑在你的 WPF 进程内。这意味着页面里的一切 JS 定时器、DOM 对象、闭包,都在你的进程里占着内存。
另一个容易忽视的点是 RCW。每次你用Browser.Document.GetElementsByTagName之类的方式访问页面 DOM,托管代码和 COM 对象之间都会产生一个运行时调用包装器(Runtime Callable Wrapper)。如果这些 RCW 没有被及时释放,或者因为你持有Document变量太久,GC 就始终认为对象还被引用着,不能回收。我有段时间为了取页面上的某个值,把Document存成了一个窗体字段,然后用完也没置空。后来一查,就是这一行代码导致每次刷新页面都多留一份 DOM 内存。
还有一个隐藏坑是ObjectForScripting。页面通过window.external持有 C# 的 Bridge 对象,Bridge 如果再持有 WebBrowser 引用,就会形成一条"WebBrowser -> Bridge -> WebBrowser"的循环引用链。有些内存就是这么被锁死的,释放不掉。
4.3 缓解手段:清文档、置空引用、周期性回收
内存问题没有一剂万能药,但下面这几个手段组合起来,能让长跑项目稳定不少。
第一,导航新页面之前,先把当前页面清掉。我会在跳转或刷新前执行Browser.Navigate("about:blank"),让旧的 DOM 先被释放,再加载新文档。
第二,不再持有Document、Element之类的 COM 对象引用。用完就置空,尤其是不要在窗体字段里长期保存。
第三,页面切换频繁的场合,可以周期性地给 COM 一点回收机会:
private static void ForceCollectCom() { Task.Run(() => { Thread.Sleep(300); GC.Collect(); GC.WaitForPendingFinalizers(); }); }这段代码会马上触发一次全量垃圾回收。工业上位机里页面切换不那么频繁,每隔几分钟触发一次完全够用,不用每帧都调用。上线前在客户机器上跑一夜,观察内存曲线,稳定在一个合理范围(比如 500MB 以内),再交付。
这里我得提一个反面案例:有人为了"让任务管理器里的内存好看",去调SetProcessWorkingSetSize清工作集。那个只是把内存压到页面文件里,属于自欺欺人,根本问题还在,程序反而可能更卡。千万别学。
5. 其他容易忽略的小坑:路径、弹窗、DPI、触摸
5.1 中文路径与 file:// 加载失败
用 WebBrowser 打开本地 HTML 是非常常见的做法,但如果你直接把一个带中文和空格的路径传给Navigate,大概率会失败。我最初写的是:
Browser.Navigate(@"D:\我的项目\报表模板.html");结果页面空白。原因是Navigate的底层把字符串当 URL 处理,而D:\我的项目\报表模板.html不是合法的 URI,中文和空格会被错误解析。正确做法是构造Uri对象再传:
Browser.Navigate(new Uri(@"D:\我的项目\报表模板.html"));new Uri会帮你做编码处理。另外,本地 HTML 里引用的相对资源(css、js、图片)如果也是中文路径,同样可能加载不出来。我的习惯是能控制资源时就全用英文文件名,不能控制时就先把整个目录拷到一个纯英文临时目录,再从临时目录加载。
5.2 脚本错误弹窗的噩梦
因为 WebBrowser 内核还是 IE,页面里任何一处 JS 运行时错误,都可能触发一个类似"Internet Explorer 已经限制此网页运行脚本或 ActiveX 控件"的对话框,有些机器还会弹"是否继续运行此页面上的脚本?请选择是否继续"。在大屏自助终端、无人值守看板这类项目里,这种弹窗会直接毁掉整个体验。
处理办法按优先级排:
- 页面是自己的:在页面里加一行
window.onerror = function () { return true; };,能拦截大部分 JS 错误弹窗。 - 不要在自己页面里留
debugger语句。IE 内核遇到debugger会尝试打开开发工具,那场面更酸爽。 - 部署前检查注册表
HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\Main下的脚本调试相关项,能关的都关掉。
但说实话,这些手段只是"遮羞",一旦页面代码质量很差,错误密集到一定程度,弹窗还是会冒出来。要从根上解决,依然得换内核。
5.3 DPI 缩放下的模糊与错位
WPF 在高 DPI 下默认是矢量渲染,文字和控件放大后不会模糊。但 WebBrowser 不是 WPF 原生元素,它的内容是把 IE 渲染结果作为位图贴进 WPF 的。当系统缩放比例是 125% 或 150% 时,这个位图被整体拉伸,里面的文字和线条就开始发虚。比例越高越明显。
如果项目必须用 WebBrowser,界面设计上要尽量减少小字号文字、细线条和复杂图标的显示,把 WebBrowser 区域用来展示大图表、视频、大按钮这类对清晰度不敏感的内容。另外,app.manifest里的 DPI 感知声明也要处理好,如果进程不声明 Per Monitor 感知,在拖动窗口跨屏幕时会整个画面糊掉,也不止 WebBrowser 一个控件的问题。
5.4 触摸屏上的滚动支持
在工控上位机里,触摸屏是很常见的外设。WPF WebBrowser 的页面内容本质是 IE 窗体,而 IE 的滚动依赖鼠标滚轮消息,触摸屏发过来的触摸事件它不会自动转换。所以你在触摸屏上滑动 WebBrowser 里的页面,往往变成选中文本、点击拖拽,页面纹丝不动。
应急方案是在 WPF 层手动把鼠标滚轮消息转成对页面scrollTop的修改:
Browser.PreviewMouseWheel += (s, e) => { dynamic doc = Browser.Document; dynamic body = doc.body; body.scrollTop = body.scrollTop - e.Delta; e.Handled = true; };这种方式只对传统的 body 滚动有效,遇到页面内部某个 div 滚动还是不理想。真要面向触摸屏长期使用,我最终还是建议走 WebView2 或 CefSharp,它们的输入处理要完整得多。
6. 如果要继续往前:迁移 WebView2 前的对照与准备
6.1 为什么我从 WPF + WebBrowser 换成了 WebView2
这个项目做到后期,我的最终结论是:WPF WebBrowser 只适合"临时应急、页面可控、不能安装第三方依赖"的极短周期项目。它所有的问题——内核老旧、空域限制、内存泄漏、安全区域拦截、触摸输入缺失——本质上都不是配置问题,而是 IE 内核时代的设计局限。Win11 里微软已经移除 IE 应用,WebBrowser 控件的底层虽然还在,但没有任何新功能跟进。我那个项目的页面随后迭代到了 Vue2 版本,WebBrowser 打开直接白屏,那一版我才彻底死了心,把目标转向了 WebView2。
这里多说一句:网上经常有人在一个问题上纠结很久,其实从投入产出比看,如果你能接受 WebView2 Runtime 的部署成本,迁移几乎是必然的。
6.2 迁移改动对照表
WebView2 和 WebBrowser 的差别比较大,但核心互调逻辑其实可以平移。关键对照如下:
| 对比项 | WPF WebBrowser | WebView2 |
|---|---|---|
| 内核 | IE(MSHTML),已停止新功能 | Chromium,Evergreen 模式自动更新 |
| HTML5 / ES6 / Vue / React | 基本不支持 | 完整支持 |
| C# 调 JS | InvokeScript("eval", ...) | ExecuteScriptAsync / PostWebMessageAsJson |
| JS 调 C# | window.external.xxx() | window.chrome.webview.postMessage(json) |
| 空域问题 | 明显,Popup 被盖、透明失效 | 有明显改善,仍有少量限制 |
| 内存隔离 | 进程内 COM,泄漏排查困难 | 独立浏览器进程,崩了不影响主程序 |
| 部署依赖 | 系统自带,零依赖 | 需要 WebView2 Runtime,离线环境要预装 |
迁移时最典型的一个改动是消息通信。WebBrowser 时代我们写window.external.SomeMethod(),WebView2 时代统一走消息通道。C# 侧这样初始化:
webView.CoreWebView2InitializationCompleted += (s, e) => { webView.CoreWebView2.WebMessageReceived += OnBridgedMessage; webView.CoreWebView2.PostWebMessageAsJson("{ \"type\": \"init\", \"data\": \"hello\" }"); };页面侧只要监听chrome.webview的消息事件即可。所有的交互都变成 JSON 字符串,类型边界干净很多,不会再有 COM 可见性那个坑。
6.3 老项目临时保命手段
如果你的项目暂时没法换 WebView2,必须在现有 WebBrowser 上继续苟一段时间,我的建议按优先级如下:
- 内核仿真注册表一定要设置,至少保证页面按 IE11 模式渲染。
- 页面代码按 IE11 的"方言"来写,抛弃 ES6+、flex、grid 这类新特性,前端同学就当在写 2013 年的浏览器兼容页面。
- 浮层全部改成独立 Window 或网页内 DOM,禁掉 Popup 直接覆盖页面。
- 长时间运行的机器,内存监控要在线,定时重启或定时强制 GC 至少能避免凌晨 3 点死机。
这些手段只能续命,不能根治。尤其不要再接新功能了,IE 内核没有未来了。
最后说一句我自己的体会:如果当初我在技术选型时多做一步调研,就不会因为"WebBrowser 是系统控件所以免费、省事"这个理由选它。省下的安装包体积,最后都会以几百行兼容代码和一周加班的方式还回去。这句既是这篇记录的结案陈词,也是我给所有还在 eval 和 Popup 之间挣扎的 WPF 同行的一句忠告。