如果想要开发 Windows 图形程序,必须了解两个相关概念:

每英寸像素点(DPI)。

设备独立像素(DPIs)。

让我们从 DPI 开始。在开始前需要了解一下排版相关的知识内容。字体的大小测量是以点(points)为单位计算的。

1 点 = 1/72 英寸

即:

1 pt = 1/72 inch

在历史上,点的定义有各种各样的版本,这里的点指的是桌面排版通用的单位。

“点” 是目前最常用的排版单位,在中文排版中也称该单位为“磅”,它与英寸及毫米的折算关系是 1pt = 1/72 inch = 0.3528mm 。该单位经常被误认为等同于象素(pixel)单位,其实“点”并不是“像素”,它们之间的换算关系与屏幕或打印分辨率有关。仅在分辨率是72 dpi(dot per inch) 时,1 pt 才与 1 pixel 相同, 如果分辨率是 96 dpi,1 pt 等同与 1.333 (96/72)象素单位。

例如,一个12点的字体代表12/72=1/6英寸。显然这不意味着每个字符都是1/6英寸高,实际上一些字符可能会高过这个阈值。许多字体中字符 Å 会比一般的字符要高。为了正确显示文本,文本之间需要添加一些空隙,这些空隙被称为行距(leading)。

行距

当涉及到计算机显示字体的时候,上面的文本大小的计算是有问题的,因为像素并不总是一样大。像素的大小依赖于两个要素:显示分辨率和物理显示器的大小。因此,物理英寸的测量模式不太合适,因为物理英寸和像素之间没有固定的换算关系。相反的,字体使用逻辑测量单位。

一个72点字体被定义为一个逻辑英寸高,逻辑英寸被转换为像素。多年来,Windows 使用下面的转换关系:1英寸等于96像素。使用这个比例因子的情况下,一个72点的字体会被渲染为96像素高,一个12点字体被渲染为16像素高。

12点 = 12/72 逻辑英寸 = 1/6 逻辑英寸 = 96/6 像素 = 16 像素

因为实际的像素大小不同,在一个显示器正常的文本,在另一个显示显示可能会很小,并且每个的偏好也不一样,一些人更喜欢大的字体。基于这些原因,Windows 支持让用户改变 DPI 设置。例如,如果用户设置 DPI 为144,一个72点的字体将被显示为144像素高。标准的DPI设置有 100%(96DPI),125%(120DPI),150%(144DPI)。从 Win7 开始,用户可以自定义这些参数。

1. DWM缩放

如果一个程序没考虑 DPI 设置,在高 DPI 中系统中可能会有下列问题:

  • UI 被裁剪。

  • 错误的布局。

  • 像素化的位图和图标。

  • 不正确的鼠标坐标,可能影响到点击测试、拖拽操作等等。

为了确保旧的程序可以在高 DPI 设置中工作,DWM 实现了一个兼容方式,如果程序没有做高 DPI 匹配,DWM 将会按照 DPI 的设置按比例缩放 UI 控件。例如,144DPI 下,UI 被缩放 150%,包括文本、图形、控件和窗口大小。如果程序默认创建 500 * 500 的窗口,则 Windows 会显示 750 * 750 的窗口大小,窗口内部的控件也会等比缩放。

这种方式也仅能让程序可以工作而已,在高 DPI 情况下,程序会显得有些模糊,因为显示的窗口是缩放后的结果。

2. DPI 感知程序

为了避免 DWM 默认缩放的行为,一个程序可以标记自己为 DPI-aware。这就告诉DWM程序不需要自动按照 DPI 比例缩放。所有的新程序都应该被设计成支持 DPI-aware,因为它可以有效的提升高 DPI 中的 UI 外观。

一个程序通过应用程序清单(manifest)声明自己是 DPI-aware 程序。一个应用程序清单是一个 XML 文件,其中描述了一个 DLL 或者应用程序。清单文件通常会嵌入到可执行程序中,虽然它可以单独提供。

一个应用程序清单包含了 DLL 依赖、请求权限基本和程序设计的版本等信息。

使用下面的清单文件声明应用程序支持 DPI-aware。

<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3" >
  <asmv3:application>
    <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
      <dpiAware>true</dpiAware>
    </asmv3:windowsSettings>
  </asmv3:application>
</assembly>

代码只显示了清单中的一部分,Visual Studio 会自动为你程序生成清单文件。要想在程序清单中包含这一部分,请执行以下步骤:

在 Project 菜单中,点击 Property。

展开左侧面板的 Configureation Properties,站看 Manifest Tool,点击 Input and Output。

点击 Additional Manifest Files 文本框,输入清单名称,点击 OK 按钮。

如果程序已经标记为 DPI-aware,DWM 将不会缩放你的程序。现在你创建一个 500 * 500 的窗口,Windows 将原封不动的创建一个 500 * 500 像素的窗口,不管用户设置的 DPI 是多少。

3. GDI 和 DPI

GDI 绘图是用像素来测量的。如果你程序标记了 DPI-aware,你告诉 GDI 绘制一个200 * 100的矩形,结果将会在窗口显示一个 200 * 100 像素的矩形,但 GDI 字体大小是根据当前 DPI 设置相关的,换句话说,如果你创建一个72点的字体,这个字体在 96DPI 下是96像素,但是当 DPI 为144的时候字体将是144像素。下面是72点字体渲染在 144 DPI 下,使用 GDI 绘制:

字体

如果你程序是 DPI-aware,并且你是用 GDI 绘图,你需要缩放所有的绘制坐标以匹配 DPI。

4. Direct2D 和 DPI

Direct2D 会自动匹配当前 DPI 设置。在 Direct2D 中,坐标是以设备独立像素(DPIs)进行测量的。一个 DPI 被定义为 1⁄96 逻辑英寸。在 Direct2D 中,所有的操作都是在 DIPs 中指定的,然后缩放到当前 DPI 设置。

DPI setting DPI size
96 1 pixel
120 1.25 pixels
144 1.5 pixels

例如,如果用户的 DPI 设置为 144DPI,你告诉 Direct2D 绘制一个 200 * 100 的矩形,这个矩形将会绘制一个 300 * 150 物理像素的矩形。另外,DirectWrite 字体大小一样在 DPIs 中计算,而不是点。创建一个12点的字体,指定 16DPIs(12 points = 1⁄6 logical inch = 96⁄6 DIPs)。当文本在屏幕上绘制的时候,Direct2D 将转换 DIPs 到物理坐标。这个好处非常明显,都是不管当前 DPI 设置是多少,对于文本和绘图工作的测量单位都是一致的。

值得注意的是,在窗口中获取的鼠标坐标是物理像素的,不是 DPIs。例如,你在进程收到一个 WM_LBUTTONDOWN 坐标,鼠标按下的位置是物理坐标的,如果在这个位置绘制一个点,必须要将该坐标转换为 DPIs。

5. 物理坐标转换为 DIPs

从物理坐标转换为 DPIs,需要下面的公式:

DIPs = pixels / (DPI / 96.0)

获取 DPI 设置,需要调用 ID2D1Factory::GetDesktopDpi 方法。DPI 会返回两个浮点数。一个代表x坐标,一个代表y坐标,理论上二者可能不同。

float g_DPIScaleX = 1.0f;
float g_DPIScaleY = 1.0f;

void InitializeDPIScale(ID2D1Factory *pFactory)
{
    FLOAT dpiX, dpiY;

    pFactory->GetDesktopDpi(&dpiX, &dpiY);

    g_DPIScaleX = dpiX/96.0f;
    g_DPIScaleY = dpiY/96.0f;
}

template <typename T>
float PixelsToDipsX(T x)
{
    return static_cast<float>(x) / g_DPIScaleX;
}

template <typename T>
float PixelsToDipsY(T y)
{
    return static_cast<float>(y) / g_DPIScaleY;
}

如果你没有使用 Direct2D,可以使用下面的方法获取 DPI:

void InitializeDPIScale(HWND hwnd)
{
    HDC hdc = GetDC(hwnd);
    g_DPIScaleX = GetDeviceCaps(hdc, LOGPIXELSX) / 96.0f;
    g_DPIScaleY = GetDeviceCaps(hdc, LOGPIXELSY) / 96.0f;
    ReleaseDC(hwnd, hdc);
}

6. 调整渲染目标大小

如果窗口改变大小,你必须同时改变渲染目标的大小。在大多数的情况下,你需要更新布局并且需要重绘。

void MainWindow::Resize()
{
    if (pRenderTarget != NULL)
    {
        RECT rc;
        GetClientRect(m_hwnd, &rc);

        D2D1_SIZE_U size = D2D1::SizeU(rc.right, rc.bottom);

        pRenderTarget->Resize(size);
        CalculateLayout();
        InvalidateRect(m_hwnd, NULL, FALSE);
    }
}

GetClientRect 函数获取新的客户区的大小,是物理像素,不是 DPIs。ID2D1HwndRenderTarget::Resize 函数用来更新渲染目标的大小,单位也是像素值。使用 InvalidateRect 函数强制重绘窗口客户区。

当窗口大小调整的时候,你需要重新计算你绘制的对象坐标,例如,以上面绘制圆形的程序为例,你必须更新绘制的半径和中心点的坐标:

void MainWindow::CalculateLayout()
{
    if (pRenderTarget != NULL)
    {
        D2D1_SIZE_F size = pRenderTarget->GetSize();
        const float x = size.width / 2;
        const float y = size.height / 2;
        const float radius = min(x, y);
        ellipse = D2D1::Ellipse(D2D1::Point2F(x, y), radius, radius);
    }
}

ID2D1RenderTarget::GetSize 方法返回渲染目标的大小,单位是 DPIs,而非像素。这正好是计算需要的坐标单位,如果有必要,你可以使用 ID2D1RenderTarget::GetPixelSize 获取物理像素大小。对于一个 HWND 渲染目标,这个返回值和 GetClientRect 匹配。不过要记住的是绘制需要的是 DPIs 坐标,而不是像素。