WPF 高 DPI 支持──「不是应该对 DPI 很强吗」却仍模糊、渗色的原因与对策

· · WPF, 高DPI, Windows, .NET, C#, .NET Framework, XAML, UI, 技术咨询

「用 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 与 PerMonitorV2WPF 正式支持 PerMonitorV2(与 Mixed-Mode DPI)是从 .NET Framework 4.8 以后才开始8,即使 4.6.2 开发者指南中的示例,也是单独声明 PerMonitor3 如果在以 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 兼容度量排布的 Display9 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 种。

  1. 改用矢量资源(首选)。以 PathGeometryDrawingImage 持有的图标,在任何 DPI 下都能锐利呈现,也不需要切换用的代码。从设计工具或 SVG 转换成 XAML 的工具也相当完备。值得把「新应用程序的图标从一开始就用矢量」定为规范。像 Segoe MDL2 Assets 这类图标字体也有同样的效果。
  2. 根据 DPI 切换多种分辨率的位图。像照片、屏幕截图这类无法矢量化的资源,就准备给 96/120/144/192 DPI 用的版本(例如 16/20/24/32px),根据当前的 DPI 选择。官方开发者指南也建议以按 DPI 切换资源作为模糊对策。1
  3. 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% 才模糊、偏移」这种讨人厌的方式出现。

第一种是自行创建像素缓冲区的代码,例如 WriteableBitmapRenderTargetBitmap。如果用与 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 个阶段。

  1. 在保持 System Aware 的情况下改善质量:在根元素上应用 UseLayoutRounding、把图标矢量化/多分辨率化、修复 WriteableBitmap 系的 DPI 问题。除了多显示器渗色之外的问题都会在这一步全部解决,即使最后决定不改用 Per-Monitor,这些成果也能直接保留下来成为资产。
  2. 声明 Per-Monitor 并进行检查:添加应用程序清单,在混合 DPI 环境下把所有画面过一遍,找出问题所在。这里发现的问题应该都能归类到第 5~7 章中的某一章。
  3. 实现对 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,应该没问题」这种认知的应用程序,用这套整理方式来看,往往也只有寥寥几处需要修正。如果不确定手上的应用程序能修到什么程度,或是在包含混合内容的现状调查、支持级别判断上感到迷惑,我们都可以提供协助。

相关文章

相关咨询领域

小村软件有限公司承接 WPF/WinForms 应用程序的高 DPI 支持(现状调查、Per-Monitor 化可行性判断、混合内容的盘点与改造)、PC 更换/引入 4K 显示器所伴随的显示问题原因调查,以及 UI 改版的相关咨询。

参考链接

  1. Microsoft Learn, Developing a Per-Monitor DPI-Aware WPF Application。关于 WPF 默认为 System DPI Aware、通过 DIP 实现自动缩放的机制、移动到 DPI 不同的显示器时操作系统会进行缩放且在非整数倍时特别模糊,以及按 DPI 切换位图资源的思路。  2 3 4 5

  2. Microsoft Learn, What’s new in .NET Framework。关于 .NET Framework 4.6.2 的 WPF 启用了 Per-Monitor DPI awareness、Switch.System.Windows.DoNotScaleForDpiChanges 开关,以及 GitHub 上的开发者指南。  2

  3. 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

  4. Microsoft Learn, Layout - WPF。关于以 DIP 进行布局与子像素渲染导致边缘模糊的机制、布局舍入(UseLayoutRounding)默认为关闭,以及设置在根元素会传播到整个可视化树。  2 3

  5. Microsoft Learn, UIElement.SnapsToDevicePixels Property。关于默认值为 false、设置在根元素会向子树继承,以及在超过 96 DPI 的环境下可减轻细线周围因抗锯齿造成的视觉瑕疵。  2

  6. Microsoft Learn, BitmapScalingMode Enum。关于默认值(Unspecified)为 Linear,以及 HighQuality(Fant)、NearestNeighbor 两种插值算法的特性。  2 3

  7. Microsoft Learn, Setting the default DPI awareness for a process。关于 dpiAwareness 元素(Windows 10 1607 及以后)的优先级高于 dpiAware,以及在以逗号列举的值中,会采用第一个能被识别的值这一回退行为。 

  8. Microsoft Learn, What’s new in .NET Framework。关于 .NET Framework 4.8 为 WPF 新增了 Per-Monitor V2 DPI Awareness 与 Mixed-Mode DPI 缩放的支持、托管 HWND/WinForms 互操作的改进,以及启用所需的 AppContext 开关。 

  9. Microsoft Learn, TextFormattingMode Enum。关于文本格式化的 Ideal(理想度量)与 Display(GDI 兼容度量)这两种模式。 

  10. Microsoft Learn, High DPI Desktop Application Development on Windows。关于各 UI 框架的 Per-Monitor 支持对照表、托管在 WPF 中的其他框架与托管在其他框架中的 WPF 不会自动缩放,以及混合 DPI 环境下的测试要点。  2

  11. Microsoft Learn, Layout Considerations for the WindowsFormsHost Element。关于 WindowsFormsHost 会转换 DIP 与物理像素这两套坐标系统,以及缩放仅限于内部的 Windows Forms 控件本身所能支持的范围。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

听说 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表