MFC 界面自适应不同分辨率

MFC 对话框程序实现控件和字体随分辨率自动缩放的完整方案,附 DPI Awareness 配置说明

$1.4k 字/约 8 min👁— views

MFC 界面自适应不同分辨率

随着高分辨率显示器(HiDPI)的普及,Windows 桌面应用的 DPI 适配成为必须解决的问题。本文从历史背景讲起,详细介绍在 MFC 中实现 DPI 自适应的完整方案。

DPI 缩放历史

早期(Vista 之前)

Windows 使用固定的 96 DPI 设计基准。所有 UI 元素按像素硬编码,分辨率高了但物理尺寸小的屏幕上界面会变得很小。

Vista/7 的系统 DPI 缩放

Windows 引入了系统级 DPI 设置,系统会对整个程序进行位图拉伸(DPI 虚拟化),让老程序在高 DPI 下仍可用,但图像会模糊。

Windows 8.1:Per-Monitor DPI

引入 Per-Monitor DPI Awareness,允许应用程序在不同 DPI 的显示器上以不同比例渲染,但 API 支持有限。

Windows 10 1607:Per-Monitor V2

最完善的 DPI 支持:

  • 窗口移到不同 DPI 显示器时自动通知
  • 非客户区(标题栏、边框)自动缩放
  • 对话框支持 DPI 变更

现状(Windows 11)

系统默认推荐使用 PerMonitorV2,老程序通过兼容性层处理。

DPI 感知模式

Windows 定义了以下 DPI 感知模式:

模式 说明 清晰度
不感知(默认) 系统拉伸,模糊
System Aware 以主显示器 DPI 渲染
Per-Monitor 监听 DPI 变化,手动处理
Per-Monitor V2 Windows 处理更多,手动干预更少 最好

Manifest 配置

app.manifest(Visual Studio 项目属性 → 清单工具)中配置:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
  <assemblyIdentity
      version="1.0.0.0"
      processorArchitecture="*"
      name="MyMFCApp"
      type="win32"/>

  <application xmlns="urn:schemas-microsoft-com:asm.v3">
    <windowsSettings>
      <!-- Windows 10 1607+ 推荐 -->
      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">
        PerMonitorV2, PerMonitor
      </dpiAwareness>
      <!-- 兼容旧版 Windows 8.1 的设置 -->
      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
        True/PM
      </dpiAware>
    </windowsSettings>
  </application>
</assembly>

注意:PerMonitorV2, PerMonitor 中间的逗号表示回退,Windows 10 1607+ 使用 V2,旧版回退到 V1。

GetDpiForWindow API

核心 API(Windows 10 1607+):

// 获取当前窗口的 DPI
UINT dpi = GetDpiForWindow(m_hWnd);

// 基准 DPI(96 = 100% 缩放)
const UINT BASE_DPI = 96;

// 计算缩放比例
float scale = (float)dpi / BASE_DPI;
// scale = 1.0  → 100%(96 DPI)
// scale = 1.25 → 125%(120 DPI)
// scale = 1.5  → 150%(144 DPI)
// scale = 2.0  → 200%(192 DPI,常见 HiDPI)

旧版 Windows 的兼容方案:

UINT GetWindowDPI(HWND hwnd) {
    // 尝试 Windows 10 1607+ API
    typedef UINT(WINAPI* PFN_GetDpiForWindow)(HWND);
    static PFN_GetDpiForWindow s_pfnGetDpiForWindow = nullptr;
    static bool s_checked = false;
    
    if (!s_checked) {
        HMODULE hUser32 = GetModuleHandle(L"user32.dll");
        if (hUser32) {
            s_pfnGetDpiForWindow = (PFN_GetDpiForWindow)
                GetProcAddress(hUser32, "GetDpiForWindow");
        }
        s_checked = true;
    }
    
    if (s_pfnGetDpiForWindow) {
        return s_pfnGetDpiForWindow(hwnd);
    }
    
    // 回退:获取系统 DPI
    HDC hdc = GetDC(hwnd);
    UINT dpi = GetDeviceCaps(hdc, LOGPIXELSX);
    ReleaseDC(hwnd, hdc);
    return dpi;
}

字体缩放

字体大小需要根据 DPI 动态计算:

// 辅助函数:将逻辑尺寸转换为当前 DPI 下的像素
int ScaleForDpi(int value, UINT dpi) {
    return MulDiv(value, dpi, 96);
}

// 创建 DPI 感知的字体
CFont* CreateDpiAwareFont(HWND hwnd, int ptSize = 9, 
                           const wchar_t* faceName = L"Microsoft YaHei UI") {
    UINT dpi = GetDpiForWindow(hwnd);
    
    LOGFONT lf = {};
    lf.lfHeight = -MulDiv(ptSize, dpi, 72);  // pt 转逻辑单位
    lf.lfWeight = FW_NORMAL;
    lf.lfCharSet = DEFAULT_CHARSET;
    wcscpy_s(lf.lfFaceName, faceName);
    
    CFont* pFont = new CFont();
    pFont->CreateFontIndirect(&lf);
    return pFont;
}

// 在 WM_DPICHANGED 时更新字体
void CMyDialog::OnDpiChanged(UINT nDpiX, UINT nDpiY, LPRECT pNewRect) {
    // 重新创建字体
    if (m_pFont) {
        m_pFont->DeleteObject();
        delete m_pFont;
    }
    m_pFont = CreateDpiAwareFont(m_hWnd);
    
    // 应用到所有控件
    EnumChildWindows(m_hWnd, [](HWND hwndChild, LPARAM lParam) -> BOOL {
        CFont* pFont = (CFont*)lParam;
        ::SendMessage(hwndChild, WM_SETFONT, (WPARAM)pFont->m_hObject, TRUE);
        return TRUE;
    }, (LPARAM)m_pFont);
    
    // 移动窗口到建议位置
    if (pNewRect) {
        SetWindowPos(nullptr,
            pNewRect->left, pNewRect->top,
            pNewRect->right - pNewRect->left,
            pNewRect->bottom - pNewRect->top,
            SWP_NOZORDER | SWP_NOACTIVATE
        );
    }
}

控件和布局缩放

MFC 对话框的控件位置使用对话框单位(DLU),通常会随系统字体自动缩放。但如果用像素硬编码,就需要手动换算:

// 按 DPI 缩放控件位置和大小
void CMyDialog::ScaleControl(CWnd* pCtrl, UINT dpiOld, UINT dpiNew) {
    CRect rect;
    pCtrl->GetWindowRect(&rect);
    ScreenToClient(&rect);
    
    // 缩放
    rect.left   = MulDiv(rect.left,   dpiNew, dpiOld);
    rect.top    = MulDiv(rect.top,    dpiNew, dpiOld);
    rect.right  = MulDiv(rect.right,  dpiNew, dpiOld);
    rect.bottom = MulDiv(rect.bottom, dpiNew, dpiOld);
    
    pCtrl->SetWindowPos(nullptr,
        rect.left, rect.top,
        rect.Width(), rect.Height(),
        SWP_NOZORDER | SWP_NOACTIVATE
    );
}

// 在 WM_DPICHANGED 中缩放所有控件
void CMyDialog::ScaleAllControls(UINT dpiOld, UINT dpiNew) {
    CWnd* pChild = GetWindow(GW_CHILD);
    while (pChild) {
        ScaleControl(pChild, dpiOld, dpiNew);
        pChild = pChild->GetNextWindow();
    }
    
    // 也需要调整对话框自身大小(由 pNewRect 给出)
}

位图缩放

图标和位图需要提供多个分辨率版本,或者动态缩放:

// 方法一:提供多分辨率图标(推荐)
// 在 .ico 文件中包含 16x16, 32x32, 48x48, 256x256 等多个尺寸
// Windows 会自动选择最合适的

// 方法二:动态缩放位图
CBitmap* CreateScaledBitmap(HBITMAP hBitmapSrc, int srcW, int srcH, 
                             int dstW, int dstH) {
    CBitmap* pBmp = new CBitmap();
    
    CDC dcMem, dcSrc;
    dcMem.CreateCompatibleDC(nullptr);
    dcSrc.CreateCompatibleDC(nullptr);
    
    pBmp->CreateCompatibleBitmap(CDC::FromHandle(GetDC(nullptr)), dstW, dstH);
    
    CBitmap* pOldDst = dcMem.SelectObject(pBmp);
    CBitmap srcBmp;
    srcBmp.Attach(hBitmapSrc);
    CBitmap* pOldSrc = dcSrc.SelectObject(&srcBmp);
    
    // 高质量缩放
    dcMem.SetStretchBltMode(HALFTONE);
    dcMem.StretchBlt(0, 0, dstW, dstH, &dcSrc, 0, 0, srcW, srcH, SRCCOPY);
    
    dcMem.SelectObject(pOldDst);
    dcSrc.SelectObject(pOldSrc);
    srcBmp.Detach();
    
    return pBmp;
}

处理 WM_DPICHANGED

当窗口移动到不同 DPI 的显示器时,系统发送 WM_DPICHANGED

// 消息映射
BEGIN_MESSAGE_MAP(CMyDialog, CDialog)
    ON_MESSAGE(WM_DPICHANGED, &CMyDialog::OnDpiChanged)
END_MESSAGE_MAP()

LRESULT CMyDialog::OnDpiChanged(WPARAM wParam, LPARAM lParam) {
    UINT dpiX = LOWORD(wParam);
    UINT dpiY = HIWORD(wParam);
    RECT* pRect = reinterpret_cast<RECT*>(lParam);  // 建议的新窗口位置/大小
    
    UINT dpiOld = m_currentDpi;
    m_currentDpi = dpiX;
    
    // 1. 更新字体
    UpdateFonts();
    
    // 2. 缩放控件
    ScaleAllControls(dpiOld, dpiX);
    
    // 3. 移到建议位置(保持物理大小不变)
    SetWindowPos(nullptr,
        pRect->left, pRect->top,
        pRect->right - pRect->left,
        pRect->bottom - pRect->top,
        SWP_NOZORDER | SWP_NOACTIVATE
    );
    
    // 4. 重绘
    Invalidate();
    
    return 0;
}

测试方法

  1. Windows 设置:设置 → 显示 → 缩放,改为 125%、150%、200%,观察应用外观。

  2. 多显示器测试:连接两台不同 DPI 的显示器,把窗口在两台显示器间拖动,观察是否正确调整。

  3. 工具辅助:使用 Inspect(Accessibility Insights)或 DpiScaling 工具测试。

  4. 手动修改注册表(测试用):

    HKCU\Control Panel\Desktop\LogPixels
    

    改为 120(125%)或 144(150%)后注销重登录。

遵循以上方案,你的 MFC 应用就能在各种 DPI 设置和显示器组合下显示清晰、布局正确。