Windows 上的高 DPI 桌面應用程式開發

此內容針對想要更新桌面應用程式的開發人員,說明如何動態處理顯示縮放比例(每英吋點數,或 DPI)變更,讓其應用程式在任何顯示器上顯示時都能保持清晰。

首先,如果您要從頭開始建立新的 Windows 應用程式,強烈建議您建立 通用 Windows 平臺 (UWP) 應用程式。 UWP 應用程式會針對其執行所在的每個顯示器,自動且動態地進行縮放。

使用舊版 Windows 程式設計技術的計算機應用程式(原始 Win32 程式設計、Windows Forms、Windows Presentation Framework (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 未察覺

未支援 DPI 感知的應用程式會以固定的 96 DPI 值(100%)呈現。 每當這些應用程式在顯示比例大於 96 DPI 的畫面上執行時,Windows 會將應用程式點陣圖延展至預期的實體大小。 這會導致應用程式看起來模糊。

系統 DPI 感知能力

具備系統 DPI 感知能力的桌面應用程式,通常會在使用者登入當下收到主要連接顯示器的 DPI 設定。 在初始化期間,他們會使用該系統 DPI 值適當地配置 UI(重設大小控件、選擇字型大小、載入資產等)。 因此,對於在該單一 DPI 下呈現的顯示器,具備系統 DPI 感知能力的應用程式不會被 Windows 進行 DPI 縮放(點陣圖拉伸)。 當應用程式移至具有不同縮放比例的顯示器,或顯示縮放比例變更時,Windows 會點陣圖縮放應用程式的視窗,使其看起來模糊。 實際上,系統 DPI 感知桌面應用程式只會以單一顯示器縮放比例清晰呈現,每當 DPI 變更時就會變得模糊。

每個監視器和每個監視器(V2)DPI 感知

建議將桌面應用程式更新為使用各監視器 DPI 感知模式,讓它們在 DPI 變更時能立即正確呈現。 當應用程式向 Windows 回報它想要在此模式中執行時,Windows 不會在 DPI 變更時點陣圖延展應用程式,而是將 WM_DPICHANGED 傳送至應用程式視窗。 此時,應用程式就必須全權負責自行因應新的 DPI 進行大小調整。 傳統型應用程式所使用的大部分 UI 架構(Windows 通用控件(comctl32)、Windows Forms、Windows Presentation Framework 等) 不支援自動 DPI 縮放,因此開發人員必須自行調整其視窗內容的大小並重新定位。

Per-Monitor 感知有兩個版本,應用程式可將自己註冊為其中之一:第 1 版和第 2 版(PMv2)。 將進程註冊為在 PMv2 感知模式中執行,會導致:

  1. 在 DPI 變更時會收到通知的應用程式(包括最上層和子 HWND)
  2. 應用程式可查看各個顯示器的原始像素
  3. 應用程式永遠不會由 Windows 以點陣圖方式縮放
  4. 由 Windows 自動進行非用戶端區域(視窗標題列、捲軸等)的 DPI 縮放
  5. 由 Windows 自動進行 DPI 縮放的 Win32 對話方塊(由 CreateDialog 建立)
  6. 通用控制項中由主題繪製的點陣圖資源(核取方塊、按鈕背景等)會自動依適當的 DPI 縮放比例呈現

Per-Monitor v2 感知模式中執行時,應用程式會在 DPI 變更時收到通知。 如果應用程式不會針對新的 DPI 自行重設大小,則應用程式 UI 會顯示太小或太大(視先前和新 DPI 值的差異而定)。

注意

Per-Monitor V1 (PMv1) 意識非常有限。 建議應用程式使用 PMv2。

下表顯示應用程式在不同案例下呈現的方式:

DPI 感知模式 引進的 Windows 版本 應用程式的 DPI 檢視 DPI 變更時的行為
不知道 N/A 所有顯示器都是96 DPI 點陣圖延展 (模糊)
系統 Vista 所有顯示器都有相同的 DPI(即目前使用者工作階段啟動時主要顯示器的 DPI) 點陣圖延展 (模糊)
每台顯示器 8.1 應用程式視窗主要所在顯示器的 DPI
  • 最上層 HWND 會收到 DPI 變更的通知
  • 沒有任何UI元素的 DPI 縮放比例。

Per-Monitor V2 Windows 10 Creators Update (1703) 應用程式視窗主要所在顯示器的 DPI
  • 最上層 子 HWND 會收到 DPI 變更通知

自動 DPI 縮放比例:
  • 非用戶端區域
  • 通用控制項中的以主題繪製的點陣圖 (comctl32 V6)
  • 對話框 (CreateDialog

每部監視器 (V1) DPI 感知

Per-Monitor Windows 8.1 引進了 V1 DPI 感知模式 (PMv1)。 此 DPI 感知模式非常有限,只提供下列功能。 建議桌面應用程式使用 Per-Monitor v2 感知模式,此模式受 Windows 10 1703 或以上版本支援。

對每個監視器感知的初始支援,僅為應用程式提供下列功能:

  1. 頂層 HWND 會收到 DPI 變更通知,並獲得建議的新尺寸
  2. Windows 不會以點陣圖方式延伸應用程式使用者介面
  3. 應用程式會以實體像素顯示所有顯示器(請參閱虛擬化)

在 Windows 10 1607 或以上版本中,PMv1 應用程式也可在 WM_NCCREATE 期間呼叫 EnableNonClientDpiScaling,要求 Windows 正確縮放視窗的非用戶端區域。

依 UI 架構/技術區分的每個監視器 DPI 縮放支援

下表顯示從 Windows 10 1703 起,各種 Windows UI 架構所提供的每個監視器 DPI 感知支援層級:

架構 / 技術 支援 作業系統版本 處理的 DPI 縮放比例 進一步閱讀
通用 Windows 平臺 (UWP) 完整 1607 UI 架構 通用 Windows 平臺 (UWP)
原始 Win32/通用控制項 V6 (comctl32.dll)
  • 傳送至所有 HWND 的 DPI 變更通知訊息
  • 由主題繪製的資源可在通用控制項中正確顯示
  • 對話方塊的自動 DPI 縮放
1703 應用程式 GitHub 範例
Windows Forms 某些控制項僅有限支援依監視器個別進行的 DPI 自動縮放 1703 UI 架構 Windows Forms 中的高 DPI 支援
Windows Presentation Framework (WPF) 原生 WPF 應用程式中,在其他架構中裝載的 WPF,以及裝載於 WPF 中的其他架構,都不會自動進行 DPI 縮放 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,這通常會導致這些應用程式的 UI 變得模糊。 將系統 DPI 感知應用程式更新為每個監視器 DPI 感知時,負責處理 UI 版面配置的程式碼也需要一併更新,使其不僅在應用程式初始化期間執行,還會在每次收到 DPI 變更通知時執行(Win32 的情況下為 WM_DPICHANGED)。 這通常意味著需要重新檢視程式碼中任何認為 UI 只需要縮放一次的假設。

此外,在 Win32 程式設計的情況下,許多 Win32 API 沒有任何 DPI 或顯示內容,因此它們只會傳回相對於系統 DPI 的值。 在程式碼中用 grep 搜尋這些 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,調整子 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/字型/大小等)永遠不需要更新的程式碼失效。 在程式初始化時快取字型大小和 DPI 值是很常見的做法。 將應用程式更新為支援各監視器 DPI 感知時,每當遇到新的 DPI,都必須重新評估對 DPI 敏感的資料。
  4. 當 DPI 發生變更時,請重新載入(或重新點陣化)任何適用於新 DPI 的點陣圖資產,或者視需要將目前已載入的點陣圖資產拉伸至正確大小。
  5. 使用 grep 找出不支援 Per-Monitor DPI 感知的 API,並在適用的情況下將其替換為支援 Per-Monitor DPI 感知的 API。 範例:將 GetSystemMetrics 取代為 GetSystemMetricsForDpi。
  6. 在多顯示器/多 DPI 系統上測試您的應用程式。
  7. 對於無法更新為正確 DPI 縮放比例的應用程式中的任何最上層視窗,請使用混合模式 DPI 縮放比例(如下所述)允許系統對這些最上層視窗進行位圖延展。

混合模式 DPI 縮放(子處理序 DPI 縮放)

更新應用程式以支援個別監視器 DPI 感知時,有時可能會變得不切實際或不可能一次更新應用程式中的每個視窗。 這可能是因為更新和測試所有UI所需的時間和精力,或因為您沒有執行所需的所有UI程式代碼(如果您的應用程式可能載入第三方UI)。 在這些情況下,Windows 提供一種方式,讓您在逐步導入每個監視器 DPI 感知時,可先讓部分應用程式視窗(僅限最上層視窗)維持以原始的 DPI 感知模式執行,同時將時間與精力集中在更新 UI 中較重要的部分。

下圖說明其外觀:您會更新圖例中的主要應用程式 UI(圖例中的「主視窗」),以使用個別監視器 DPI 感知執行,同時以現有模式執行其他視窗(「次要視窗」)。

感知模式之間 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 變更。 要測試的一些細節包括:

  1. 在不同 DPI 值的顯示之間來回移動應用程式視窗
  2. 在顯示不同的 DPI 值時啟動您的應用程式
  3. 在應用程式執行時變更監視器的縮放比例
  4. 變更您設為主要顯示器的顯示器、登出 Windows,然後在重新登入後再次測試您的應用程式。 這對於尋找使用硬式編碼大小/維度的程式代碼特別有用。

常見的陷阱 (Win32)

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

當 Windows 傳送應用程式視窗 WM_DPICHANGED 訊息時,此訊息會包含建議的矩形,您應該用來調整視窗的大小。 您的應用程式務必使用此矩形來調整自身大小,因為這將會:

  1. 在顯示之間拖曳時,確定滑鼠游標會停留在視窗的相同相對位置
  2. 防止應用程式窗口進入遞歸 DPI 變更迴圈,其中一個 DPI 變更會觸發後續 DPI 變更,這會觸發另一個 DPI 變更。

如果您有應用程式特定需求,導致您無法使用 Windows 在WM_DPICHANGED訊息中提供的建議矩形,請參閱 WM_GETDPISCALEDSIZE。 此訊息可用來為 Windows 指定您希望在 DPI 變更完成後採用的大小,同時仍可避免上述問題。

缺乏有關虛擬化 的檔

當 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

從 Windows 11(Build 22000)開始,從記憶體資料建立游標的應用程式可以使用 SetThreadCursorCreationScaling 來啟用自動的每個螢幕 DPI 縮放,類似於從模組資源載入的游標。

強制重設整個處理程序的 DPI 感知

一般而言,進程初始化之後,無法變更進程的 DPI 感知模式。 不過,如果您試圖違反「視窗樹中所有 HWND 都必須具有相同 DPI 感知模式」這項要求,Windows 可能會強制變更您的處理程序的 DPI 感知模式。 在所有 Windows 版本中,自 Windows 10 1703 起,無法讓同一個 HWND 樹狀結構中的不同 HWND 以不同的 DPI 感知模式執行。 如果您嘗試建立違反此規則的子系-父系關係,整個處理序的 DPI 感知狀態可能會遭到重設。 這可以由下列方式觸發:

  1. CreateWindow 呼叫,其中傳入的父視窗其 DPI 感知模式與呼叫執行緒的 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 參考資料

混合模式 DPI 縮放和 DPI 感知 API。