
上周有位老哥给我发来一张截图4K 显示器、150% 缩放一个用 VS2022 写的 Winform 程序整个界面像被人拿毛玻璃糊过一遍文字发虚、图标发软、按钮边线全是半透明的毛边但表格行高又挤成一坨滚动条窄得像头发丝。他问我是不是要重写。我说不用把 DPI 这条链路捋清楚再按目标框架分开处理就行。这篇讲的就是这件事在 VS2022 里用一套代码同时输出 net461 和 net6.0 两个目标把 Winform 的高 DPI 兼容一次做对。为什么要双出因为很多项目还在给 Windows 7、Windows 10 老机器出货客户机上只有 .NET Framework同时又想让新版本跑在新屏幕上不丢人。两个框架在高 DPI 上的能力边界完全不一样硬套一套方案必然出问题。这篇适合手上有存量 Winform 项目、被高 DPI 折磨过、又不想维护两套代码的人看。我按先搞清成因、再搭工程、再分头配、最后排布局和验证的顺序讲每一步都给出可直接抄的配置和代码。1. 先把糊的成因拆开DPI 感知、清单、AutoScale 三条链路动手改代码之前得先知道那块模糊到底是谁造成的。绝大多数人一上来就调AutoScaleMode调了半天发现文字还是虚的原因就是他改的那条链路跟造成问题的链路根本不是一个。1.1 进程的 DPI 感知级别决定了谁来缩放Windows 对每个进程都有一个 DPI 感知级别的标记它决定了系统要不要替你把画面放大。可以理解成拍照你有一张 800×600 的照片要塞进 4K 相框。DpiUnaware不感知系统直接把照片硬拉大塞进相框结果是整体放大、全部模糊。这是老程序的默认状态也是糊成一片的元凶。SystemAware系统感知程序自己按相框尺寸重拍一张清晰。但它只认第一个相框——程序启动时所在那块屏幕。你把它拖到另一块缩放比不同的屏幕上系统还是会给你拉伸。PerMonitorV2每显示器感知 V2每换一个相框都重新拍一张清晰且尺寸正确。代价是程序自己得负责所有重排逻辑。DpiUnawareGdiScaledGDI 负责缩放字体位图仍然拉伸属于半吊子方案一般不用。判断一个程序当前处于哪一级最土也最有效的办法是看截图里文字边缘不感知的话文字会连字形结构都变形感知了但没处理跨屏的话只在跨屏时糊。1.2 清单文件和运行时 API 谁说话算数声明 DPI 感知有两条路一是在**应用程序清单app.manifest**里写dpiAware/dpiAwareness二是运行时代码调Application.SetHighDpiMode()底层是SetProcessDpiAwarenessContext。关键在于清单的优先级高于运行时 API。进程启动时清单先被解析并设置感知级别之后你再调运行时 API 去改系统会直接拒绝SetHighDpiMode返回false。很多人两边都写了结果调了半天没反应就是因为清单已经生效了。手段生效时机谁适合用app.manifest进程启动前net461 这类没有运行时 API 的目标Application.SetHighDpiMode首个窗口创建前net6.0app.config 的 DpiAwareness框架初始化时.NET Framework 4.7结论很直白一个目标框架只选一条路别混着用。1.3 net461 和 net6.0 在能力上的真实差距这是双出这件事最关键的一张表。差距不在语言特性上而在 WinForms 框架给你的 DPI 工具上能力net461net6.0Control.DeviceDpi无有Control.DpiChanged事件无有LogicalToDeviceUnits无有Application.SetHighDpiMode无有ApplicationConfiguration.Initialize无有app.config 的 DpiAwareness 配置段支持不完整不适用DeviceDpi和DpiChanged这两个东西官方是在 .NET Framework 4.7 才补进来的。也就是说在 net461 这条线上你既拿不到当前 DPI也收不到DPI 变了的通知想自己实现跨屏重排就得手写WndProc处理WM_DPICHANGED消息工作量相当可观。所以我的实际做法是net461 这一支老老实实走 SystemAware保证不糊net6.0 这一支走 PerMonitorV2保证跨屏也对。这不是妥协是基于能力边界的合理取舍。如果客户真的要求老框架也要跨屏完美那就得评估升到 4.7.2 的成本而不是硬啃 461。2. 一个 csproj 同时出 net461 和 net6.0多目标工程怎么搭工程结构没搭对后面所有的坑都会翻倍。多目标的 SDK 风格项目有几个地方和单目标完全不一样尤其是设计器的行为。2.1 TFM 的写法、顺序以及设计器挑谁csproj的核心几行Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworksnet461;net6.0-windows/TargetFrameworks UseWindowsFormstrue/UseWindowsForms LangVersionlatest/LangVersion Nullabledisable/Nullable GenerateAssemblyInfotrue/GenerateAssemblyInfo /PropertyGroup ItemGroup Condition$(TargetFramework) net461 Reference IncludeSystem.Windows.Forms / Reference IncludeSystem.Drawing / /ItemGroup /Project几个细节值得说清楚net6.0-windows等价于net6.0-windows7.0加了-windows之后才能用 WinForms 相关的 API。UseWindowsForms这个属性主要是给 .NET Core 那条线用的net461 分支我习惯显式加引用避免不同版本的 SDK 行为差异——实测下来显式引用比依赖UseWindowsForms在 Framework 分支上更可控。TargetFrameworks里TFM 的顺序会影响 VS2022 设计器挑哪个框架来渲染设计视图。默认它取第一个它能支持的。所以如果你想在设计器里看到 net461 的渲染结果把net461放前面想按 .NET 6 设计就把net6.0-windows放前面。想要在设计器里自由切换有个小技巧加一个专用的构建配置在配置里把TargetFramework单独指定成单目标。PropertyGroup Condition$(Configuration) Design461 TargetFrameworknet461/TargetFramework /PropertyGroup PropertyGroup Condition$(Configuration) Design60 TargetFrameworknet6.0-windows/TargetFramework /PropertyGroup然后在 VS 的配置管理器里切这两个配置。这样设计器和调试目标能精确对齐比来回改TargetFrameworks的顺序省心得多。2.2 条件编译符号和真正的分叉点SDK 风格项目在编译net461时会自动定义NETFRAMEWORK和NET461编译net6.0-windows时会定义NET6_0和NET6_0_WINDOWS仅当 TFM 带-windows时。这些符号足够区分大部分场景但我觉得再定义一个语义化的符号更顺眼PropertyGroup Condition$(TargetFramework) net6.0-windows DefineConstants$(DefineConstants);NET6WIN/DefineConstants /PropertyGroup代码里出现的分叉点其实就四个程序入口。net6.0 用ApplicationConfiguration.Initialize()net461 用老三样。DPI 感知设置。net6.0 调 APInet461 靠配置和清单。DPI 查询工具类。net6.0 用DeviceDpinet461 用Graphics.DpiX或 P/Invoke。默认字体。net6.0 能全局改net461 得在每个窗体上设。把这四处收拢到一两个文件里其余 95% 的业务代码和界面代码完全共用这才是双出的意义。2.3 输出目录、配置文件与发布方式编译后两个目标会各自落在bin\Debug\net461\和bin\Debug\net6.0-windows\下天然隔离互不干扰。配置文件方面net461 分支会自动把App.config拷贝成你的程序.exe.configappSettings、DpiAwareness都读这个。net6.0 分支生成的是你的程序.runtimeconfig.json没有.exe.configConfigurationManager得额外引System.Configuration.ConfigurationManager包才能用。发布命令也不一样# net461 msbuild MyApp.csproj /p:ConfigurationRelease /p:TargetFrameworknet461 # net6.0框架依赖模式 dotnet publish MyApp.csproj -f net6.0-windows -c Release -r win-x64 --self-contained false注意WinForms 不支持程序集裁剪Trim。如果你在 net6.0 分支上加了PublishTrimmedtrue/PublishTrimmed界面会因为反射找不到类型而在运行时崩。这个坑我在一个瘦身打包的项目里踩过排查了大半天。单文件发布PublishSingleFile对 WinForms 是能用的但要留意Application.StartupPath的行为差异以及配置文件的读取路径——单文件模式下主体被解压到临时目录凡是靠Assembly.Location找资源的地方都要改成AppContext.BaseDirectory。3. 让程序被识别成高 DPI清单与运行时开关的正确写法工程搭好了接下来是真正的开关。这一节两个分支分开讲因为它们完全是两套东西。3.1 net6.0 分支HighDpiMode 写在项目文件里.NET 6 的 WinForms 引入了一套源生成器ApplicationConfiguration.Initialize()这个方法不是你写的是编译时按项目属性生成的。你只需要在csproj里声明PropertyGroup Condition$(TargetFramework) net6.0-windows ApplicationHighDpiModePerMonitorV2/ApplicationHighDpiMode ApplicationDefaultFontMicrosoft YaHei UI, 9pt/ApplicationDefaultFont ApplicationVisualStylestrue/ApplicationVisualStyles ApplicationUseCompatibleTextRenderingfalse/ApplicationUseCompatibleTextRendering /PropertyGroup生成的Program.cs就是static void Main() { ApplicationConfiguration.Initialize(); Application.Run(new MainForm()); }它等价于手写这三行Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false);三点必须记住第一这些调用必须在第一个窗口创建之前完成。如果你在MainForm构造函数里才调会失败或者部分生效表现就是主窗体糊、子窗体清楚这种诡异现象。第二检查返回值。SetHighDpiMode返回bool如果项目里手工加过一个带dpiAware的app.manifest这里会返回false你的设置压根没生效。我在一个从 Framework 迁移过来的项目里就遇到过老清单文件被一起复制过来了两边打架结果PerMonitorV2静默失效。第三ApplicationDefaultFont只影响那些没有显式设置 Font 的控件。设计器给每个控件都生成一行this.button1.Font ...的话全局字体就不起作用了。所以从 Framework 迁移过来的窗体往往需要先把设计器里那些冗余的 Font 赋值清掉全局字体才吃得进去。3.2 net461 分支app.config 加清单双保险Framework 这条线上没有运行时 API配置走App.config?xml version1.0 encodingutf-8? configuration startup supportedRuntime versionv4.0 sku.NETFramework,Versionv4.6.1 / /startup System.Windows.Forms.ApplicationConfigurationSection add keyDpiAwareness valueSystem / add keyEnableWindowsFormsHighDpiAutoResizing valuetrue / /System.Windows.Forms.ApplicationConfigurationSection /configurationDpiAwareness可取None、System、PerMonitorV2。这里我写System而不是PerMonitorV2原因在 1.3 说过461 上收不到DpiChanged自动重排不完整硬上 PerMonitorV2 反而会出现窗口变大了但控件没跟着动的错位。EnableWindowsFormsHighDpiAutoResizing是让 WinForms 在启动时按当前 DPI 做一次自动缩放这个开关比什么都重要422 之前的配置段没有它就是糊。但这里有个现实问题DpiAwareness这个配置段在 4.6.1 上能不能被正确解析取决于安装的补丁版本官方把完整支持定在 4.7。为了稳我在 461 分支上一定再配一份清单?xml version1.0 encodingutf-8? assembly manifestVersion1.0 xmlnsurn:schemas-microsoft-com:asm.v1 assemblyIdentity version1.0.0.0 nameMyApp.app / application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAware xmlnshttp://schemas.microsoft.com/SMI/2005/WindowsSettingstrue/dpiAware /windowsSettings /application compatibility xmlnsurn:schemas-microsoft-com:compatibility.v1 application supportedOS Id{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a} / /application /compatibility /assemblydpiAware写true就是 SystemAware。清单文件通过csproj挂上PropertyGroup Condition$(TargetFramework) net461 ApplicationManifestapp.net461.manifest/ApplicationManifest /PropertyGroup3.3 双目标下怎么只维护一份配置清单文件是纯 XML两个分支其实可以共用但千万不要共用因为 net6.0 那边一旦有清单SetHighDpiMode就会失效。我的做法是把清单命名成app.net461.manifest只在 net461 的条件组里引用net6.0 分支完全不挂清单感知级别全部交给运行时 API。App.config同理只在 net461 输出.exe.confignet6.0 那边它会被忽略不用特意排除。判断运行时到底生效在哪个级别有个简单验证法把窗口从 100% 的屏幕拖到 150% 的屏幕。如果窗口尺寸和字体跟着变了说明是 PerMonitorV2如果窗口尺寸不变但被系统拉伸变糊说明是 SystemAware如果从启动那一刻就糊说明还是 Unaware配置没生效。4. 界面层怎么排把像素写死换成容器驱动DPI 开关配对了程序不糊了接下来就是错位问题——控件重叠、按钮超出边界、文字被截断。这些全是布局层的事。4.1 AutoScaleMode 与 AutoScaleDimensions 的真实含义WinForms 的自动缩放发生在窗体加载阶段机制是这样设计器在你布局的时候会把当时的逻辑尺寸基准记在AutoScaleDimensions里程序运行时框架拿当前环境的实际字体高度或 DPI 跟这个基准比一下算出一个比例然后把窗体上所有控件的尺寸、位置按比例放大。// 设计器生成的别动它 this.AutoScaleDimensions new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode System.Windows.Forms.AutoScaleMode.Font;Font模式下比例 运行时的字体高度 /AutoScaleDimensions.Height。Dpi模式下比例 当前 DPI / 96。两种模式的选择上我建议保留设计器的默认值也就是 Font 模式不要手动改成 Dpi。原因是 Font 模式跟用户把文字调大的辅助功能设置是联动的而且 WinForms 官方在设计器里就是按这个模式生成的。中文版 VS 的默认基准可能是6F, 13FMicrosoft Sans Serif 8.25pt也可能是别的值以你现有Designer.cs里的数值为准一个字符都不要改。改了这个值会发生什么程序在 100% 缩放下就已经错位了因为基准比例不等于 1 了。我见过有人在 150% 的显示器上打开设计器发现控件位置不对就手改了AutoScaleDimensions改完 150% 看着正常了发到 100% 的客户机上全乱。这是个典型的改一个点坏一大片。4.2 容器布局的取舍Dock、Anchor 还是 TableLayoutPanel能用Dock和Anchor解决的布局就不要上TableLayoutPanel。原因是每一层缩放都会带来一次舍入误差嵌套三层以上的表格布局在 175%、225% 这种非整数倍下误差会累积表现为右边框差 1 像素、行高对不齐。我的优先级顺序是单层Dock顶部工具栏、底部状态栏、中间填充区这是最稳的。Anchor四边组合对话框里的确定/取消按钮锚定右下输入框左右拉伸。TableLayoutPanel只有当页面是严格的标签 输入框网格时才用且列宽行高一律用百分比或AutoSize不要填绝对像素。FlowLayoutPanel适合工具栏按钮、标签流。注意它的WrapContents在高 DPI 下会提前换行因为按钮变宽了这是正常的但要把容器高度留足。绝对像素的坑具体表现在哪TableLayoutPanel的RowStyles里如果写new RowStyle(SizeType.Absolute, 28F)这一行在任何缩放下都是 28 物理像素字体放大后就截断了。要么改成SizeType.AutoSize让内容决定要么在运行时按比例重算。MinimumSize和MaximumSize也是重灾区。构造函数里this.MinimumSize new Size(800, 600)在 200% 的屏上这个限制看起来只有一半大窗口能被拖到畸形。正确做法是乘以缩放系数this.MinimumSize new Size( (int)(800 * DpiHelper.GetScale(this)), (int)(600 * DpiHelper.GetScale(this)));4.3 字体、行高、间距这些隐形像素除了控件本身的尺寸还有一批不显眼的数值不会自动缩放位置典型问题处理方式DataGridView.RowTemplate.Height行高写死 22150% 下文字被切按 Font 高度重算DataGridView.ColumnHeadersHeight表头文字被截同上或设AutoSizeColumnsModeToolStrip.ImageScalingSize图标模糊或过大乘以当前缩放比ListView列宽拖动后不跟随缩放DpiChanged里按比例重设ComboBox.DropDownWidth下拉列表窄文字截断按最长项宽度动态算Panel.AutoScrollMinSize滚动范围不对逻辑尺寸乘缩放比TextBox的Size高度被字体撑开或压扁用AutoSize true或按 Font 重算TextBox这个特别说明一下单行TextBox的默认高度受字体影响如果你在设计器里手动固定了Size在高 DPI 下会出现文字上下被切。最简单的处理是单行TextBox一律AutoSize true多行的再手动算高度。至于怎么拿到缩放系数两个框架的写法不一样。net6.0 直接读DeviceDpinet461 得绕一下。我抽了个小工具类internal static class DpiHelper { private static readonly DictionaryIntPtr, float _cache new DictionaryIntPtr, float(); public static float GetScale(Control c) { #if NET6WIN return c.DeviceDpi / 96f; #else if (c.IsHandleCreated _cache.TryGetValue(c.Handle, out var v)) return v; // .NET Framework 4.6.1 没有 DeviceDpi只能借 Graphics 拿 using (var g c.CreateGraphics()) { var scale g.DpiX / 96f; if (c.IsHandleCreated) _cache[c.Handle] scale; return scale; } #endif } public static int Scale(Control c, int logical) (int)Math.Round(logical * GetScale(c)); }提示CreateGraphics()每次都创建一个 GDI 对象在OnPaint里频繁调用会拖慢界面。上面这个缓存就是为了避开这个开销相应地DPI 变化时必须清缓存否则拖到另一个屏幕上返回的还是旧值。5. 图片、图标与自绘控件不会自己缩放的那部分控件和文字能靠框架自动缩放但图片和自绘内容框架管不了这块是看起来糊的另一个主要来源。5.1 图标资源的准备与加载最省事的做法是准备一套多尺寸 ICO 文件里面塞 16、20、24、32、48、64、256 七个尺寸。Windows 会根据当前 DPI 自动选最合适的那一层来渲染不需要你写一行代码。用 VS 自带的图标编辑器或者 Axialis 这类工具都能做。如果一定要用 PNG就得自己按缩放比选图。我的目录结构是这样的Resources/ Icons/ save_16.png save_20.png save_24.png save_32.png save_48.png然后按当前缩放比例选最接近的那一档取到之后不要再用DrawImage拉伸——从 24 拉到 25 像素必然糊宁可向下取一档稍微小一点点视觉上也比模糊强。ImageList是另一个坑。它内部的图片会被强制缩放到ImageSize而且不同框架版本的插值算法不一样。不要把 32×32 的图塞进ImageSize 16×16的ImageList那出来的结果跟糊了一层油一样。要么在ImageSize设置前先把源图按目标尺寸处理好要么给ImageList直接喂对应尺寸的图。还有一点ImageList.ImageSize一旦有控件在用就不能改了改之前要把所有关联控件的ImageList置空。这个限制在设计时布局里很难注意到运行时改的话会抛异常。5.2 DrawImage 缩放的质量与性能有些场景必须做运行时缩放比如自绘的缩略图、从流里读出来的不规则图片。这时候缩放参数很重要public static Bitmap ScaleImage(Image src, int w, int h) { var bmp new Bitmap(w, h, PixelFormat.Format32bppPArgb); bmp.SetResolution(96, 96); using (var g Graphics.FromImage(bmp)) { g.CompositingQuality CompositingQuality.HighQuality; g.InterpolationMode InterpolationMode.HighQualityBicubic; g.PixelOffsetMode PixelOffsetMode.HighQuality; g.SmoothingMode SmoothingMode.AntiAlias; g.InterpolationMode InterpolationMode.HighQualityBicubic; g.DrawImage(src, new Rectangle(0, 0, w, h)); } return bmp; }三个参数值得解释PixelFormat.Format32bppPArgb是预乘 Alpha的 32 位格式比Format32bppArgb在渲染时快很多尤其是每帧重绘的自绘控件。这是我在一个实时刷新图表的项目里实测出来的差距肉眼可见。SetResolution(96, 96)是为了让生成的位图有一个确定的 DPI 元数据。如果源图的 DPI 元数据是 150不做这步的话某些控件的自动缩放逻辑会二次放大它结果就是 1.5 倍变 2.25 倍。InterpolationMode.HighQualityBicubic在放大时质量好但在缩小时会过度平滑锐利的小图标会变得发灰。缩小时改用InterpolationMode.HighQualityBilinear或者干脆用NearestNeighbor做整数倍缩小效果反而更好。性能方面缩放结果是必须缓存的。我见过有人在OnPaint里每次都ScaleImage界面一动就 100% CPU。缓存键里要带上尺寸DpiChanged时把整个缓存清掉。5.3 自绘控件里别再写死 96自绘控件继承Control或Panel重写OnPaint是高 DPI 的重灾区因为里面所有的坐标和字号都是硬编码的。改写要点第一用g.DpiX算比例不要用 96 当常量protected override void OnPaint(PaintEventArgs e) { var g e.Graphics; float scale g.DpiX / 96f; using (var pen new Pen(Color.FromArgb(200, 200, 200), 1f * scale)) g.DrawRectangle(pen, 0, 0, Width - 1, Height - 1); }第二字体从父控件继承不要自己 new。new Font(宋体, 12f)这种写法在高 DPI 下会明显偏小因为它是绝对点数不参与缩放。要么用Parent.Font要么new Font(Font.FontFamily, 12f * scale)。第三文本绘制优先用TextRenderer.DrawText。它底层走 GDI渲染出来的 UI 文本更锐利而且缩放行为跟系统一致。Graphics.DrawString走 GDI在 ClearType 下偶尔会出现颜色边缘异常。当然TextRenderer不支持透明背景半透明浮层上用DrawString更合适。第四MeasureString的结果要留 padding。Graphics.MeasureString默认会加上StringFormat的额外间距如果用它算出来的宽度直接做裁剪文字容易被切掉半个字。用StringFormat.GenericTypographic并把FormatFlags关掉会精确一些但更保险的是算完之后多加 46 逻辑像素的余量。第五绝对不要在OnPaint里调CreateGraphics()这在 DPI 查询的封装里是陷阱——如果DpiHelper.GetScale(this)内部用了CreateGraphics且没有缓存每一帧都在创建 GDI 对象用不了多久就会 GDI 对象超限界面直接变黑。6. 跨屏拖拽和运行时 DPI 变化PerMonitorV2 真正的难点程序在单屏上显示正常只是及格线。真正的挑战是用户把窗口从 100% 的屏幕拖到 150% 的屏幕上整个界面要能重新排好。6.1 DpiChanged 的触发顺序和该做的事在 net6.0 分支上WinForms 已经帮你处理了大部分的自动重排你要做的是补上框架管不到的部分。事件的触发顺序是控件先收到WM_DPICHANGED框架按新 DPI 调整控件尺寸然后触发DpiChanged事件最后才是重绘。DpiChanged里我通常只做三件事protected override void OnDpiChanged(DpiChangedEventArgs e) { base.OnDpiChanged(e); // 1. 清掉所有跟尺寸相关的缓存 IconCache.Clear(); ThumbnailCache.Clear(); // 2. 重设不会自动缩放的属性 _toolStrip.ImageScalingSize new Size( (int)(16 * e.DeviceDpiNew / 96f), (int)(16 * e.DeviceDpiNew / 96f)); // 3. 自绘控件请求重绘 _chart.Invalidate(); }不要在DpiChanged里改Font。改字体会触发一次控件树的重新布局而这次重新布局又可能再次触发 DPI 相关的处理运气不好就会递归到栈溢出。字体交给框架的自动缩放处理就好。顶层窗体的位置系统会给一个建议值处理WM_DPICHANGED的标准做法是把窗口矩形设成系统建议的那个否则窗口在跨屏时会出现大小跳变protected override void WndProc(ref Message m) { const int WM_DPICHANGED 0x02E0; if (m.Msg WM_DPICHANGED) { // lParam 指向系统建议的新窗口矩形 var suggested Marshal.PtrToStructureRECT(m.LParam); Bounds new Rectangle( suggested.Left, suggested.Top, suggested.Right - suggested.Left, suggested.Bottom - suggested.Top); } base.WndProc(ref m); }net461 分支上DpiChanged事件不存在上面这段WndProc就是唯一手段而且你还得自己遍历控件树重新计算尺寸工作量大概是 net6.0 分支的十倍以上。这就是我前面建议 461 只用 SystemAware 的根本原因——不是技术做不到是投入产出比不划算。6.2 多显示器定位、居中、弹窗位置跨屏场景下的坐标计算也有讲究。Screen.AllScreens里每块屏幕的Bounds都是物理像素在一个 200% 缩放的主屏加一块 100% 的副屏的机器上主屏逻辑上是 960×540但Bounds返回 1920×1080。窗口居中的经典写法var screen Screen.FromControl(this); var area screen.WorkingArea; // 用 WorkingArea不用 Bounds Location new Point( area.Left (area.Width - Width) / 2, area.Top (area.Height - Height) / 2);用Bounds而不是WorkingArea的话在任务栏居左或者自动隐藏的机器上窗口会有明显偏斜。这个坑在 4K 屏上被放大了四倍肉眼一看就知道不对。还有一个隐蔽问题StartPosition FormStartPosition.CenterParent在跨屏场景下会以父窗口所在屏幕为参照如果父窗口在副屏子窗口也会跑到副屏这是对的但如果子窗口在显示之前已经按主屏 DPI 算过尺寸落到副屏上就会大小不对。稳妥做法是在Load事件里重新算一次位置和尺寸。6.3 第三方自绘 UI 库的兼容情况与取舍现实项目里很少全用原生控件。以 AntdUI 这类自绘 UI 库为例这类库的价值在于它把圆角、阴影、动画这些原生控件做不出来的效果封装好了引入成本也不高。但用之前得先确认三件事第一库本身是不是 PerMonitorV2 友好的。判断方法很直接把窗口拖到不同缩放的屏幕之间看它的阴影和圆角有没有变形。很多库的阴影是用位图预先渲染的不跟随 DPI 变化跨屏时会突然变成一圈糊边。第二主题和字体设置会不会覆盖全局配置。这类库通常会在初始化时设置自己的默认字体。如果你在csproj里配了ApplicationDefaultFont库又自己设了一套混用下会出现系统控件一个字体、库控件另一个字体的错位感。要么全局统一交给库要么在库的配置里显式指定字体。第三自绘控件和标准控件的缩放比例可能会不一致。自绘控件如果自己算了一套缩放而框架又算了一套就会出现按钮变大了但旁边的原生文本框没变的现象。测试方法我一般是这样把窗口在两个不同缩放的屏幕之间来回拖 20 次看有没有累积漂移——好的实现每次都回到同样的尺寸差的实现会一次比一次大或者一次比一次小。7. 验证与回归怎么确认这套东西真的适配好了配置写完、代码改完最怕的是我这台机器看着没问题。高 DPI 的问题往往只在特定档位和特定硬件组合下才暴露。7.1 测试档位矩阵我一般按下面这张表过一遍不留死角缩放档位关注点是否必测100%与设计稿完全一致不应该有任何偏移必测125%非整数倍最容易出现像素舍入误差必测150%最常见的笔记本默认值必测175%舍入误差最明显的档位之一必测200%极端值验证是否有溢出和截断必测跨屏拖拽两个不同缩放的屏幕之间来回移动必测远程桌面RDP 会报告另一套 DPI容易出意外建议测系统字体放大辅助功能里的使文本更大建议测高对比度主题自绘控件的配色会不会失效建议测切缩放档位不用每次都注销重启。Windows 10/11 里改完缩放会立即生效但已经运行的程序不会重新读取得重启被测程序。我在测试机上通常会准备一个批处理改完缩放重启程序省得来回点。远程桌面这一项特别值得单列RDP 会话里的 DPI 报告机制跟本地不一样有些情况下会报 96 但实际呈现是缩放的导致程序按 100% 布局看起来特别小。测试的时候用mstsc连到一台缩放设置不同的机器上跑一遍能发现一批本地测不出来的问题。7.2 一份可以贴在 PR 描述里的自查清单代码评审时我一般要求作者把这几条挨个确认一遍100% 缩放下所有窗体的实际尺寸跟设计视图一致没有多出来或少了几个像素。每个缩放档位下没有控件重叠、没有文字被截断、没有按钮超出窗体边界。图标在 400% 放大截图下边缘锐利没有明显的插值模糊。跨屏拖拽 20 次窗口尺寸和控件布局不漂移。所有弹窗都居中在父窗口所在的那块屏幕上不是主屏。DataGridView行高、表头高度跟字体匹配没有半行字被切。所有自绘控件在DpiChanged后重绘缓存被清空。打印/导出功能走的是逻辑坐标输出不随屏幕缩放变化。*.Designer.cs里没有因为设计器 DPI 变化而被改写的AutoScaleDimensions。最后一条要特意强调。VS2022 自己是 PerMonitorV2 的你在不同缩放的显示器之间来回拖设计器窗口设计器有可能会重写Designer.cs里的AutoScaleDimensions和一堆控件的Size。这种事如果不注意提交上去就是一次界面全乱的事故。提交前 diff 一下Designer.cs把非预期的尺寸改动全部还原这个习惯值一顿饭。7.3 我实际踩过的几个具体坑第一个在构造函数里写this.Size new Size(800, 600)。这行代码会在自动缩放之前执行把框架算好的尺寸直接覆盖掉结果就是 150% 下窗口还是 800×600 的逻辑像素内容全被挤在一起。正确做法是设ClientSize并且放到OnLoad里或者干脆交给AutoScaleMode处理。第二个App.config 的DpiAwareness和 manifest 同时写。两个都写的时候谁生效取决于框架的加载顺序而且不同机器上表现还不一样。我遇到过开发机正常、测试机糊的诡异现象就是因为 manfiest 在测试机上先被解析了。一个目标框架只留一条路这句话值一条命的调试时间。第三个图标缓存没清。程序启动在 100% 的屏幕上把图标按 16×16 缩好缓存起来拖到 150% 的屏幕上图标还是 16×16 被拉伸成 24×24糊得很明显。在DpiChanged里清缓存这一步经常被漏掉因为它只在跨屏场景下才暴露。第四个net461 上EnableWindowsFormsHighDpiAutoResizing没生效。原因是这个配置段是从 4.7 才开始完整支持的461 上它被静默忽略了。如果你的项目必须留在 461 上那就只能靠 manifest 声明 SystemAware 加手工缩放没有别的捷径。我现在维护的这套双目标工程跑了一年多最难缠的其实就是跨屏拖拽和第三方自绘库这两块。前者靠DpiChanged加缓存清理基本能压住后者只能一个库一个库去实测——毕竟自绘库的 DPI 处理质量完全看作者的功力。另外分享个小技巧把DpiHelper.GetScale的结果打到日志里每次程序启动和跨屏时输出一行出问题时看日志就能判断到底是感知级别配错了还是某个控件没跟着重排比用截图猜快得多。