「用 WPF 的话应该什么都不用做,DPI 就自动没问题了吧?」这是我们在高 DPI 相关咨询中经常被问到的问题。这种说法一半对、一半不对。实际被拿来咨询的症状包括:「笔记本单独使用时很清晰,接上会议室的外接显示器后整个画面就渗色」「文字很清晰,却只有工具栏的图标模糊」「列表的边框线在不同位置会变粗或消失」「画面中嵌入的旧报表控件只有那一部分变小」──这些全都是实际发生在「应该对 DPI 很强」的 WPF 应用程序上的现象。
前一天的文章「WinForms 的高 DPI 支持」整理了 Windows DPI 缩放的机制与 DPI 感知模式(Unaware/System Aware/Per-Monitor V2),以及如何拯救停留在 96 DPI 硬编码时代的 WinForms。本文则是它的 WPF 版本。DPI 虚拟化与 DPI 感知模式的一般理论交给 WinForms 那篇文章,这里集中讨论 WPF 特有的问题:「明明一开始就应该支持 DPI 的 WPF,为什么、又在哪里会残留问题」。内容按实务上会用到的顺序排列:症状判断、Per-Monitor DPI 支持的声明方式(分别针对 .NET Framework 4.6.2 与 .NET)、细线与位图渗色的对策、WindowsFormsHost 混用的陷阱,直到该做到什么程度的判断表。
1. 先说结论
- WPF 从一开始就能正确跟踪「启动时的 DPI」。 由于布局单位是 1/96 英寸的设备无关单位(DIP),渲染系统会根据系统 DPI 自动缩放,因此默认就是 System DPI Aware。不需要像 WinForms 那样设置、检查 AutoScaleMode。1
- 尽管如此,仍会残留的问题可以分成 4 类:(a) 移动到 DPI 不同的显示器时整体渗色、(b) 位图图像/图标模糊、(c) 细线/边框线渗色、(d) WindowsFormsHost/WebBrowser 等混合内容。原因与修复方法各不相同,请先用第 3 章的表格判断属于哪一类。
- (a) 的根本解决办法是 Per-Monitor DPI 支持。.NET Framework 4.6.2 及以后的 WPF 在框架层面支持这项功能,只要在应用程序清单中声明,窗口本身的重新缩放就会自动完成。23
- .NET(Core 3.1~.NET 8)上的 WPF,默认也仍是 System Aware。WPF 没有相当于 WinForms
ApplicationHighDpiMode/SetHighDpiMode的机制,声明方式和 .NET Framework 一样,都是通过应用程序清单。 - (c) 是比 DPI 更早就存在的子像素定位问题。第一步是在根元素上设置
UseLayoutRounding="True",而SnapsToDevicePixels则是另一种在渲染时进行对齐的工具。两者默认都是关闭。45 - (b) 应优先选用 Path / Geometry 等矢量资源。只有位图版本的资源,就准备多种分辨率并根据 DPI 切换。WPF 默认的插值方式(Linear)在非整数倍缩放时,最容易让像图标这类小图变模糊。6
- (d) 的混合内容是决定 Per-Monitor 支持上限的因素。尤其「被托管」在 ElementHost/HwndSource 中的 WPF,其 Per-Monitor 行为官方已明确表示不支持。3
- DPI 感知模式的一般理论(DPI 虚拟化、System Aware 与 Per-Monitor V2 的区别、用户从 exe 属性覆盖设置)以及混合 DPI 测试环境的搭建方式,与WinForms 那篇文章的第 2~3 章、第 7 章相同,本文不再重复。
2. WPF 为什么「对 DPI 很强」──DIP 与 System DPI Aware
WinForms 高 DPI 问题的根源,在于坐标与尺寸都是直接硬编码为物理像素,而以 96 DPI 为前提的布局,是靠事后补上的机制(AutoScale)在运行时重新换算。WPF 的出发点不同。XAML 中写的 Width="120",这个 120 并不是物理像素,而是以 1/96 英寸为 1 的设备无关单位(DIP)。整个布局都以这个单位计算,渲染时再根据系统 DPI 换算成对应的倍率(150% 时为 1.5 倍)。文字也是以矢量字体绘制,即使放大也不会有位图式的劣化。靠着这套机制,WPF 应用程序即使什么都不声明,也会以 System DPI Aware 的方式运行。1
这里先整理一份与 WinForms 的差异对照表。
| WinForms | WPF | |
|---|---|---|
| 坐标与尺寸的单位 | 物理像素 | DIP(1/96 英寸) |
| 什么都不声明时 | Unaware(因操作系统的位图放大而模糊) | System Aware(主显示器上很清晰) |
| 对启动时 DPI 的跟踪 | 通过 AutoScaleMode 逐个表单重新计算,需要配置与检查 | 渲染系统自动缩放,无需配置 |
| 高 DPI 下常见的问题 | 固定坐标布局重叠、被裁切 | 布局不会崩坏,而是以渗色、模糊的形式出现 |
| 设计器引起的事故 | AutoScaleDimensions 被改写(常见案例) | 原则上没有(XAML 仍以 DIP 保存) |
如表所示,在 WinForms 中占工时最大头的「修复布局崩坏」「防止设计器事故」这类工作,在 WPF 中几乎不会发生。「WPF 对 DPI 很强」是正确的认知。
不过,自动化只覆盖到System Aware 的范围。System Aware 是「按登录时主显示器的 DPI 显示」这种模式,并不包含跟踪各显示器 DPI 不同的环境(Per-Monitor)。另外,以 DIP 放大的终究只是 WPF 自己绘制的东西(文字、图形、控件),位图图像放大就会模糊,而带入 HWND 的混合内容则在 WPF 缩放机制的范围之外。「明明应该对 DPI 很强」这类咨询,几乎都发生在这剩下的部分。
3. 仍然会发生的问题──从症状判断原因
这是我们接到咨询时,第一个会拿出来用的判断表。WPF 中症状与原因的对应关系比 WinForms 分得更清楚,光靠这张表几乎就能确定原因。
| 症状 | 原因 | 对策 |
|---|---|---|
| 移动到 DPI 不同的显示器时整体一致地渗色。移回原来的显示器就恢复正常 | 仍是 System Aware,操作系统以位图方式放大整个窗口 | 第 4 章(改用 Per-Monitor) |
| 刚更改缩放设置后,或通过高 DPI 客户端进行 RDP 连接时渗色 | 同上(System DPI 在登录时就固定下来) | 第 4 章 |
| 文字很清晰,却只有图标、图像模糊或变小 | 位图资源被插值放大 | 第 5.3 节 |
| 1px 的边框线依位置粗细不同、微微渗色 | 子像素定位与抗锯齿 | 第 5.1 节 |
| 在 100%(96 DPI)的显示器上,小字看起来也渗色 | 默认文本格式化(Ideal)的抗锯齿 | 第 5.2 节 |
| 自行生成的图像(WriteableBitmap、RenderTargetBitmap 等)模糊 | 像素尺寸是以 96 DPI 为前提计算的 | 第 6 章 |
| 窗口位置、鼠标坐标、屏幕截图的坐标偏移 | 混淆了 DIP 与物理像素 | 第 6 章 |
| 只有WindowsFormsHost/WebBrowser/部分第三方控件的内部变小、变粗糙、错乱 | 混合内容(不在 WPF 缩放的范围内) | 第 7 章 |
补充说明第 1 行「整体一致地渗色」。这是我在 WinForms 那篇文章第 2 章写过的DPI 虚拟化(操作系统的位图放大)作用在 System Aware 应用程序上的状态,应用程序本身并没有出问题。System Aware 应用程序是以「登录时主显示器的 DPI」绘制,在其他 DPI 的显示器上,则由操作系统放大或缩小来凑合。这是操作系统的一种补救措施:布局不会崩坏,但代价是渗色。1 要修复,就是拒绝这项补救、声明「我会自己跟踪每个显示器的 DPI」,也就是改用 Per-Monitor。
反过来说,第 3 行以后的症状,即使停留在 System Aware 也能修复。如果咨询的是「没有多显示器使用场景,但 150% 画面下的图标和边框线很脏」,可以跳过第 4 章,直接从第 5 章开始处理。
4. 多显示器的渗色──Per-Monitor DPI 支持
4.1 System Aware 的局限
System DPI 会在登录时以主显示器的 DPI 固定下来。如果 150% 的笔记本内置屏幕是主显示器,WPF 就会以 1.5 倍绘制所有窗口;移动到 100% 的外接显示器时,操作系统会将其缩小显示为 2/3。这种操作系统层面的缩放在非整数倍时看起来特别模糊。1 实际验证就会发现,比起 200% 与 100% 这种整数 2 倍差,125% 与 150% 这类不上不下的组合,外观上的画质劣化反而更明显。
换句话说,System Aware 的 WPF 会造成困扰的场景,仅限于在 DPI 不同的显示器之间来回使用,以及登录后缩放设置发生变化的情况(例如插拔扩展基座导致主显示器改变、通过 RDP 带入客户端的 DPI 等)。如果是所有人都在单一显示器的固定桌面环境使用的内部应用程序,停留在 System Aware 在实务上就已经足够。这个判断会在第 8 章的表格中再次出现。
4.2 .NET Framework 4.6.2 及以后的声明方式
WPF 的 Per-Monitor DPI 支持是在 .NET Framework 4.6.2 才加入的。2 在那之前,即使向操作系统声明 Per-Monitor,WPF 本身也不会跟踪 DPI 变化,必须自己把窗口重新缩放的逻辑全部写出来(这也是为什么 Windows 8.1 时代的示例会用原生辅助 DLL 做出那么复杂的架构)。4.6.2 以后,WPF 会自行处理 WM_DPICHANGED,从调整窗口大小、重新布局到重新绘制,全部自动完成。
条件有两个:Windows 10 Anniversary Update(1607)及以后的操作系统,以及以 .NET Framework 4.6.2 及以后为目标的构建。3 声明要写在应用程序清单中。
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<!-- .NET Framework 4.6.2~4.7.2 单独声明 PerMonitor(与开发者指南一致)。
WPF 的 PerMonitorV2 支持从 .NET Framework 4.8 才开始,
因此 4.8+ / .NET 要写成「PerMonitorV2, PerMonitor」,把 V2 放在前面 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"
>PerMonitor</dpiAwareness>
<!-- 给不认识 dpiAwareness 的旧版操作系统使用(回退到 System Aware) -->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
这种双层结构是有道理的。dpiAwareness 元素在 Windows 10 1607 及以后才会被识别,且在以逗号分隔的多个值中,会采用第一个能被识别的值。7 对于根本不认识 dpiAwareness 元素的旧版操作系统,就会回退到 dpiAware 的声明(System Aware),这就是它的运作原理。
这里请注意,要根据目标框架版本区分使用 PerMonitor 与 PerMonitorV2。WPF 正式支持 PerMonitorV2(与 Mixed-Mode DPI)是从 .NET Framework 4.8 以后才开始8,即使 4.6.2 开发者指南中的示例,也是单独声明 PerMonitor。3 如果在以 4.6.2~4.7.2 为目标的项目中把 V2 写在前面,Windows 10 1703 及以后的操作系统就会选到框架其实并不支持的 V2 模式,尤其在 WindowsFormsHost 等混合内容周边,容易出现预期之外的行为。我们的建议做法是:先升级到 4.8 及以后(或 .NET 上的 WPF),再把 V2 放在前面写成 PerMonitorV2, PerMonitor,把标题栏、滚动条等非客户区的缩放交给操作系统处理(参见WinForms 那篇文章第 3 章)。
有一个目标框架的陷阱要注意。即使运行环境的 .NET Framework 是 4.6.2 及以后,只要项目的目标仍停留在 4.6.1 以前,Per-Monitor 跟踪默认就是关闭的。这时要在 app.config 中通过 AppContext 开关明确启用。3
<configuration>
<runtime>
<!-- 注意双重否定:把「DPI 变化时不缩放」设置为 false,等于启用 -->
<AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
</runtime>
</configuration>
「明明写了应用程序清单却没效果」这类咨询,依经验来看几乎都出在这个目标框架问题,或是清单根本没有被打包进构建(项目设置仍停留在默认清单)这两者之一。
4.3 .NET(Core 3.1~.NET 8)的声明方式
.NET 上的 WPF,在没有应用程序清单的情况下,默认仍是 System Aware。相对于 WinForms 在项目文件中有专用入口 ApplicationHighDpiMode,或是 Application.SetHighDpiMode,WPF 并没有从代码或项目设置切换 DPI 感知模式的官方机制。声明方式与 .NET Framework 相同:在项目中添加 app.manifest(在 Visual Studio 中选择「添加新项」→「应用程序清单文件」),写入与 4.2 节相同的 dpiAwareness 声明,再从项目文件中引用它。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
因为运行环境本身较新,所以不需要 4.2 节的 AppContext 开关。唯一要注意的是,并不会因为「迁移到 .NET」就自动解决高 DPI 问题。迁移带来的轻松,是 WinForms 那一侧的故事(见 WinForms 那篇文章第 4.1 节),WPF 早在 .NET Framework 4.6.2 的时候就已经达到了现在的水平。
声明之后仍有工作要做,这点与 WinForms 相同,但内容不一样。在 WinForms,修复布局崩坏是主战场。在 WPF,布局由框架自动跟踪,所以剩下的是全屏显示检查、位图资源的 DPI 切换(第 5.3 节)、直接处理像素的代码跟踪(第 6 章)、混合内容的确认(第 7 章)。屏幕数量相同的情况下,改用 Per-Monitor 的总工时通常会比 WinForms 少一截。
5. 渲染的渗色与模糊──细线、文字、位图
本章的内容与是否改用 Per-Monitor 无关。即使停留在 System Aware 也有效,因此就算是没有多显示器使用场景的应用程序,也值得应用。
5.1 细线、边框线的渗色──UseLayoutRounding 与 SnapsToDevicePixels
以 DIP 进行布局,意味着元素的边界不一定会落在物理像素的整数位置上。在 125% 时,1 DIP = 1.25px,因此宽度为 1 DIP 的边框线相当于 1.25 个物理像素,落在像素中间的边缘就会被抗锯齿处理成半透明。结果就是「线条渗色」「明明都设置成 1px,不同行看起来粗细却不一样」。即使在 96 DPI 下,只要 Grid 的星号尺寸(*)分割或居中对齐的边距计算落在 0.5px 上,也会发生同样的事。这是 WPF 子像素渲染规格本身的特性,与其说是 DPI 问题,更像是「DPI 一提高就更容易暴露出来」的问题。4
第一步是布局舍入。在根元素上加一行即可。
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
UseLayoutRounding="True">
UseLayoutRounding 是在布局阶段把非整数的像素值四舍五入的机制,默认是关闭的,只要设置在根元素上,就会传播到整个可视化树。4 名称相似的 SnapsToDevicePixels 扮演的角色不同,它不是在布局,而是在渲染时把边缘对齐到像素边界。这个属性的默认值同样是 false,设置会向子树继承。官方文档也提到,它可用于在超过 96 DPI 的环境下,抑制细线周围因抗锯齿造成的渗色。5
| UseLayoutRounding | SnapsToDevicePixels | |
|---|---|---|
| 作用时机 | 布局阶段(把 Measure/Arrange 的结果四舍五入为整数像素) | 渲染时(把边缘对齐到像素边界) |
| 默认值 | false | false |
| 适用范围 | 设置在根元素则传播到子孙元素 | 同样向子孙元素继承 |
| 主要作用对象 | 布局整体。元素尺寸、位置的 0.5px 偏移,以及由此造成的线条、边界渗色 | Border、细线等个别元素残留的渗色 |
| 使用建议 | 先应用在根 Window 上。新项目可放进所有窗口共用的样式中 | 针对 UseLayoutRounding 仍无法解决的地方做定点处理 |
如果不确定该怎么做,顺序就是「先在根元素上设置 UseLayoutRounding="True",残留的地方再应用 SnapsToDevicePixels="True"」。舍入的副作用是,原本以星号尺寸平均分配的列宽,可能会出现 1px 左右的不均等,但依经验,这在业务应用程序的画面上几乎不会造成问题。另外,对于用 DrawingContext 自行绘制的代码所产生的渗色,还有一个叫 GuidelineSet 的低层手段,但光是把绘制坐标四舍五入,大部分情况下就已经能解决。
5.2 文字的渗色──TextFormattingMode
「WPF 的文字很淡、会渗色」这个由来已久的抱怨,与其说是 DPI 问题,其实更接近文本格式化模式的问题。WPF 的文本格式化有两种模式:以字体本身的理想度量排布的 Ideal(默认),以及以 GDI 兼容度量排布的 Display。9 Ideal 的字符间距很漂亮,缩放时也很自然,但在 96 DPI(100%)下绘制较小的文字时,有时会因为抗锯齿而看起来渗色。若在根元素上设置 TextOptions.TextFormattingMode="Display",就会得到类似 WinForms 那种锐利的感觉。
不过在高 DPI 环境下,情况会反过来。Display 是按照 96 DPI 像素网格进行的格式化,因此在缩放状态下,反而可能让质量变差。实务上比较干脆的做法是:如果 100% 环境的用户较多,就用 Display;在如今 125% 以上已成主流的标准环境下,保持默认的 Ideal 即可。也可以做成只在 100% 时切换为 Display 的实现,但值得做到这种程度的画面(像文本编辑器那类画面)其实有限。
5.3 图像、图标的模糊──矢量优先与多种分辨率
WPF 对于指定给 Image 的位图,同样以 DIP 布局,并根据 DPI 放大。16×16px 的图标在 150% 环境下会被插值放大成 24×24px,用默认的插值算法(Linear)处理,会明显变模糊。6 WPF 应用程序那种「文字很清晰,却只有图标看起来发虚」的外观,几乎全都源自这个原因。
对策按优先顺序有 3 种。
- 改用矢量资源(首选)。以
Path/Geometry/DrawingImage持有的图标,在任何 DPI 下都能锐利呈现,也不需要切换用的代码。从设计工具或 SVG 转换成 XAML 的工具也相当完备。值得把「新应用程序的图标从一开始就用矢量」定为规范。像 Segoe MDL2 Assets 这类图标字体也有同样的效果。 - 根据 DPI 切换多种分辨率的位图。像照片、屏幕截图这类无法矢量化的资源,就准备给 96/120/144/192 DPI 用的版本(例如 16/20/24/32px),根据当前的 DPI 选择。官方开发者指南也建议以按 DPI 切换资源作为模糊对策。1
- 用
RenderOptions.BitmapScalingMode选择插值方式。这是无法增加资源时的缓解手段。如果是像 200% 这种整数倍,用NearestNeighbor保持点状放大反而更锐利;缩小大图像则适合用HighQuality(Fant)。6
第 2 点的切换,如果已改用 Per-Monitor,就用 OnDpiChanged(.NET Framework 4.6.2 及以后可用)来处理。
public partial class MainWindow : Window
{
protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
{
base.OnDpiChanged(oldDpi, newDpi);
// 每次在显示器之间移动或缩放设置变化时都会被调用
AppIcon.Source = IconAssets.SelectFor(newDpi.DpiScaleX);
}
}
public static class IconAssets
{
// 从准备好的 16 / 24 / 32 px 资源中,挑出符合放大倍率的版本
public static BitmapImage SelectFor(double scale) => scale switch
{
<= 1.0 => Load("icon16.png"),
<= 1.5 => Load("icon24.png"),
_ => Load("icon32.png"),
};
private static BitmapImage Load(string name) =>
new(new Uri($"pack://application:,,,/Assets/{name}"));
}
如果停留在 System Aware,DPI 在启动后就不会改变,因此只需在启动时(Loaded)用 VisualTreeHelper.GetDpi(this) 选一次即可。在写切换用的代码之前,每次都先问自己「这个资源本来能不能做成矢量」,结果往往是维护起来最省力的做法。
6. 处理像素的代码与跟踪 DPI 变化
脱离 WPF 自动缩放保护范围的,是自己计算物理像素的代码。典型情况有下面 3 种,都会以「开发机(100%)上完美无缺,只在客户端 150% 才模糊、偏移」这种讨人厌的方式出现。
第一种是自行创建像素缓冲区的代码,例如 WriteableBitmap/RenderTargetBitmap。如果用与 DIP 尺寸相同的数值创建像素数,在 150% 环境下就会被插值放大 1.5 倍而渗色。当前的 DPI 可以通过 VisualTreeHelper.GetDpi 返回的 DpiScale 结构获取,所以像素数要按物理像素计算,并把正确的 DPI 写入缓冲区。
// imageHost:显示渲染结果的元素(ActualWidth/Height 为 DIP)
DpiScale dpi = VisualTreeHelper.GetDpi(imageHost);
int pixelWidth = (int)Math.Ceiling(imageHost.ActualWidth * dpi.DpiScaleX);
int pixelHeight = (int)Math.Ceiling(imageHost.ActualHeight * dpi.DpiScaleY);
// 不要固定写 96,96,要传入实际的 DPI(让 1 个物理像素 = 1 个缓冲区像素)
var bitmap = new WriteableBitmap(
pixelWidth, pixelHeight,
dpi.PixelsPerInchX, dpi.PixelsPerInchY,
PixelFormats.Bgra32, null);
第二种是屏幕坐标与 Win32 互操作。PointToScreen 返回的坐标、通过 WM_MOUSEMOVE 或钩子接收到的坐标、传给 MoveWindow 的坐标,全部都是物理像素,如果和 XAML 端的 DIP 混用,在 125% 环境下就会偏移 1.25 倍。转换时要用 CompositionTarget 的矩阵。
var source = PresentationSource.FromVisual(this);
if (source?.CompositionTarget is { } target)
{
// DIP → 物理像素
Point device = target.TransformToDevice.Transform(new Point(x, y));
// 物理像素 → DIP
Point dip = target.TransformFromDevice.Transform(devicePoint);
}
第三种是缓存。如果缓存了把 DPI 内置进去而生成的位图、布局数值、已格式化的文本,一旦改用 Per-Monitor,就会在每次跨显示器移动时失效。请像 5.3 节一样,在 OnDpiChanged(或窗口的 DpiChanged 事件)中重新生成。这里也是一样,只要停留在 System Aware,DPI 在启动时就固定了,不需要跟踪代码。若以「改用 Per-Monitor 的成本=处理像素的代码数量」来估算,通常会与实际情况相当吻合。
另外,如果以异步方式进行这类重新生成的处理,UI 线程的处理方式,就如同「WPF/WinForms 的 async 与 UI 线程」中整理的内容。
7. 混合内容的陷阱──WindowsFormsHost、WebBrowser、第三方
WPF 自动缩放能覆盖的,仅限于 WPF 自己绘制的东西。带入 HWND 的元素在 WPF 绘制范围的「外面」,因此 DPI 的处理会多一层复杂。Windows 官方文档也明确写着:「托管在 WPF 中的其他框架,以及托管在其他框架中的 WPF,都不会自动缩放」。10
| 混合的形式 | 会发生什么 | 现实的处理方式 |
|---|---|---|
| WindowsFormsHost(在 WPF 中托管 WinForms) | 宿主会转换 DIP 与物理像素这两套坐标系统,但内部内容的缩放仅限于 WinForms 控件本身所能支持的范围11 | 依 WinForms 那篇文章的标准,检查内部 WinForms 侧的 DPI 支持(AutoScaleMode 等)。不要指望它能跟踪 Per-Monitor |
| WebBrowser 控件(IE 引擎) | 位于另一个 HWND 的原生内容。缩放比例有时会与应用程序本身的缩放不一致 | 视为遗留技术。可作为评估迁移到 WebView2的参考素材之一 |
| ElementHost/HwndSource(在 WinForms 或 Win32 中托管 WPF) | Per-Monitor 场景官方明确不支持3 | 设计上只做到与宿主应用程序 DPI 感知模式相符的 System Aware |
| 第三方控件 | 各产品的支持程度不一。不支持 Per-Monitor 的产品会成为该画面的上限 | 先盘点各厂商的支持状况与支持版本,再判断是否要改用 Per-Monitor |
实务上最常见的是第 1 行。假设一个应用程序已经迁移到 WPF,但报表预览或图表这部分仍通过 WindowsFormsHost 托管旧的 WinForms/ActiveX 控件,把它改成 Per-Monitor 之后,WPF 的部分会完美跟踪,但只有宿主内部的内容无法跟踪移动后的 DPI,维持原本较小、较粗糙的状态。Win32 层面虽然有一种托管混合 DPI 的机制(SetThreadDpiHostingBehavior),但并不是能从 WPF 随意使用的东西。我们的做法是干脆认定「混合的部分决定了 Per-Monitor 支持的上限」,先从建立混合内容清单开始。如果要朝消除混合内容的方向判断,可以参考「如何选择 WinForms/WPF/WinUI」与「保留、包装还是替换 ActiveX/OCX 控件」这两篇文章。
8. 做到什么程度──判断表与分阶段推进方法
在 WPF 的情况下,实际上只有 2 个选项(WinForms 那种「什么都不做=Unaware」的选项不存在,因为即使不声明也会是 System Aware)。
| 停留在 System Aware | 改用 Per-Monitor 支持 | |
|---|---|---|
| 外观表现 | 主显示器上很清晰。在 DPI 不同的显示器、更改缩放设置后、RDP 场景下会渗色 | 所有显示器上都清晰 |
| 声明工作 | 不需要(默认) | 只需添加应用程序清单(第 4 章) |
| 声明后剩余工作 | ── | 全屏显示检查+位图资源的 DPI 切换(第 5.3 节)+像素相关代码的 DpiChanged 处理(第 6 章)+混合内容的确认(第 7 章) |
| 决定上限的因素 | ── | WindowsFormsHost、WebBrowser、第三方控件 |
| 适合场景 | 以单一显示器、固定桌面环境为主的内部应用程序。混合内容多的应用程序 | 笔记本+外接显示器混用、长期维护的主力应用程序、要交付给客户的产品 |
判断的依据轴线与 WinForms 那篇文章第 7 章相同(应用程序的生命周期、使用环境、改造预算),不同之处在于WPF 改用 Per-Monitor 的边际成本较小。声明只需 1 个文件,布局由框架自动跟踪,剩余工作则与像素相关代码、资源的数量成比例。如果应用程序的混合内容较少,改用 Per-Monitor 的投资回报率明显比 WinForms 高。我们在实际项目中建议的做法分为以下 3 个阶段。
- 在保持 System Aware 的情况下改善质量:在根元素上应用
UseLayoutRounding、把图标矢量化/多分辨率化、修复WriteableBitmap系的 DPI 问题。除了多显示器渗色之外的问题都会在这一步全部解决,即使最后决定不改用 Per-Monitor,这些成果也能直接保留下来成为资产。 - 声明 Per-Monitor 并进行检查:添加应用程序清单,在混合 DPI 环境下把所有画面过一遍,找出问题所在。这里发现的问题应该都能归类到第 5~7 章中的某一章。
- 实现对 DPI 变化的跟踪:在
OnDpiChanged中进行资源切换、缓存重新生成。混合内容则在这个阶段决定「要容忍到什么程度」。
测试环境可以直接沿用 WinForms 那篇文章第 7 章写的配置(缩放不同的两台显示器、更换主显示器并重新登录、运行中更改缩放设置、从高 DPI 客户端进行 RDP)。10 在 WPF 中应该重点检查的是非整数倍(125%/150%)下的边框线、图标、自行绘制内容,以及跨显示器拖动窗口时的行为。事先掌握使用环境的分布(有多少百分比的用户是多显示器),投资判断就不会摇摆不定。这个角度我们也在「Windows 应用程序 UX 设计」中写过。
9. 总结
WPF 靠 DIP 与自动缩放,从一开始就是 System DPI Aware,WinForms 的主战场「与布局崩坏的战斗」在这里几乎不存在。尽管如此,仍会残留 4 类问题──多显示器渗色(未支持 Per-Monitor)、位图模糊、细线渗色、混合内容──每一类都有已经确立的对策。
- 多显示器渗色,只要是 .NET Framework 4.6.2 及以后/.NET 上的 WPF,光靠应用程序清单声明就能自动获得 Per-Monitor 跟踪
- 细线、边框线的第一步是在根元素上设置
UseLayoutRounding="True",其余再用SnapsToDevicePixels - 图标优先选用矢量资源,位图则需要多种分辨率+按 DPI 切换
WriteableBitmap或屏幕坐标等只有处理像素的代码才是真正需要改造的对象,其数量决定了改用 Per-Monitor 的工作量- WindowsFormsHost/WebBrowser/第三方掌握着支持程度的上限,要先盘点清楚
就算是停留在「因为是 WPF,应该没问题」这种认知的应用程序,用这套整理方式来看,往往也只有寥寥几处需要修正。如果不确定手上的应用程序能修到什么程度,或是在包含混合内容的现状调查、支持级别判断上感到迷惑,我们都可以提供协助。
相关文章
- WinForms 的高 DPI 支持──在 4K 显示器上模糊、错乱的原因与现实对策
- WinForms/WPF/WinUI 的选择方式 - 实务判断表
- 用一张表整理 WPF/WinForms 的 async 与 UI 线程
- Windows 应用程序 UX 设计 - 按使用环境的优先级
相关咨询领域
小村软件有限公司承接 WPF/WinForms 应用程序的高 DPI 支持(现状调查、Per-Monitor 化可行性判断、混合内容的盘点与改造)、PC 更换/引入 4K 显示器所伴随的显示问题原因调查,以及 UI 改版的相关咨询。
参考链接
-
Microsoft Learn, Developing a Per-Monitor DPI-Aware WPF Application。关于 WPF 默认为 System DPI Aware、通过 DIP 实现自动缩放的机制、移动到 DPI 不同的显示器时操作系统会进行缩放且在非整数倍时特别模糊,以及按 DPI 切换位图资源的思路。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, What’s new in .NET Framework。关于 .NET Framework 4.6.2 的 WPF 启用了 Per-Monitor DPI awareness、Switch.System.Windows.DoNotScaleForDpiChanges 开关,以及 GitHub 上的开发者指南。 ↩ ↩2
-
GitHub (microsoft/WPF-Samples),Per Monitor DPI Developer Guide。关于 Windows 10 Anniversary Update 与 .NET Framework 4.6.2 及以后的要求、应用程序清单中 dpiAwareness/dpiAware 的写法、针对早于 4.6.2 目标的 AppContextSwitchOverrides,以及托管在 HwndSource/ElementHost 中的 WPF 不支持 Per-Monitor 这一点。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Layout - WPF。关于以 DIP 进行布局与子像素渲染导致边缘模糊的机制、布局舍入(UseLayoutRounding)默认为关闭,以及设置在根元素会传播到整个可视化树。 ↩ ↩2 ↩3
-
Microsoft Learn, UIElement.SnapsToDevicePixels Property。关于默认值为 false、设置在根元素会向子树继承,以及在超过 96 DPI 的环境下可减轻细线周围因抗锯齿造成的视觉瑕疵。 ↩ ↩2
-
Microsoft Learn, BitmapScalingMode Enum。关于默认值(Unspecified)为 Linear,以及 HighQuality(Fant)、NearestNeighbor 两种插值算法的特性。 ↩ ↩2 ↩3
-
Microsoft Learn, Setting the default DPI awareness for a process。关于 dpiAwareness 元素(Windows 10 1607 及以后)的优先级高于 dpiAware,以及在以逗号列举的值中,会采用第一个能被识别的值这一回退行为。 ↩
-
Microsoft Learn, What’s new in .NET Framework。关于 .NET Framework 4.8 为 WPF 新增了 Per-Monitor V2 DPI Awareness 与 Mixed-Mode DPI 缩放的支持、托管 HWND/WinForms 互操作的改进,以及启用所需的 AppContext 开关。 ↩
-
Microsoft Learn, TextFormattingMode Enum。关于文本格式化的 Ideal(理想度量)与 Display(GDI 兼容度量)这两种模式。 ↩
-
Microsoft Learn, High DPI Desktop Application Development on Windows。关于各 UI 框架的 Per-Monitor 支持对照表、托管在 WPF 中的其他框架与托管在其他框架中的 WPF 不会自动缩放,以及混合 DPI 环境下的测试要点。 ↩ ↩2
-
Microsoft Learn, Layout Considerations for the WindowsFormsHost Element。关于 WindowsFormsHost 会转换 DIP 与物理像素这两套坐标系统,以及缩放仅限于内部的 Windows Forms 控件本身所能支持的范围。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
WinForms 的高DPI支持 - 4K显示器下模糊、错位的原因与实用对策
本文从 DPI 虚拟化与 DPI 感知模式(System Aware / Per-Monitor V2)的角度,整理 WinForms 应用在4K显示器、150%缩放环境下显示模糊、布局错乱的原因,并说明 .NET 与 .NET Framework 各自的配置方法、Auto...
在 WinForms/WPF 应用中集成 Entra ID 认证 —— MSAL.NET 与 WAM Broker 的实务架构
本文以实务视角整理在 WinForms/WPF 桌面应用中集成 Entra ID(原 Azure AD)认证的步骤:公共客户端的思路、ROPC 被弃用的现状、应用注册、MSAL.NET 的 AcquireTokenSilent 模式、WAM Broker、令牌缓存的持久化,...
业务应用的日期时间与时区 ── 从 DateTime 的陷阱到 UTC 存储原则、测试设计
服务器迁移后时间整整差了 9 个小时、只有海外分支机构的日期会变成前一天——本文从 DateTime 的 Kind 与隐式转换梳理日期时间事故的根本原因。内容涵盖与 DateTimeOffset 的取舍、UTC 存储与 ISO 8601 原则、TimeZoneInfo 与夏...
Windows 应用的任务栏托盘常驻与 Toast 通知 —— NotifyIcon 的坑与 AppNotification 的选型
本文整理了将业务 Windows 应用常驻在任务栏托盘(通知区域)并通过 Toast 通知告知用户的实现要点。内容涵盖 NotifyIcon 的正确用法与「关闭后驻留托盘」的设计、资源管理器重启后的重新注册、三种 Toast API(Windows App SDK AppN...
WinForms/WPF应用的多语言化 ── resx、附属程序集与区域文化切换的实务
本文从实务角度系统梳理Windows桌面应用多语言化的要点:CurrentCulture与CurrentUICulture的区别、resx与附属程序集构成的资源机制、WinForms的Localizable属性、WPF的现实方案选择、运行时语言切换,以及排版、格式、RTL等内容。
常见问题
汇总了咨询这一主题时常见的问题。
- 听说 WPF 不需要做 DPI 支持,为什么还是会渗色?
- WPF 的布局单位是 1/96 英寸的设备无关单位(DIP),并且默认以 System DPI Aware 方式运行,因此对启动时主显示器的 DPI 能正确跟踪。但 System DPI 是在登录时就固定下来的,所以把窗口移到 DPI 不同的显示器时,操作系统会把整个窗口以位图方式放大或缩小来凑合,导致整体一致地渗色。尤其在 125% 与 150% 这类非整数倍的组合下,画质劣化会特别明显。根本解决办法是在应用程序清单(manifest)中声明 Per-Monitor DPI 支持,.NET Framework 4.6.2 以后的 WPF 只要声明,窗口的重新缩放就会自动完成。
- WPF 中 1px 的边框线会渗色、粗细不一致,是为什么?
- 因为 WPF 以 DIP 为单位进行布局,元素的边界不一定会落在物理像素的整数位置上。在 125% 环境下,1 DIP = 1.25px,落在像素中间的边缘会被抗锯齿处理成半透明而渗色。第一步是在根 Window 上设置 UseLayoutRounding="True",让布局阶段把非整数的像素值四舍五入,并向下传播到整个可视化树。如果还有残留,再针对个别元素应用 SnapsToDevicePixels="True",在渲染时把边缘对齐到像素边界。两者的默认值都是关闭。
- WPF 中文字很清晰,却只有图标模糊,是为什么?
- 因为文字是以矢量字体绘制,在任何 DPI 下都很锐利,而位图图像是以 DIP 布局的,会根据 DPI 进行插值放大。16×16px 的图标在 150% 环境下会放大到 24×24px,默认的插值算法(Linear)会让它明显模糊。对策的优先顺序是:(1) 改用 Path / Geometry / DrawingImage 或图标字体等矢量资源;(2) 准备多种分辨率的位图,通过 VisualTreeHelper.GetDpi 或 OnDpiChanged 根据 DPI 切换;(3) 用 RenderOptions.BitmapScalingMode 调整插值算法。
- WPF 的 Per-Monitor 支持要怎么设置?
- 在应用程序清单中写入 dpiAwareness 元素来声明。WPF 没有像 WinForms 的 ApplicationHighDpiMode 那样的项目设置或代码切换手段。条件是 Windows 10 1607 以后的操作系统,以及以 .NET Framework 4.6.2 以后为目标的构建,声明之后从 WM_DPICHANGED 的处理到窗口重新缩放都会由框架自动完成。WPF 的 PerMonitorV2 支持从 .NET Framework 4.8 才开始提供,因此在 4.6.2~4.7.2 要单独声明 PerMonitor。如果目标仍停留在 4.6.1 以前,跟踪功能默认是关闭的,需要通过 AppContext 开关来启用。另外,托管在 ElementHost 或 HwndSource 中的 WPF,其 Per-Monitor 行为官方明确不支持。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。