基于传统技术开发的 Windows 桌面应用,在高分辨率的显示设备上表现得“惨不忍睹”。随着高分辨率显示设备的普及,所有桌面应用程序的开发人员,都需要关注自己的软件在不同的 DPI 上的表现。
在 Windows 2000 之前,大部分大部分开发人员对显示器分辨率的关注点是如何让自己的程序在低分辨率的显示器上表现正常,因为过低的分辨率会导致窗口界面显示不完整。随着垂直分辨率低于 768 的显示设备逐步被淘汰,为 Windows XP 和 Windows 7 开发软件的程序员在很长一段时间都不需要考虑显示器的分辨率问题。但是近几年,高分辨率显示器开始迅速普及,Windows 8 或 Windows 10 的桌面程序需要再次应对高分辨率显示设备的挑战。
为了让应用程序在不同 DPI (Dots-Per-Inchs)显示设备上都表现正常,需要感知显示设备的分辨率变化。随着 Windows 的发展,对 DPI 感知也经历了一系列技术更新。从 DPI 无感知,到 Windows 8.1 开始支持 Per-Monitor,再到 Windows 10 开始支持的 Per-Monitor V2,应用程序感知 DPI 变化,并动态调整窗口显示的方法也越来越简单。
传统的 Windows 总是以 96 DPI 显示窗口系统,此时系统的显示缩放比例就是 100%。当用户调整显示缩放比例的时候,实际上调整得是显示器的分辨率(DPI),比如缩放比例 125% 对应的是 120 DPI,150% 缩放比例对应的是 144 DPI,200% 对应的 DPI 是 192。对 DPI 无感知的应用程序,在高 DPI 的情况下会显示一个非常小的窗口,有些情况下会小到看不清楚窗口内容。Windows 系统在高 DPI 的时候,会提示优化应用程序的显示效果,对 DPI 无感知的程序来说,这种“优化”就是用拉伸的方式放大窗口内容。但是这种放大是基于光栅位图的缩放,不是基于矢量技术的缩放,通常会导致窗口显示模糊,尤其是文字的边缘轮廓模糊,人眼看起来非常不舒服。
从 Windows 8.1 开始,操作系统为应用程序增加了一种感知系统 DPI 变化的能力,就是 Per-Monitor。当显示设备的 DPI 发生变化的时候,对于使用了 Per-Monitor 技术的应用程序,系统不再做窗口显示的拉伸放大,而是向程序的顶层窗口发送 WM _ DPICHANGED 消息,让应用程序根据变化调整自己。
Per-Monitor 技术的限制性主要是开发人员的应用不方便,顶层窗口在收到 WM _ DPICHANGED 消息的时候,不仅要负责计算所有的子窗口的位置,还需要在窗口创建时的 WM NCCREATE 消息处理中调用 `EnableNonClientDpiScaling` 这个 API,让系统帮忙处理非客户去的正确缩放。
从 Windows 10 1703 开始,操作系统开始支持 Per-Monitor V2 级别的感知,它比 Per-Monitor 具有更多的感知模式,比如在不同显示分辨率的两个显示器之间拖动窗口的时候,也能收到 WM _ DPICHANGED 消息。另外,Per-Monitor V2 不仅向顶层窗口发送 WM _ DPICHANGED 消息,还向所有的子窗口发送 WM _ DPICHANGED 消息,这就大大减少了主窗口控件调整的复杂度。除此之外,Per-Monitor V2 还自动处理非客户去的正确缩放,对公用对话框(比如文件选择对话框,颜色对话框)也做了正确的缩放处理。
让桌面应用程序支持 DPI 变化有两种方法,一种是采用编程方式在程序初始化的时候调用某个 API 告知操作系统本程序的 DPI 感知能力,另一种是使用程序清单文件(manifest),本节介绍第一种方法。有这种功能的 API 有两个,功能上是等效的。第一个是 `SetProcessDpiAwareness` ,通过 `value` 参数通知操作系统本程序的 DPI 感知级别。使用这个 API 需要包含 shellscalingapi.h 头文件,并导入 Shcore.lib 库,其原型如下:
HRESULT SetProcessDpiAwareness(PROCESS_DPI_AWARENESS value);
`PROCESS_DPI_AWARENESS` 有三个值可选:
typedef enum PROCESS_DPI_AWARENESS {
PROCESS_DPI_UNAWARE,
PROCESS_SYSTEM_DPI_AWARE,
PROCESS_PER_MONITOR_DPI_AWARE
} ;
`PROCESS_DPI_UNAWARE` 表示程序不感知 DPI 变化,效果就是在高 DPI 的显示设备上显示一个非常小的窗口。`PROCESS_SYSTEM_DPI_AWARE` 表示让系统调整在高 DPI 时候的显示,通常就是窗口拉伸放大,会导致窗口内容或文字边缘模糊。`PROCESS_PER_MONITOR_DPI_AWARE` 表示应用程序自己感知 DPI 变化,不需要系统拉伸放大窗口,但是需要接收并处理 WM _ DPICHANGED 消息。
另一个 API 是 `SetProcessDpiAwarenessContext`,用于设置应用程序感知 DPI 变化的上下文,其原型是:
BOOL SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT value);
`DPI_AWARENESS_CONTEXT` 有 5 个值可选,分别是:
DPI_AWARENESS_CONTEXT_UNAWARE
DPI_AWARENESS_CONTEXT_SYSTEM_AWARE
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED
`DPI_AWARENESS_CONTEXT_UNAWARE` 表示程序不感知 DPI 变化,`DPI_AWARENESS_CONTEXT_SYSTEM_AWARE`表示让系统拉伸放大窗口(在高 DPI 的情况下),`DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE` 表示支持 Per-Monitor 感知,`DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2`表示支持 Per-Monitor V2 感知,`DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED` 在 Windows 10 1809 版本引入,效果和 `DPI_AWARENESS_CONTEXT_UNAWARE` 类似,但是系统会利用 GDI 技术的提升改善一下文字在拉伸后的显示效果,请阅读本文“参考文献”部分的链接了解更多 GDI Scaling 技术。
相对于调用 API 的方式,微软更推荐使用程序清单文件(Manifest File)的方式让应用程序支持 DPI 感知。在程序清单文件中有两种标签( tag )可以设置程序的 DPI 感知,一种是在 Windows 10 之前引入的 <dpiAware>,另一种是在 Windows 10 之后引入的 <dpiAwareness>。<dpiAware> 标签只支持系统级别 DPI 感知,就是通过拉伸放大窗口的方式适应高 DPI,比如:
<dpiAware>false</dpiAware>
表示程序不支持 DPI 感知,而
<dpiAware>true</dpiAware>
表示支持系统级别 DPI 感知。
随着 Windows 10 引入的 <dpiAwareness> 标签具有更多的感知模式:
<dpiAwareness>unaware</dpiAwareness>
<dpiAwareness>system</dpiAwareness>
<dpiAwareness>PerMonitor</dpiAwareness>
<dpiAwareness>PerMonitorV2</dpiAwareness>
使用清单文件的好处就是可以在清单文件中同时包含这两种标签,因为在旧版本的 Windows 系统上,会忽略不支持的 <dpiAwareness> 标签,而在支持 <dpiAwareness> 标签的新系统上,又会忽略旧的 <dpiAware> 标签,简直完美。如果使用 API 编程方式,就需要判断一下操作系统的版本号,然后设置相应的 DPI 感知能力,显然比使用清单文件要麻烦的多。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
支持 Per Monitor 和 Per Monitor V2 感知级别的程序,需要响应 系统发送的 WM _ DPICHANGED 消息,并在消息中处理相关的显示调整。WM _ DPICHANGED 消息的定义如下:
#define WM_DPICHANGED 0x02E0
wParam 分两部分,高 16 位是窗口新的 X 轴方向 DPI,低 16 位是窗口新的 Y 轴方向 DPI
lParam 是一个 `RECT *` 类型的指针,内容是系统建议在 DPI 调整后的窗口大小和位置,应用程序可根据这个参数调整窗口的大小和位置,当然也可以不接受这个建议,自己计算窗口的大小和位置。
需要注意的一点,就是 Per Monitor 级别 DPI 感知的应用程序,需要在窗口创建时调用 `EnableNonClientDpiScaling` 通知系统调整非客户去的显示,这个 API 的原型如下:
BOOL EnableNonClientDpiScaling(HWND hwnd);
一般建议放在 WM NCCREATE 消息中调用这个 API。
对于 Per Monitor V2 级别 DPI 感知的应用程序,不需要做这个事情,不要画蛇添足。
当应用程序启用 DPI 感知后,一些 Windows API 的行为会发生一些变化,有些 API 会根据当前进程的感知上下文返回对应调整后的结果,但是也有一些 API 不会这么做。比如 `GetSystemMetrics()` API ,总是按照 DPI=96 的情况返回相关的数值,比如图标大小,窗口边框宽度。如果需要根据 DPI 变化调整了显示缩放比例之后的数值,需要使用对应的 `GetSystemMetricsForDpi()`。属于此类情况的常见 API 有:
单个 DPI 版本 | Per-Monitor 版本 |
---|---|
GetSystemMetrics | GetSystemMetricsForDpi |
AdjustWindowRectEx | AdjustWindowRectExForDpi |
SystemParametersInfo | SystemParametersInfoForDpi |
GetDpiForMonitor | GetDpiForWindow |
除了这些 API 的行为差异之外,如果你的代码中使用了数字作为硬编码的情况,也需要调整。比如创建窗口时指定窗口的大小:
// 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 = 96 时的设计大小
#define INITIALX_96DPI 50
#define INITIALY_96DPI 50
#define INITIALWIDTH_96DPI 100
#define INITIALHEIGHT_96DPI 50
int iDpi = GetDpiForWindow(hWnd);
int dpiScaledX = MulDiv(INITIALX_96DPI, iDpi, 96);
int dpiScaledY = MulDiv(INITIALY_96DPI, iDpi, 96);
int dpiScaledWidth = MulDiv(INITIALWIDTH_96DPI, iDpi, 96);
int dpiScaledHeight = MulDiv(INITIALHEIGHT_96DPI, iDpi, 96);
HWND hWndChild = CreateWindow(L"BUTTON", L"Click Me", WS_CHILD|WS_VISIBLE|BS_PUSHBUTTON,
dpiScaledX, dpiScaledY, dpiScaledWidth, dpiScaledHeight, hWnd, (HMENU)NULL, NULL, NULL);
还有一种情况就是对于一些不支持 DPI 感知,也没有对应的 DPI 感知版本的陈旧 API,比如 `LoadIcon`、`LoadImage`,此时就需要根据实际形况做处理,使用其他手段将得到的内容做适当的放大或缩小处理。
有些 API 在进程启用了 DPI 感知上下文后,会返回根据缩放级别调整后的虚拟化结果。关于 API 的这些差异,目前只有少数总结出来的资料,缺少系统化的文档。对于一些重要的操作,如果不确定 DPI 感知是否会产生不良的影响,可以考虑使用 `SetThreadDpiAwarenessContext` ,暂时停止当前线程的 DPI 感知,在执行完这些重要的操作后再恢复当前线程的 DPI 感知。
让系统感知 DPI 变化并不难,难得是在 DPI 变化的时候调整窗口的显示。但是如果你使用的界面库支持自动调整,那就非常容易了。SOUI2 的最新版本就支持根据 DPI 动态调整窗口的显示,只需要做很少的操作就可以实现在不同的 DPI 设备上自适应窗口显示。SOUI2 的 Demo 程序中,有个名为 MultiLangs 的例子,就演示了 SOUI2 的自适应窗口显示能力。根据这个例子,再补充上 Per Monitor 级别设置和 WM _ DPICHANGED 消息响应,就可以轻松地让使用 SOUI 做界面的应用程序具有高 DPI 自适应的能力。
SOUI2 库中资源布置默认的位置和大小单位都是像素,但是最新的 SOUI2 支持新的单位:dp。对于需要根据 DPI 调整位置和大小的控件,需要使用 dp 单位,比如:size="80dp, 26dp"。
对于图片资源来说,可以根据每种分辨率设置一种图片,SOUI 会根据系统 DPI 选择适当的图片,MultiLangs 例子也演示了这种用法。不过,对于大部分使用 imgframe 九宫格类型贴图的图片资源来说,基本上不用考虑为不同的分辨率准备不同的图片资源文件,因为对于大多数系统来说,最大 200% 的调整,也就是需要图片大小放大两倍, SOUI2 使用的渲染系统应对这种级别的拉伸放大绰绰有余。
SOUI2 对 `WM _ DPICHANGED` 消息已经做了对应的分派宏,可以在 `BEGIN_MSG_MAP_EX` 中直接使用:
void OnDpiChanged(WORD dpi, const RECT* desRect);
BEGIN_MSG_MAP_EX(CMainDlg)
MSG_WM_DPICHANGED(OnDpiChanged)
END_MSG_MAP()
在这个消息响应中,要给全部 SOUI 的子窗口发送一个 `UM_SETSCALE` 消息,通知所有子窗口缩放级别发生变化,同时调整窗口的大小。
void CMainDlg::OnDpiChanged(WORD dpi, const RECT* desRect)
{
int nScale = ScaleFromSystemDpi(dpi); //根据 DPI 返回对应的缩放级别
SDispatchMessage(UM_SETSCALE, nScale, 0);
//接受系统的建议位置和大小
SetWindowPos(NULL, desRect->left, desRect->top, desRect->right - desRect->left,
desRect->bottom - desRect->top, SWP_NOZORDER | SWP_NOACTIVATE);
}
`ScaleFromSystemDpi` 的作用是根据 DPI 返回对应的缩放比例,一般 96 对应的是 100%,120 对应的是 125%,144 对应的是 150%,192 对应的是 200%。
如果不想手工处理 `WM _ DPICHANGED` 消息,还可以考虑使用 SOUI 提供的 SDpiHandler 嵌入类,直接将 `WM _ DPICHANGED` 消息的默认处理加入窗口的消息分派表中。 SDpiHandler 类的使用非常简单,首先在目标窗口的继承关系中添加 SDpiHandler,例如:
class CMainDlg : public SHostWnd
.... , // 其他派生关系
public SDpiHandler<CMainDlg>
然后在消息分派表中插入这个嵌入类的消息分派:
BEGIN_MSG_MAP_EX(CMainDlg)
...
CHAIN_MSG_MAP(SDpiHandler<CMainDlg>)
...
END_MSG_MAP()
尽管`WM _ DPICHANGED` 消息会告知应用程序当前的 DPI 变化,但是当程序启动的时候,是不会收到这个消息的,所以需要在程序启动的时候获取系统 DPI,初始化 SOUI 窗口系统的缩放级别。Windows 10 1607 以后的 SDK 提供了 `GetDpiForSystem` API,可以直接获取系统的 DPI,这个 API 的原型是:
UINT GetDpiForSystem()
对于较早的 Windows 版本,可以用这个传统的方法获取屏幕显示设备的 DPI:
UINT GetSystemDeviceDpi()
{
HDC hDCScreen = ::GetDC(NULL);
UINT dpiY = ::GetDeviceCaps(hDCScreen, LOGPIXELSY);
::ReleaseDC(NULL, hDCScreen);
return dpiY;
}
尽管 SOUI 使用很方便,但是还是有一些“坑”需要填上,比如字体,对于全局的字体,指定字号时可以不适用 dp 单位,比如在资源定义中定义的全局字体:
```
<font face="微软雅黑" size="14"/>
```
但是如果是在控件中使用 font 属性指定的字体,则需要使用 dp 单位,否则控件的字体将始终使用指定的字号,不会随着 DPI 的缩放级别变化,比如这样使用:
```
font="face:微软雅黑,size:14dp"
```
还有就是对于菜单的使用,无论是 `SMenu` 还是 `SMenuEx`类,`TrackPopupMenu()` 函数的最后一个参数是缩放级别,需要使用 `GetScale()` 获取当前窗口的缩放级别,然后传递给菜单。如果不传递缩放级别参数的话,系统总是使用默认值 100,当时在这个问题上浪费了不少时间,希望你可以避免。