Windows 上的高 DPI 桌面应用程序开发
此内容面向希望更新桌面应用程序以动态处理显示缩放因子(每英寸点数或 DPI)更改的开发人员,使他们的应用程序在呈现的任何显示器上都能清晰显示。
首先,如果要从头开始创建新的 Windows 应用,强烈建议创建通用 Windows 平台 (UWP) 应用程序。 UWP 应用程序会自动且动态地针对它们在上面运行的每个显示器进行缩放。
使用较旧 Windows 编程技术(原始 Win32 编程、Windows 窗体、Windows Presentation Framework (WPF) 等)的桌面应用程序 如果没有额外的开发人员工作,就无法自动处理 DPI 缩放。 如果没有此类工作,在许多常见的使用场景中,应用程序将显示模糊或大小不正确。 本文档提供了有关更新桌面应用程序以正确呈现时涉及的内容的上下文和信息。
显示缩放因子 & DPI
随着显示技术的进步,显示面板制造商在其面板上的每个物理空间单元中都封装了越来越多的像素。 这导致新式显示面板的每英寸点数 (DPI) 远高于历史上的点数。 过去,大多数显示器每线性英寸的物理空间有 96 个像素 (96 DPI):2017 年,具有近 300 DPI 或更高 DPI 的显示器随处可见。
大多数旧版桌面 UI 框架都有内置的假设,即显示 DPI 在进程的生存期内不会改变。 这种假设不再成立,在应用程序进程的整个生存期内,显示 DPI 通常会更改多次。 显示缩放因子/DPI 更改的一些常见场景如下:
- 多监视器设置,其中每个显示器具有不同的缩放因子,应用程序从一个显示器移动到另一个显示器(如 4K 和 1080p 显示器)
- 将高 DPI 笔记本电脑与低 DPI 外部显示器连接和分离(反之亦然)
- 通过远程桌面从高 DPI 笔记本电脑/平板电脑连接到低 DPI 设备(反之亦然)
- 在应用程序运行时更改显示缩放因子设置
在这些场景中,UWP 应用程序会自动根据新 DPI 自行重新绘制。 默认情况下,如果没有额外的开发人员工作,桌面应用程序就不会这样做。 不执行此额外工作来响应 DPI 更改的桌面应用程序可能会向用户显示模糊或大小不正确。
DPI 感知模式
桌面应用程序必须告知 Windows 它们是否支持 DPI 缩放。 默认情况下,系统将桌面应用程序 DPI 视为无法感知,并对其窗口进行位图拉伸。 通过设置以下可用的 DPI 感知模式之一,应用程序可以明确告知 Windows 它们希望如何处理 DPI 缩放:
无法感知 DPI
无法感知 DPI 的应用程序以固定 DPI 值 96 (100%) 进行呈现。 每当这些应用程序在显示比例大于 96 DPI 的屏幕上运行时,Windows 会将应用程序位图拉伸到预期的物理大小。 这会导致应用程序显示模糊。
系统 DPI 感知
可感知系统 DPI 的桌面应用程序通常在用户登录时接收主连接监视器的 DPI。 在初始化期间,它们使用该系统 DPI 值来适当地设置 UI 布局(调整控件大小、选择字号、加载资产等)。 因此,在以单个 DPI 呈现的显示器上,可感知系统 DPI 的应用程序不会由 Windows 进行 DPI 缩放(位图拉伸)。 当应用程序移动到具有不同缩放因子的显示器时,或者如果显示比例因子发生其他更改,Windows 将对应用程序的窗口进行位图缩放,使其显示模糊。 实际上,可感知系统 DPI 的桌面应用程序仅以单个显示缩放因子清晰呈现,每当 DPI 发生更改时,这些应用程序都会变得模糊。
每显示器和每显示器 (V2) DPI 感知
建议将桌面应用程序更新为使用每显示器 DPI 感知模式,以便它们能够在 DPI 发生更改时立即正确呈现。 当应用程序向 Windows 报告它想要在这种模式下运行时,Windows 不会对应用程序进行位图拉伸,而是将 WM_DPICHANGED 发送到应用程序窗口。 然后,应用程序全权负责根据新 DPI 调整自身大小。 桌面应用程序使用的大多数 UI 框架(Windows 常用控件 (comctl32)、Windows 窗体、Windows Presentation Framework 等) 不支持自动 DPI 缩放,要求开发人员调整窗口本身的大小并重新定位其内容。
每显示器感知有两个版本,应用程序可以将自己注册为:版本 1 和版本 2 (PMv2)。 将进程注册为在 PMv2 感知模式下运行会导致:
- 当 DPI 发生更改时通知应用程序(顶级 HWND 和子 HWND)
- 应用程序看到每个显示器的原始像素
- 应用程序永远不会由 Windows 进行位图缩放
- 自动非工作区(窗口描述文字、滚动条等)由 Windows 执行 DPI 缩放
- Win32 对话框(从 CreateDialog)由 Windows 自动执行 DPI 缩放
- 常用控件(复选框、按钮背景等)中的主题绘制位图资产以适当的 DPI 缩放因子自动呈现
在每显示器 v2 感知模式下运行时,应用程序在 DPI 发生更改时会收到通知。 应用程序不根据新 DPI 调整自身大小,则应用程序 UI 将显示太小或太大(具体取决于上一个和新 DPI 值的差异)。
注意
每显示器 V1 (PMv1) 感知非常有限。 建议应用程序使用 PMv2。
下表显示了应用程序在不同场景下的呈现方式:
DPI 感知模式 | 引入的 Windows 版本 | 应用程序的 DPI 视图 | DPI 更改时的行为 |
---|---|---|---|
无法感知 | 不可用 | 所有显示器均为 96 DPI | 位图拉伸(模糊) |
系统 | Vista | 所有显示器都具有相同的 DPI(启动当前用户会话时主显示器的 DPI) | 位图拉伸(模糊) |
每显示器 | 8.1 | 应用程序窗口主要位于的显示器的 DPI |
|
每显示器 V2 | Windows 10 创意者更新 (1703) | 应用程序窗口主要位于的显示器的 DPI |
自动 DPI 缩放:
|
每显示器 (V1) DPI 感知
Windows 8.1 引入了每显示器 V1 DPI 感知模式 (PMv1)。 此 DPI 感知模式非常有限,仅提供下面列出的功能。 建议桌面应用程序使用 Windows 10 1703 或更高版本支持的每显示器 v2 感知模式。
对每显示器感知的初始支持仅对应用程序提供以下内容:
- 通知顶级 HWND DPI 更改,并提供新的建议大小
- Windows 不会对应用程序 UI 进行位图拉伸
- 应用程序以物理像素看到所有显示器(请参阅虚拟化)
在 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) |
|
1703 | 应用程序 | GitHub 示例 |
Windows 窗体 | 某些控件的自动每显示器 DPI 缩放受限 | 1703 | UI 框架 | Windows 窗体中的高 DPI 支持 |
Windows Presentation Foundation (WPF) | 本机 WPF 应用程序将对其他框架中托管的 WPF 进行 DPI 缩放,而 WPF 中托管的其他框架不会自动缩放 | 1607 | UI 框架 | GitHub 示例 |
GDI | 无 | 不可用 | 应用程序 | 请参阅 GDI 高 DPI 缩放 |
GDI+ | 无 | 不可用 | 应用程序 | 请参阅 GDI 高 DPI 缩放 |
MFC | 无 | 不可用 | 应用程序 | 不可用 |
更新现有应用程序
若要更新现有桌面应用程序以正确处理 DPI 缩放,需要对其进行更新,以便至少更新 UI 的重要部分以响应 DPI 更改。
大多数桌面应用程序在系统 DPI 感知模式下运行。 系统 DPI 感知应用程序通常缩放到主显示器的 DPI(启动 Windows 会话时系统托盘所在的显示器)。 当 DPI 更改时,Windows 将对这些应用程序的 UI 进行位图拉伸,这通常会导致它们变得模糊。 当更新系统 DPI 感知应用程序以变为每显示器 DPI 感知时,需要更新处理 UI 布局的代码,以便不仅在应用程序初始化期间执行,而且每当收到 DPI 更改通知(在 Win32 的情况下为 WM_DPICHANGED)时也执行。 这通常涉及重新访问代码中的任何假设,即 UI 只需缩放一次。
此外,对于 Win32 编程,许多 Win32 API 没有任何 DPI 或显示上下文,因此它们仅返回相对于系统 DPI 的值。 对代码运行 grep 指令以查找其中一些 API 并将其替换为 DPI 感知变体,这非常有用。 具有 DPI 感知变体的一些常见 API 包括:
单个 DPI 版本 | 每显示器版本 |
---|---|
GetSystemMetrics | GetSystemMetricsForDpi |
AdjustWindowRectEx | AdjustWindowRectExForDpi |
SystemParametersInfo | SystemParametersInfoForDpi |
GetDpiForMonitor | GetDpiForWindow |
最好在代码库中搜索假定常数 DPI 的硬编码大小,将其替换为正确考虑 DPI 缩放的代码。 下面是包含以下所有建议的示例:
示例:
下面的示例演示了创建子 HWND 的简化 Win32 案例。 对 CreateWindow 的调用假设应用程序以 96 DPI(USER_DEFAULT_SCREEN_DPI
常数)运行,并且按钮的大小和位置在较高 DPI 下都不正确:
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);
}
以下更新的代码显示:
- 窗口创建代码 DPI,按其父窗口的 DPI 缩放子 HWND 的位置和大小
- 通过重新定位和调整子 HWND 的大小来响应 DPI 更改
- 删除硬编码大小并将其替换为响应 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 感知应用程序时,要遵循的一些常见步骤如下:
- 使用应用程序清单(或其他方法,具体取决于使用的 UI 框架)将进程标记为每显示器 DPI 感知 (V2)。
- 使 UI 布局逻辑可重用,并将其移出应用程序初始化代码,以便可以在发生 DPI 更改时重复使用(在 Windows (Win32) 编程的情况下为 WM_DPICHANGED)。
- 使任何假定 DPI 敏感数据(DPI/字体/大小等)永远不需要更新的代码无效。 在进程初始化时缓存字号和 DPI 值是一种非常常见的做法。 更新应用程序以变为每显示器 DPI 感知时,每当遇到新 DPI 时,都必须重新评估 DPI 敏感数据。
- 当 DPI 发生更改时,重新加载(或重新光栅化)新 DPI 的任何位图资产,或者(可选)将当前加载的资产的位图拉伸到正确大小。
- 对于非每显示器 DPI 感知的 API 执行 grep 指令,并将其替换为每显示器 DPI 感知 API(如果适用)。 示例:将 GetSystemMetrics 替换为 GetSystemMetricsForDpi。
- 在多显示器/多 DPI 系统上测试应用程序。
- 对于应用程序中无法更新为正确 DPI 缩放的任何顶级窗口,请使用混合模式 DPI 缩放(如下所述),以允许系统对这些顶级窗口进行位图拉伸。
混合模式 DPI 缩放(子进程 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 感知的详细信息,请参阅混合模式 DPI 缩放和 DPI 感知 API。
测试更改
在更新应用程序以成为每显示器 DPI 感知后,请务必验证应用程序是否正确响应混合 DPI 环境中的 DPI 更改。 要测试的一些细节包括:
- 在不同 DPI 值的显示器之间来回移动应用程序窗口
- 在不同 DPI 值的显示器上启动应用程序
- 在应用程序运行时更改显示器的缩放因子
- 更改用作主显示器的显示器,退出登录 Windows,然后在重新登录后重新测试应用程序。 这在查找使用硬编码大小/维度的代码时特别有用。
常见陷阱 (Win32)
不使用 WM_DPICHANGED 中提供的建议矩形
当 Windows 向应用程序窗口发送 WM_DPICHANGED 消息时,此消息包括一个建议的矩形,应使用它来调整窗口大小。 应用程序使用此矩形调整自身大小至关重要,因为这将:
- 确保在显示器之间拖动时,鼠标光标将保持在窗口上相同的相对位置
- 防止应用程序窗口进入递归 dpi 更改周期,其中一个 DPI 更改触发后续 DPI 更改,这会触发另一个 DPI 更改。
如果你有应用程序特定的要求,妨碍你使用 Windows 在 WM_DPICHANGED 消息中提供的建议矩形,请参阅 WM_GETDPISCALEDSIZE。 此消息可用于在 DPI 发生更改后为 Windows 提供你希望使用的所需大小,同时仍然可以避免上述问题。
缺少有关虚拟化的文档
当 HWND 或进程在“无法感知 DPI”或“感知系统 DPI”模式下运行时,它可以由 Windows 进行位图拉伸。 发生这种情况时,Windows 会将 DPI 敏感信息从某些 API 缩放并转换为调用线程的坐标空间。 例如,如果无法感知 DPI 的线程在高 DPI 显示器上运行时查询屏幕大小,Windows 将虚拟化提供给应用程序的答案,就好像屏幕以 96 DPI 为单位一样。 或者,当感知系统 DPI 的线程以与当前用户会话启动时使用的 DPI 不同的 DPI 与显示器交互时,Windows 会对一些 API 调用执行 DPI 缩放,缩放到 HWND 以其原始 DPI 缩放因子运行时将使用的坐标空间中。
当将桌面应用程序更新为 DPI 正确缩放时,很难知道哪些 API 调用可以根据线程上下文返回虚拟化值;Microsoft 目前没有充分记录此信息。 请注意,如果从无法感知 DPI 或感知系统 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 树中的不同 HWND 不可能以不同的 DPI 感知模式运行。 如果尝试创建违反此规则的父子关系,则可以重置整个过程的 DPI 感知。 这可以通过以下方式触发:
- CreateWindow 调用,其中传入的父窗口与调用线程具有不同的 DPI 感知模式。
- SetParent 调用,其中两个窗口与不同的 DPI 感知模式相关联。
下表显示了如果尝试违反此规则会发生什么情况:
操作 | Windows 8.1 | Windows 10(1607 及更早版本) | Windows 10(1703 及更高版本) |
---|---|---|---|
CreateWindow(进程内) | 不可用 | 子级继承(混合模式) | 子级继承(混合模式) |
CreateWindow(跨进程) | 强制重置(调用方的进程) | 子级继承(混合模式) | 强制重置(调用方的进程) |
SetParent(进程内) | 不可用 | 强制重置(当前进程) | 失败 (ERROR_INVALID_STATE) |
SetParent(跨进程) | 强制重置(子窗口的进程) | 强制重置(子窗口的进程) | 强制重置(子窗口的进程) |