Windows 上的高 DPI 桌面应用程序开发

此内容面向希望更新桌面应用程序以动态处理显示比例系数(每英寸点或 DPI)更改的开发人员,从而允许其应用程序在呈现的任何显示器上清晰显示。

首先,如果要从头开始创建新的 Windows 应用,强烈建议创建 通用 Windows 平台(UWP) 应用程序。 UWP 应用程序针对其运行的每个显示自动缩放并动态缩放。

使用较旧的 Windows 编程技术(原始 Win32 编程、Windows 窗体、Windows 演示文稿框架(WPF)等的桌面应用程序 在没有其他开发人员工作的情况下,无法自动处理 DPI 缩放。 如果没有此类工作,许多常见使用方案中应用程序将显得模糊或大小不正确。 本文档提供有关更新桌面应用程序以正确呈现的内容的上下文和信息。

显示比例系数 & DPI

随着显示技术的发展,显示面板制造商在其面板上将越来越多的像素打包到每个物理空间单元中。 这导致现代显示面板的每英寸点数(DPI)远高于历史上的点数。 过去,大多数显示器每线性英寸的物理空间有 96 像素(96 DPI):在 2017 年,具有近 300 DPI 或更高版本的显示器随时可用。

大多数旧版桌面 UI 框架都有内置的假设,即显示 DPI 在进程的生存期内不会更改。 此假设不再适用,显示 DPIs 通常会在整个应用程序进程的生存期内更改多次。 显示比例因子/DPI 更改的一些常见方案如下:

  • 多监视器设置,其中每个显示器具有不同的比例系数,应用程序从一个显示器移动到另一个显示器(如 4K 和 1080p 显示器)
  • 使用低 DPI 外部显示器停靠和取消停靠高 DPI 笔记本电脑(反之亦然)
  • 通过远程桌面从高 DPI 笔记本电脑/平板电脑连接到低 DPI 设备(反之亦然)
  • 在应用程序运行时更改显示缩放因子设置

在这些方案中,UWP 应用程序会自动重新绘制新 DPI。 默认情况下,如果没有其他开发人员工作,桌面应用程序不会。 不执行此额外工作以响应 DPI 更改的桌面应用程序可能会向用户显示模糊或大小错误。

DPI 感知模式

桌面应用程序必须告知 Windows 它们是否支持 DPI 缩放。 默认情况下,系统将桌面应用程序 DPI 视为不知道,并按位图拉伸其窗口。 通过设置以下可用的 DPI 感知模式之一,应用程序可以显式告知 Windows 如何处理 DPI 缩放:

DPI Unaware

DPI 不知道应用程序以固定 DPI 值为 96 (100%) 呈现)。 每当这些应用程序在显示比例大于 96 DPI 的屏幕上运行时,Windows 会将应用程序位图拉伸到预期的物理大小。 这会导致应用程序显得模糊。

系统 DPI 感知

系统 DPI 感知的桌面应用程序通常在用户登录时接收主连接监视器的 DPI。 在初始化期间,他们使用系统 DPI 值适当(调整控件大小、选择字号、加载资产等)来设置 UI 布局。 因此,系统 DPI 感知应用程序不会按 Windows 缩放(位图拉伸),以该单个 DPI 显示呈现。 当应用程序移动到具有不同比例因子的显示器时,或者如果显示比例因子发生其他更改,Windows 将位图缩放应用程序的窗口,使它们显得模糊。 实际上,系统 DPI 感知桌面应用程序仅以单个显示比例因子清晰呈现,每当 DPI 发生更改时,都变得模糊。

Per-Monitor 和 Per-Monitor (V2) DPI 感知

建议更新桌面应用程序以使用每监视器 DPI 感知模式,允许在 DPI 发生更改时立即正确呈现这些应用程序。 当应用程序向 Windows 报告它想要在此模式下运行的 Windows 时,Windows 不会在 DPI 更改时位图拉伸应用程序,而是将 WM_DPICHANGED 发送到应用程序窗口。 然后,应用程序负责处理新 DPI 的大小调整大小。 桌面应用程序使用的大多数 UI 框架(Windows 通用控件(comctl32)、Windows 窗体、Windows 演示文稿框架等) 不支持自动 DPI 缩放,要求开发人员调整窗口本身的大小并重新定位其内容。

有两个版本的 Per-Monitor 意识,应用程序可以自行注册为:版本 1 和版本 2(PMv2)。 将进程注册为在 PMv2 感知模式下运行会导致:

  1. 当 DPI 更改时通知应用程序(顶级 HWND 和子 HWND)
  2. 应用程序看到每个显示的原始像素
  3. 应用程序永远不会由 Windows 缩放位图
  4. 自动非工作区(窗口标题、滚动条等)按 Windows 缩放 DPI
  5. Win32 对话框(从 CreateDialog) 自动按 Windows 缩放的 DPI
  6. 公共控件(复选框、按钮背景等)中的主题绘制位图资产在相应的 DPI 比例系数上自动呈现

在 Per-Monitor v2 感知模式下运行时,应用程序在 DPI 发生更改时收到通知。 如果应用程序不调整新 DPI 的大小,则应用程序 UI 将显示太小或太大(具体取决于上一个和新的 DPI 值的差异)。

注意

Per-Monitor V1(PMv1)意识非常有限。 建议应用程序使用 PMv2。

下表显示了应用程序在不同方案中的呈现方式:

DPI 感知模式 引入的 Windows 版本 应用程序的 DPI 视图 DPI 更改行为
知道 N/A 所有显示器均为 96 DPI 位图拉伸(模糊)
系统 前景 所有显示器都具有相同的 DPI(启动当前用户会话时主显示器的 DPI) 位图拉伸(模糊)
Per-Monitor 8.1 应用程序窗口主要位于的显示器的 DPI
  • 收到 DPI 更改的顶级 HWND 通知
  • 没有任何 UI 元素的 DPI 缩放。

Per-Monitor V2 Windows 10 创意者更新 (1703) 应用程序窗口主要位于的显示器的 DPI
  • 顶级 子 HWND 收到 DPI 更改通知

自动 DPI 缩放:
  • 非工作区
  • 常见控件中的主题绘制位图 (comctl32 V6)
  • 对话(CreateDialog

每监视器 (V1) DPI 感知

Per-Monitor Windows 8.1 引入了 V1 DPI 感知模式(PMv1)。 此 DPI 感知模式非常有限,仅提供下面列出的功能。 建议桌面应用程序使用 windows 10 1703 或更高版本支持的 Per-Monitor v2 感知模式。

对按监视器感知的初始支持仅提供以下应用程序:

  1. 顶级 HWND 收到 DPI 更改的通知,并提供了新的建议大小
  2. Windows 不会位图拉伸应用程序 UI
  3. 应用程序看到所有以物理像素为单位的显示(请参阅虚拟化)

在 Windows 10 1607 或更高版本上,PMv1 应用程序还可以在WM_NCCREATE期间调用 EnableNonClientDpiScaling,以请求 Windows 正确缩放窗口的非工作区。

UI 框架/技术的按监视器 DPI 缩放支持

下表显示了从 Windows 10 1703 开始,各种 Windows UI 框架提供的按监视器 DPI 感知支持级别:

框架/技术 支持 OS 版本 处理方式的 DPI 缩放 进一步阅读
通用 Windows 平台 (UWP) 1607 UI 框架 通用 Windows 平台(UWP)
原始 Win32/通用控件 V6 (comctl32.dll)
  • 发送到所有 HWND 的 DPI 更改通知消息
  • 主题绘制的资产在常见控件中正确呈现
  • 对话框的自动 DPI 缩放
1703 应用 GitHub 示例
Windows 窗体 某些控件的自动按监视器 DPI 缩放受限 1703 UI 框架 Windows 窗体 中的 高 DPI 支持
Windows 演示文稿框架 (WPF) 本机 WPF 应用程序将 DPI 缩放在其他框架中托管的 WPF,而 WPF 中托管的其他框架不会自动缩放 1607 UI 框架 GitHub 示例
GDI 没有 N/A 应用 请参阅 GDI High-DPI 缩放
GDI+ 没有 N/A 应用 请参阅 GDI High-DPI 缩放
MFC 没有 N/A 应用 N/A

更新现有应用程序

若要更新现有桌面应用程序以正确处理 DPI 缩放,需要对其进行更新,以便至少更新 UI 的重要部分以响应 DPI 更改。

大多数桌面应用程序在系统 DPI 感知模式下运行。 系统 DPI 感知应用程序通常缩放到主显示器的 DPI(启动 Windows 会话时系统托盘所在的显示器)。 当 DPI 更改时,Windows 将位图拉伸这些应用程序的 UI,这通常会导致它们模糊。 更新系统 DPI 感知应用程序以变为按监视器-DPI 感知时,处理 UI 布局的代码需要更新,以便不仅在应用程序初始化期间执行,而且每当收到 DPI 更改通知(在 Win32 的情况下WM_DPICHANGED)。 这通常涉及重新访问代码中的任何假设,即 UI 只需缩放一次。

此外,对于 Win32 编程,许多 Win32 API 没有任何 DPI 或显示上下文,因此它们仅返回相对于系统 DPI 的值。 通过代码查找其中一些 API 并将其替换为 DPI 感知变体,这非常有用。 具有 DPI 感知变体的一些常见 API 包括:

单个 DPI 版本 Per-Monitor 版本
GetSystemMetrics GetSystemMetricsForDpi
AdjustWindowRectEx AdjustWindowRectExForDpi
SystemParametersInfo SystemParametersInfoForDpi
GetDpiForMonitor GetDpiForWindow

最好在代码库中搜索硬编码大小,该大小假定为一个常量 DPI,将其替换为正确考虑 DPI 缩放的代码。 下面是包含以下所有建议的示例:

例:

下面的示例演示了创建子 HWND 的简化 Win32 案例。 对 CreateWindow 的调用假定应用程序在 96 DPI(USER_DEFAULT_SCREEN_DPI 常量)上运行,并且按钮的大小和位置都不会在更高的 DPIs 上正确:

case WM_CREATE: 
{ 
    // Add a button 
    HWND hWndChild = CreateWindow(L"BUTTON", L"Click Me",  
        WS_CHILD|WS_VISIBLE|BS_PUSHBUTTON,  
        50,  
        50,  
        100,  
        50,  
        hWnd, (HMENU)NULL, NULL, NULL); 
} 

以下更新的代码显示:

  1. 窗口创建代码 DPI 缩放其父窗口 DPI 的子 HWND 的位置和大小
  2. 通过重新定位和调整子 HWND 的大小来响应 DPI 更改
  3. 删除硬编码大小并将其替换为响应 DPI 更改的代码
#define INITIALX_96DPI 50 
#define INITIALY_96DPI 50 
#define INITIALWIDTH_96DPI 100 
#define INITIALHEIGHT_96DPI 50 

// DPI scale the position and size of the button control 
void UpdateButtonLayoutForDpi(HWND hWnd) 
{ 
    int iDpi = GetDpiForWindow(hWnd); 
    int dpiScaledX = MulDiv(INITIALX_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    int dpiScaledY = MulDiv(INITIALY_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    int dpiScaledWidth = MulDiv(INITIALWIDTH_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    int dpiScaledHeight = MulDiv(INITIALHEIGHT_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    SetWindowPos(hWnd, hWnd, dpiScaledX, dpiScaledY, dpiScaledWidth, dpiScaledHeight, SWP_NOZORDER | SWP_NOACTIVATE); 
} 
 
... 
 
case WM_CREATE: 
{ 
    // Add a button 
    HWND hWndChild = CreateWindow(L"BUTTON", L"Click Me",  
        WS_CHILD|WS_VISIBLE|BS_PUSHBUTTON, 
        0, 
        0, 
        0, 
        0, 
        hWnd, (HMENU)NULL, NULL, NULL); 
    if (hWndChild != NULL) 
    { 
        UpdateButtonLayoutForDpi(hWndChild); 
    } 
} 
break; 
 
case WM_DPICHANGED: 
{ 
    // Find the button and resize it 
    HWND hWndButton = FindWindowEx(hWnd, NULL, NULL, NULL); 
    if (hWndButton != NULL) 
    { 
        UpdateButtonLayoutForDpi(hWndButton); 
    } 
} 
break; 

更新系统 DPI 感知应用程序时,要遵循的一些常见步骤如下:

  1. 使用应用程序清单(或其他方法,具体取决于使用的 UI 框架)将进程标记为按监视器 DPI 感知(V2)。
  2. 使 UI 布局逻辑可重用,并将其移出应用程序初始化代码,以便可以在发生 DPI 更改时重复使用(在 Windows(Win32)编程的情况下WM_DPICHANGED)。
  3. 使任何假定 DPI 敏感数据(DPI/fonts/sizes/etc.)无需更新的代码失效。 在进程初始化时缓存字号和 DPI 值是很常见的做法。 更新应用程序以变为每监视器 DPI 感知时,每当遇到新 DPI 时,都必须重新评估 DPI 敏感数据。
  4. 发生 DPI 更改时,重新加载(或重新光栅化)新 DPI 的任何位图资产,或者(可选)位图会将当前加载的资产拉伸到正确的大小。
  5. 不 Per-Monitor DPI 感知的 API 的 Grep,并将其替换为 Per-Monitor DPI 感知 API(如果适用)。 示例:将 GetSystemMetrics 替换为 GetSystemMetricsForDpi。
  6. 在多显示器/多 DPI 系统上测试应用程序。
  7. 对于应用程序中无法更新为正确 DPI 缩放的任何顶级窗口,请使用混合模式 DPI 缩放(如下所述)允许系统对这些顶级窗口进行位图拉伸。

Mixed-Mode DPI 缩放(Sub-Process DPI 缩放)

更新应用程序以支持按监视器 DPI 感知时,有时可能变得不切实际或不可能一次性更新应用程序中的每个窗口。 这可能是由于更新和测试所有 UI 所需的时间和精力,或者你不拥有运行所需的所有 UI 代码(如果应用程序可能加载第三方 UI)。 在这些情况下,Windows 提供了一种方法,让你在原始 DPI 感知模式下运行一些应用程序窗口(仅顶级),同时专注于时间和精力更新 UI 中更重要的部分,从而轻松了解每个监视器的感知。

下图显示了这一点:在现有模式(“辅助窗口”)中运行其他窗口时,更新主应用程序 UI(图中的“主窗口”)以使用按监视器 DPI 感知运行。

差异

在 Windows 10 周年更新(1607)之前,进程的 DPI 感知模式是进程范围的属性。 从 Windows 10 周年更新开始,现在可以为每个 顶级 窗口设置此属性。 ( 窗口必须继续匹配其父窗口的缩放大小。顶级窗口定义为没有父窗口的窗口。 这通常是一个“常规”窗口,最小化、最大化和关闭按钮。 子进程 DPI 感知旨在使辅助 UI 按 Windows 缩放(位图拉伸),同时将时间和资源集中在更新主 UI 上。

若要启用子进程 DPI 感知,请在任何窗口创建调用之前和之后调用 SetThreadDpiAwarenessContext。 创建的窗口将与通过 SetThreadDpiAwarenessContext 设置的 DPI 感知相关联。 使用第二次调用还原当前线程的 DPI 感知。

虽然使用子进程 DPI 缩放使你能够依赖 Windows 为应用程序执行某些 DPI 缩放,但它会增加应用程序的复杂性。 请务必了解此方法的缺点及其引入的复杂性的性质。 有关子进程 DPI 感知的详细信息,请参阅 Mixed-Mode DPI 缩放和 DPI 感知 API。

测试更改

更新应用程序以成为每监视器 DPI 感知后,请务必验证应用程序正确响应混合 DPI 环境中的 DPI 更改。 要测试的一些细节包括:

  1. 在显示不同 DPI 值之间来回移动应用程序窗口
  2. 在显示不同的 DPI 值时启动应用程序
  3. 在应用程序运行时更改监视器的规模因子
  4. 更改用作主显示器的显示,注销 Windows,然后在重新登录后重新测试应用程序。 这在查找使用硬编码大小/维度的代码时特别有用。

常见陷阱 (Win32)

不使用WM_DPICHANGED中提供的建议矩形

当 Windows 向应用程序窗口发送 WM_DPICHANGED 消息时,此消息包含一个建议的矩形,用于调整窗口的大小。 应用程序使用此矩形调整自身大小至关重要,如下所示:

  1. 在显示之间拖动时,确保鼠标光标将保持窗口的相同相对位置
  2. 防止应用程序窗口进入递归 dpi 更改周期,其中一个 DPI 更改触发后续 DPI 更改,这会触发另一个 DPI 更改。

如果你有应用程序特定的要求,阻止你使用 Windows 在WM_DPICHANGED消息中提供的建议矩形,请参阅 WM_GETDPISCALEDSIZE。 此消息可用于在发生 DPI 更改后为 Windows 提供所需的大小,同时仍避免上述问题。

缺少有关虚拟化 的文档

当 HWND 或进程作为 DPI 不知道或系统 DPI 感知运行时,它可以由 Windows 拉伸。 发生这种情况时,Windows 会将 DPI 敏感信息从某些 API 缩放并转换为调用线程的坐标空间。 例如,如果 DPI 不知道线程在高 DPI 显示器上运行时查询屏幕大小,Windows 将虚拟化提供给应用程序的答案,就像屏幕在 96 DPI 单位中一样。 或者,当系统 DPI 感知线程与当前用户会话启动时使用的显示器交互时,如果 HWND 以原始 DPI 比例系数运行,Windows 会将一些 API 调用缩放到 HWND 将使用的坐标空间。

将桌面应用程序更新为 DPI 正确缩放时,很难知道哪些 API 调用可以根据线程上下文返回虚拟化值;Microsoft目前没有充分记录此信息。 请注意,如果从 DPI-unaware 或 system-DPI 感知线程上下文调用任何系统 API,则可能会虚拟化返回值。 因此,请确保线程在与屏幕或单个窗互时预期的 DPI 上下文中运行。 使用 SetThreadDpiAwarenessContext临时更改线程的 DPI 上下文时,请务必在完成作后还原旧上下文,以避免在应用程序中其他位置造成错误行为。

许多 Windows API 没有 DPI 上下文

许多旧版 Windows API 不包含 DPI 或 HWND 上下文作为其接口的一部分。 因此,开发人员通常需要执行额外的工作来处理任何 DPI 敏感信息(例如大小、点或图标)的缩放。 例如,使用 LoadIcon 的开发人员必须位图拉伸加载的图标或使用备用 API 为适当的 DPI 加载大小正确的图标,例如 LoadImage

强制重置进程范围的 DPI 感知

通常,进程初始化后无法更改进程的 DPI 感知模式。 但是,如果你尝试打破窗口树中所有 HWND 具有相同 DPI 感知模式的要求,Windows 可能会强行更改进程的 DPI 感知模式。 在所有版本的 Windows 上,从 Windows 10 1703 开始,HWND 树中不可能在不同的 DPI 感知模式下运行不同的 HWND。 如果尝试创建中断此规则的子父关系,则可以重置整个过程的 DPI 感知。 这可以通过以下方法触发:

  1. 在父窗口中传递的 CreateWindow 调用与调用线程不同 DPI 感知模式。
  2. 一个 SetParent 调用,其中两个窗口与不同的 DPI 感知模式相关联。

下表显示了如果尝试违反此规则会发生什么情况:

操作 Windows 8.1 Windows 10 (1607 及更早版本) Windows 10 (1703 及更高版本)
CreateWindow (In-Proc) N/A 子继承(混合模式) 子继承(混合模式)
CreateWindow (Cross-Proc) 强制重置(调用方的进程) 子继承(混合模式) 强制重置(调用方的进程)
SetParent (In-Proc) N/A 强制重置(当前进程) 失败(ERROR_INVALID_STATE)
SetParent (Cross-Proc) 强制重置(子窗口的进程) 强制重置(子窗口的进程) 强制重置(子窗口的进程)

高 DPI API 参考

Mixed-Mode DPI 缩放和 DPI 感知 API。