「用 WPF 的話應該什麼都不用做,DPI 就自動沒問題了吧?」這是我們在高 DPI 相關諮詢中經常被問到的問題。這個說法一半對、一半不對。實際被拿來諮詢的症狀包括:「筆電單獨使用時很清晰,接上會議室的外接螢幕後整個畫面就暈邊」「文字很清晰,卻只有工具列的圖示模糊」「清單的框線在不同位置會變粗或消失」「畫面中嵌入的舊報表控制項只有那一部分變小」──這些全都是實際發生在「應該對 DPI 很強」的 WPF 應用程式上的現象。
前一天的文章「WinForms 的高 DPI 對應」整理了 Windows DPI 縮放的機制與 DPI 感知模式(Unaware/System Aware/Per-Monitor V2),以及如何拯救停留在 96 DPI 直接寫死年代的 WinForms。本文則是它的 WPF 版本。DPI 虛擬化與 DPI 感知模式的一般理論交給 WinForms 那篇文章,這裡集中討論 WPF 特有的問題:「明明一開始就應該支援 DPI 的 WPF,為什麼、又在哪裡會殘留問題」。內容依實務上會用到的順序排列:症狀判斷、Per-Monitor DPI 對應的宣告方式(分別針對 .NET Framework 4.6.2 與 .NET)、細線與點陣圖暈邊的對策、WindowsFormsHost 混用的陷阱,直到該做到什麼程度的判斷表。
1. 先講結論
- WPF 從一開始就能正確追蹤「啟動時的 DPI」。 由於版面配置單位是 1/96 英吋的裝置無關單位(DIP),繪製系統會依系統 DPI 自動縮放,因此預設就是 System DPI Aware。不需要像 WinForms 那樣設定、檢查 AutoScaleMode。1
- 儘管如此,仍會殘留的問題可分成 4 類:(a) 移到 DPI 不同的螢幕時整體暈邊、(b) 點陣圖影像/圖示模糊、(c) 細線/框線暈邊、(d) WindowsFormsHost/WebBrowser 等混合內容。原因與修法各不相同,請先用第 3 章的表格來判斷屬於哪一類。
- (a) 的根本解法是 Per-Monitor DPI 對應。.NET Framework 4.6.2 以後的 WPF 在框架層級支援這項功能,只要在應用程式清單中宣告,視窗本身的重新縮放就會自動完成。23
- .NET(Core 3.1~.NET 8)上的 WPF,預設也仍是 System Aware。WPF 沒有相當於 WinForms
ApplicationHighDpiMode/SetHighDpiMode的機制,宣告方式和 .NET Framework 一樣,都是透過應用程式清單。 - (c) 是比 DPI 更早就存在的子像素配置問題。第一步是在根元素設定
UseLayoutRounding="True",而SnapsToDevicePixels則是另一種在繪製時進行對齊的工具。兩者預設都是關閉。45 - (b) 應優先選用 Path / Geometry 等向量資產。只有點陣圖版本的資產,就準備多種解析度並依 DPI 切換。WPF 預設的內插方式(Linear)在非整數倍縮放時,最容易讓像圖示這類小圖變模糊。6
- (d) 的混合內容是決定 Per-Monitor 對應上限的因素。尤其「被載入」到 ElementHost/HwndSource 中的 WPF,其 Per-Monitor 行為官方已明確表示不支援。3
- DPI 感知模式的一般理論(DPI 虛擬化、System Aware 與 Per-Monitor V2 的差異、使用者從 exe 屬性覆寫設定)以及混合 DPI 測試環境的建置方式,與WinForms 那篇文章的第 2~3 章、第 7 章共通,本文不再重複。
2. WPF 為什麼「對 DPI 很強」──DIP 與 System DPI Aware
WinForms 高 DPI 問題的根源,在於座標與尺寸都是直接寫死物理像素,而以 96 DPI 為前提的版面配置,是靠事後補上的機制(AutoScale)在執行期重新換算。WPF 的出發點不同。XAML 中寫的 Width="120",這個 120 並不是物理像素,而是以 1/96 英吋為 1 的裝置無關單位(DIP)。整個版面配置都以這個單位計算,繪製時再依系統 DPI 換算成對應的倍率(150% 時為 1.5 倍)。文字也是以向量字型繪製,即使放大也不會有點陣圖式的劣化。靠著這套機制,WPF 應用程式即使什麼都不宣告,也會以 System DPI Aware 的方式運作。1
這裡先整理一份與 WinForms 的差異對照表。
| WinForms | WPF | |
|---|---|---|
| 座標與尺寸的單位 | 物理像素 | DIP(1/96 英吋) |
| 什麼都不宣告時 | Unaware(因 OS 的點陣圖放大而模糊) | System Aware(主螢幕上很清晰) |
| 對啟動時 DPI 的追蹤 | 透過 AutoScaleMode 逐個表單重新計算,需要設定與檢查 | 繪製系統自動縮放,不需設定 |
| 高 DPI 下常見的崩壞 | 固定座標版面重疊、被裁切 | 版面不會崩壞,而是以暈邊、模糊的形式出現 |
| 設計工具引起的事故 | AutoScaleDimensions 被改寫(常見案例) | 原則上沒有(XAML 仍以 DIP 儲存) |
如表所示,在 WinForms 中佔工時最大宗的「修正版面崩壞」「防止設計工具事故」這類工作,在 WPF 幾乎不會發生。「WPF 對 DPI 很強」是正確的認知。
不過,自動化只涵蓋到System Aware 的範圍。System Aware 是「配合登入時主螢幕的 DPI」這種模式,並不包含追蹤各螢幕 DPI 不同的環境(Per-Monitor)。另外,以 DIP 放大的終究只是 WPF 自己繪製的東西(文字、圖形、控制項),點陣圖影像放大就會模糊,而帶入 HWND 的混合內容則在 WPF 縮放機制的範圍之外。「明明應該對 DPI 很強」這類諮詢,幾乎都發生在這剩下的部分。
3. 仍然會發生的問題──從症狀判斷原因
這是我們接到諮詢時,第一個會拿出來用的判斷表。WPF 中症狀與原因的對應關係比 WinForms 分得更清楚,光靠這張表幾乎就能確定原因。
| 症狀 | 原因 | 對策 |
|---|---|---|
| 移到 DPI 不同的螢幕時整體一致地暈邊。移回原本的螢幕就恢復正常 | 仍是 System Aware,作業系統以點陣圖方式放大整個視窗 | 第 4 章(改用 Per-Monitor) |
| 剛變更縮放設定後,或透過高 DPI 用戶端進行 RDP 連線時暈邊 | 同上(System DPI 在登入時就固定下來) | 第 4 章 |
| 文字很清晰,卻只有圖示、影像模糊或變小 | 點陣圖資產被內插放大 | 第 5.3 節 |
| 1px 的框線、邊框依位置粗細不同、微微暈邊 | 子像素配置與反鋸齒 | 第 5.1 節 |
| 在 100%(96 DPI)的螢幕上,小字看起來也暈邊 | 預設文字整形(Ideal)的反鋸齒 | 第 5.2 節 |
| 自行產生的影像(WriteableBitmap、RenderTargetBitmap 等)模糊 | 像素尺寸是以 96 DPI 為前提計算的 | 第 6 章 |
| 視窗位置、滑鼠座標、螢幕截圖的座標偏移 | 混淆了 DIP 與物理像素 | 第 6 章 |
| 只有WindowsFormsHost/WebBrowser/部分第三方控制項的內部變小、變粗糙、跑版 | 混合內容(不在 WPF 縮放的範圍內) | 第 7 章 |
補充說明第 1 行「整體一致地暈邊」。這是我在 WinForms 那篇文章第 2 章寫過的DPI 虛擬化(OS 的點陣圖放大)作用在 System Aware 應用程式上的狀態,應用程式本身並沒有壞掉。System Aware 應用程式是以「登入時主螢幕的 DPI」繪製,在其他 DPI 的螢幕上,則由 OS 放大或縮小來湊合。這是 OS 的一種補救措施:版面不會崩壞,但代價是暈邊。1 要修正,就是拒絕這項補救、宣告「我會自己追蹤每個螢幕的 DPI」,也就是改用 Per-Monitor。
反過來說,第 3 行以後的症狀,即使停留在 System Aware 也能修正。如果諮詢的是「沒有多螢幕使用情境,但 150% 畫面下的圖示和框線很髒」,可以跳過第 4 章,直接從第 5 章著手。
4. 多螢幕的暈邊──Per-Monitor DPI 對應
4.1 System Aware 的極限
System DPI 會在登入時以主螢幕的 DPI 固定下來。如果 150% 的筆電內建螢幕是主螢幕,WPF 就會以 1.5 倍繪製所有視窗;移到 100% 的外接螢幕時,OS 會將其縮小顯示為 2/3。這種 OS 層級的縮放在非整數倍時看起來特別模糊。1 實際驗證就會發現,比起 200% 與 100% 這種整數 2 倍差,125% 與 150% 這類不上不下的組合,外觀上的劣化反而更明顯。
換句話說,System Aware 的 WPF 會造成困擾的情境,僅限於在 DPI 不同的螢幕之間來回使用,以及登入後縮放設定發生變化的狀況(例如插拔擴充基座導致主螢幕改變、透過 RDP 帶入用戶端的 DPI 等)。如果是所有人都在單一螢幕的固定桌上型環境使用的內部應用程式,停留在 System Aware 在實務上就已經足夠。這個判斷會在第 8 章的表格中再次出現。
4.2 .NET Framework 4.6.2 以後的宣告方式
WPF 的 Per-Monitor DPI 對應是在 .NET Framework 4.6.2 才加入的。2 在那之前,即使向 OS 宣告 Per-Monitor,WPF 本身也不會追蹤 DPI 變化,必須自己把視窗重新縮放的邏輯全部寫出來(這也是為什麼 Windows 8.1 時代的範例會用原生輔助 DLL 做出那麼複雜的架構)。4.6.2 以後,WPF 會自行處理 WM_DPICHANGED,從調整視窗大小、重新配置版面到重新繪製,全部自動完成。
條件有兩個:Windows 10 Anniversary Update(1607)以後的 OS,以及以 .NET Framework 4.6.2 以後為目標的組建。3 宣告要寫在應用程式清單中。
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<!-- .NET Framework 4.6.2~4.7.2 單獨宣告 PerMonitor(與開發者指南一致)。
WPF 的 PerMonitorV2 支援是從 .NET Framework 4.8 才開始,
因此 4.8+ / .NET 要寫成「PerMonitorV2, PerMonitor」,把 V2 放在前面 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"
>PerMonitor</dpiAwareness>
<!-- 給不認識 dpiAwareness 的舊版 OS 使用(回退到 System Aware) -->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
這種雙層架構是有道理的。dpiAwareness 元素在 Windows 10 1607 以後才會被辨識,且以逗號分隔的多個值中,會採用第一個能被辨識的值。7 對於根本不認識 dpiAwareness 元素的舊版 OS,就會回退到 dpiAware 的宣告(System Aware),這就是它的運作原理。
這裡請注意,要依目標框架版本區分使用 PerMonitor 與 PerMonitorV2。WPF 正式支援 PerMonitorV2(與 Mixed-Mode DPI)是從 .NET Framework 4.8 以後開始8,即使 4.6.2 的開發者指南範例,也是單獨宣告 PerMonitor。3 若在以 4.6.2~4.7.2 為目標的專案中把 V2 寫在前面,Windows 10 1703 以後的 OS 就會選到框架其實不支援的 V2 模式,尤其在 WindowsFormsHost 等混合內容周邊,容易出現預期外的行為。我們建議的做法是:先升級到 4.8 以後(或 .NET 上的 WPF),再把 V2 放在前面寫成 PerMonitorV2, PerMonitor,把標題列、捲軸等非用戶端區域的縮放交給 OS 處理(參見WinForms 那篇文章第 3 章)。
有一個目標框架的陷阱要注意。即使執行環境的 .NET Framework 是 4.6.2 以後,只要專案的目標仍停留在 4.6.1 以前,Per-Monitor 追蹤預設就是關閉的。這時要在 app.config 中透過 AppContext 開關明確啟用。3
<configuration>
<runtime>
<!-- 注意雙重否定:把「DPI 變更時不縮放」設為 false,等於啟用 -->
<AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
</runtime>
</configuration>
「明明寫了應用程式清單卻沒效果」這類詢問,依經驗來看幾乎都出在這個目標框架問題,或是清單根本沒被包進組建(專案設定仍停留在預設清單)這兩者之一。
4.3 .NET(Core 3.1~.NET 8)的宣告方式
.NET 上的 WPF,在沒有應用程式清單的情況下,預設仍是 System Aware。相對於 WinForms 在專案檔中有 ApplicationHighDpiMode、或是 Application.SetHighDpiMode 這種專用入口,WPF 並沒有從程式碼或專案設定切換 DPI 感知模式的官方機制。宣告方式與 .NET Framework 相同:在專案中新增 app.manifest(在 Visual Studio 中選「新增項目」→「應用程式資訊清單檔案」),寫入與 4.2 節相同的 dpiAwareness 宣告,再從專案檔參照它。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
因為執行環境本身較新,所以不需要 4.2 節的 AppContext 開關。唯一要注意的是,並不會因為「遷移到 .NET」就自動解決高 DPI 問題。遷移帶來的輕鬆,是 WinForms 那一側的故事(見 WinForms 那篇文章第 4.1 節),WPF 早在 .NET Framework 4.6.2 的時候就已經達到現在的水準。
宣告之後仍有工作要做,這點與 WinForms 相同,但內容不一樣。在 WinForms,修正版面崩壞是主戰場。在 WPF,版面配置由框架自動追蹤,所以剩下的是全畫面顯示檢查、點陣圖資產的 DPI 切換(第 5.3 節)、直接處理像素的程式碼追蹤(第 6 章)、混合內容的確認(第 7 章)。畫面數量相同的情況下,改用 Per-Monitor 的總工時通常會比 WinForms 少一截。
5. 繪製的暈邊與模糊──細線、文字、點陣圖
本章的內容與是否改用 Per-Monitor 無關。即使停留在 System Aware 也有效,因此就算是沒有多螢幕使用情境的應用程式,也值得套用。
5.1 細線、框線的暈邊──UseLayoutRounding 與 SnapsToDevicePixels
以 DIP 進行版面配置,意味著元素的邊界不一定會落在物理像素的整數位置上。在 125% 時,1 DIP = 1.25px,因此寬度為 1 DIP 的框線相當於 1.25 個物理像素,落在像素中間的邊緣就會被反鋸齒處理成半透明。結果就是「線條暈邊」「明明都設定成 1px,不同行看起來粗細卻不一樣」。即使在 96 DPI 下,只要 Grid 的星號尺寸(*)分割或置中對齊的邊距計算落在 0.5px 上,也會發生同樣的事。這是 WPF 子像素繪製規格本身的特性,與其說是 DPI 問題,更像是「DPI 一提高就更容易顯現出來」的問題。4
第一步是版面配置四捨五入。在根元素加一行即可。
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
UseLayoutRounding="True">
UseLayoutRounding 是在版面配置階段把非整數的像素值四捨五入的機制,預設是關閉的,只要設定在根元素上,就會傳播到整個視覺樹。4 名稱相似的 SnapsToDevicePixels 扮演的角色不同,它不是在版面配置,而是在繪製時把邊緣對齊到像素邊界。這個屬性的預設值同樣是 false,設定會向子樹繼承。官方文件也提到,它可用於在超過 96 DPI 的環境下,抑制細線周圍因反鋸齒造成的暈邊。5
| UseLayoutRounding | SnapsToDevicePixels | |
|---|---|---|
| 作用時機 | 版面配置階段(把 Measure/Arrange 的結果四捨五入為整數像素) | 繪製時(把邊緣對齊到像素邊界) |
| 預設值 | false | false |
| 適用範圍 | 設定在根元素則傳播到子孫元素 | 同樣向子孫元素繼承 |
| 主要作用對象 | 版面配置整體。元素尺寸、位置的 0.5px 偏移,以及由此造成的線條、邊界暈邊 | Border、細線等個別元素殘留的暈邊 |
| 使用時機參考 | 先套用在根 Window 上。新專案可放進所有視窗共用的樣式中 | 針對 UseLayoutRounding 仍無法解決的地方做定點處理 |
若不確定該怎麼做,順序就是「先在根元素設定 UseLayoutRounding="True",殘留的地方再套用 SnapsToDevicePixels="True"」。四捨五入的副作用是,原本以星號尺寸平均分配的欄寬,可能會出現 1px 左右的不均等,但依經驗,這在業務應用程式的畫面上幾乎不會造成問題。另外,對於用 DrawingContext 自行繪製的程式碼所產生的暈邊,還有一個叫 GuidelineSet 的低階手段,但光是把繪製座標四捨五入,大部分情況就已經能解決。
5.2 文字的暈邊──TextFormattingMode
「WPF 的文字很淡、會暈邊」這個長久以來的抱怨,與其說是 DPI 問題,其實更接近文字整形模式的問題。WPF 的文字整形有兩種模式:以字型本來的理想度量配置的 Ideal(預設),以及以 GDI 相容度量配置的 Display。9 Ideal 的字元間距很漂亮,縮放時也很自然,但在 96 DPI(100%)下繪製較小的文字時,有時會因為反鋸齒而看起來暈邊。若在根元素設定 TextOptions.TextFormattingMode="Display",就會得到類似 WinForms 那種銳利的感覺。
不過在高 DPI 環境下,情況會反過來。Display 是配合 96 DPI 像素格線做的整形,因此在縮放狀態下,反而可能讓品質變差。實務上比較乾脆的做法是:如果 100% 環境的使用者較多,就用 Display;在如今 125% 以上已成主流的標準環境下,維持預設的 Ideal 即可。也可以做成只在 100% 時切換為 Display 的實作,但值得做到這種程度的畫面(像文字編輯器那類畫面)其實有限。
5.3 影像、圖示的模糊──向量優先與多種解析度
WPF 對於指定給 Image 的點陣圖,同樣以 DIP 配置,並依 DPI 放大。16×16px 的圖示在 150% 環境下會被內插放大成 24×24px,用預設的內插演算法(Linear)處理,會明顯變模糊。6 WPF 應用程式那種「文字很清晰,卻只有圖示看起來很鬆散」的外觀,幾乎全都源自這個原因。
對策依優先順序有 3 種。
- 改用向量資產(第一選擇)。以
Path/Geometry/DrawingImage持有的圖示,在任何 DPI 下都能銳利呈現,也不需要切換用的程式碼。從設計工具或 SVG 轉換成 XAML 的工具也相當完備。值得把「新應用程式的圖示一開始就用向量」定為規範。像 Segoe MDL2 Assets 這類圖示字型也有同樣的效果。 - 依 DPI 切換多種解析度的點陣圖。像照片、螢幕截圖這類無法向量化的資產,就準備給 96/120/144/192 DPI 用的版本(例如 16/20/24/32px),依目前的 DPI 選擇。官方開發者指南也建議以依 DPI 切換資產作為模糊對策。1
- 用
RenderOptions.BitmapScalingMode選擇內插方式。這是無法增加資產時的緩解手段。若是像 200% 這種整數倍,用NearestNeighbor保持點狀放大反而更銳利;縮小大型影像則適合用HighQuality(Fant)。6
第 2 點的切換,若已改用 Per-Monitor,就用 OnDpiChanged(.NET Framework 4.6.2 以後可用)來處理。
public partial class MainWindow : Window
{
protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
{
base.OnDpiChanged(oldDpi, newDpi);
// 每次在螢幕間移動或縮放設定變更時都會被呼叫
AppIcon.Source = IconAssets.SelectFor(newDpi.DpiScaleX);
}
}
public static class IconAssets
{
// 從準備好的 16 / 24 / 32 px 資產中,挑出符合放大率的版本
public static BitmapImage SelectFor(double scale) => scale switch
{
<= 1.0 => Load("icon16.png"),
<= 1.5 => Load("icon24.png"),
_ => Load("icon32.png"),
};
private static BitmapImage Load(string name) =>
new(new Uri($"pack://application:,,,/Assets/{name}"));
}
如果停留在 System Aware,DPI 在啟動後就不會改變,因此只要在啟動時(Loaded)用 VisualTreeHelper.GetDpi(this) 選一次即可。在寫切換用的程式碼之前,每次都先問自己「這個資產本來就能不能做成向量」,結果往往是維護起來最省力的做法。
6. 處理像素的程式碼與追蹤 DPI 變更
脫離 WPF 自動縮放保護範圍的,是自己計算物理像素的程式碼。典型情況有下面 3 種,都會以「開發機(100%)上完美無缺,只在客戶端 150% 才模糊、偏移」這種討人厭的方式出現。
第一種是自行建立像素緩衝區的程式碼,例如 WriteableBitmap/RenderTargetBitmap。如果用與 DIP 尺寸相同的數值建立像素數,在 150% 環境下就會被內插放大 1.5 倍而暈邊。目前的 DPI 可以透過 VisualTreeHelper.GetDpi 回傳的 DpiScale 結構取得,所以像素數要用物理像素計算,並把正確的 DPI 寫入緩衝區。
// imageHost:顯示繪製結果的元素(ActualWidth/Height 為 DIP)
DpiScale dpi = VisualTreeHelper.GetDpi(imageHost);
int pixelWidth = (int)Math.Ceiling(imageHost.ActualWidth * dpi.DpiScaleX);
int pixelHeight = (int)Math.Ceiling(imageHost.ActualHeight * dpi.DpiScaleY);
// 不要固定寫 96,96,要傳入實際的 DPI(讓 1 個物理像素 = 1 個緩衝區像素)
var bitmap = new WriteableBitmap(
pixelWidth, pixelHeight,
dpi.PixelsPerInchX, dpi.PixelsPerInchY,
PixelFormats.Bgra32, null);
第二種是螢幕座標與 Win32 互通。PointToScreen 回傳的座標、透過 WM_MOUSEMOVE 或掛鉤取得的座標、傳給 MoveWindow 的座標,全部都是物理像素,若和 XAML 端的 DIP 混用,在 125% 環境下就會偏移 1.25 倍。轉換時要用 CompositionTarget 的矩陣。
var source = PresentationSource.FromVisual(this);
if (source?.CompositionTarget is { } target)
{
// DIP → 物理像素
Point device = target.TransformToDevice.Transform(new Point(x, y));
// 物理像素 → DIP
Point dip = target.TransformFromDevice.Transform(devicePoint);
}
第三種是快取。如果快取了將 DPI 內建進去而做出的點陣圖、版面配置數值、已格式化的文字,一旦改用 Per-Monitor,就會在每次跨螢幕移動時失效。請像 5.3 節一樣,在 OnDpiChanged(或視窗的 DpiChanged 事件)中重新產生。這裡也是一樣,只要停留在 System Aware,DPI 在啟動時就固定了,不需要追蹤用的程式碼。若以「改用 Per-Monitor 的成本=處理像素的程式碼數量」來估算,通常會與實際情況相當吻合。
另外,若以非同步方式進行這類重新產生的處理,UI 執行緒的處理方式,就如同「WPF/WinForms 的 async 與 UI 執行緒」中整理的內容。
7. 混合內容的陷阱──WindowsFormsHost、WebBrowser、第三方
WPF 自動縮放能涵蓋的,僅限於 WPF 自己繪製的東西。帶入 HWND 的元素在 WPF 繪製範圍的「外面」,因此 DPI 的處理會多一層複雜。Windows 官方文件也明確寫著:「載入到 WPF 中的其他框架,以及載入到其他框架中的 WPF,都不會自動縮放」。10
| 混合的形式 | 會發生什麼事 | 現實的處理方式 |
|---|---|---|
| WindowsFormsHost(在 WPF 中載入 WinForms) | 主體會轉換 DIP 與物理像素這兩套座標系統,但內部內容的縮放僅限於 WinForms 控制項本身所能支援的範圍11 | 依 WinForms 那篇文章的標準,檢查內部 WinForms 側的 DPI 對應(AutoScaleMode 等)。不要期待它能追蹤 Per-Monitor |
| WebBrowser 控制項(IE 引擎) | 位於另一個 HWND 的原生內容。縮放比例有時會與應用程式本身的縮放不一致 | 視為舊技術。可作為評估遷移到 WebView2的參考素材之一 |
| ElementHost/HwndSource(在 WinForms 或 Win32 中載入 WPF) | Per-Monitor 情境官方明確不支援3 | 設計上只做到與主體應用程式 DPI 感知模式相符的 System Aware |
| 第三方控制項 | 各產品的支援程度不一。不支援 Per-Monitor 的產品會成為該畫面的上限 | 先盤點各供應商的支援狀況與支援版本,再判斷是否要改用 Per-Monitor |
實務上最常見的是第 1 行。假設一個應用程式已經遷移到 WPF,但報表預覽或圖表這部分仍透過 WindowsFormsHost 載入舊的 WinForms/ActiveX 控制項,把它改成 Per-Monitor 後,WPF 的部分會完美追蹤,但只有主體內部的內容無法追蹤移動後的 DPI,維持原本較小、較粗糙的狀態。Win32 層級雖然有一種主控混合 DPI 的機制(SetThreadDpiHostingBehavior),但並不是能從 WPF 隨意使用的東西。我們的做法是乾脆認定「混合的部分決定了 Per-Monitor 對應的上限」,先從建立混合內容清單開始。若要往消除混合內容的方向判斷,可以參考「如何選擇 WinForms/WPF/WinUI」與「維持、包裝或替換 ActiveX/OCX 控制項」這兩篇文章。
8. 該做到什麼程度──判斷表與階段式做法
在 WPF 的情況下,實際上只有 2 個選項(WinForms 那種「什麼都不做=Unaware」的選項不存在,因為即使不宣告也會是 System Aware)。
| 停留在 System Aware | 改用 Per-Monitor 對應 | |
|---|---|---|
| 外觀表現 | 主螢幕上很清晰。在 DPI 不同的螢幕、變更縮放設定後、RDP 情境下會暈邊 | 所有螢幕上都清晰 |
| 宣告工作 | 不需要(預設) | 只需新增應用程式清單(第 4 章) |
| 宣告後剩餘工作 | ── | 全畫面顯示檢查+點陣圖資產的 DPI 切換(第 5.3 節)+像素相關程式碼的 DpiChanged 對應(第 6 章)+混合內容的確認(第 7 章) |
| 決定上限的因素 | ── | WindowsFormsHost、WebBrowser、第三方控制項 |
| 適合情境 | 以單一螢幕、固定桌上型環境為主的內部應用程式。混合內容多的應用程式 | 筆電+外接螢幕混用、長期維運的主力應用程式、要交付給客戶的產品 |
判斷的依據軸線與 WinForms 那篇文章第 7 章相同(應用程式的生命週期、使用環境、改造預算),不同之處在於WPF 改用 Per-Monitor 的邊際成本較小。宣告只需 1 個檔案,版面配置由框架自動追蹤,剩餘工作則與像素相關程式碼、資產的數量成比例。如果應用程式的混合內容較少,改用 Per-Monitor 的投資報酬率明顯比 WinForms 高。我們在實際專案中建議的做法分為以下 3 個階段。
- 在維持 System Aware 的情況下改善品質:在根元素套用
UseLayoutRounding、把圖示向量化/多解析度化、修正WriteableBitmap系的 DPI 問題。除了多螢幕暈邊之外的問題都會在這一步全部解決,即使最後決定不改用 Per-Monitor,這些成果也能直接保留下來成為資產。 - 宣告 Per-Monitor 並進行檢查:新增應用程式清單,在混合 DPI 環境下把所有畫面過一輪,找出問題所在。這裡發現的問題應該都能歸類到第 5~7 章的其中之一。
- 實作對 DPI 變更的追蹤:在
OnDpiChanged中進行資產切換、快取重新產生。混合內容則在這個階段決定「要容許到什麼程度」。
測試環境可以直接沿用 WinForms 那篇文章第 7 章寫的配置(縮放不同的兩台螢幕、更換主螢幕並重新登入、執行中變更縮放設定、從高 DPI 用戶端進行 RDP)。10 在 WPF 中應該重點檢視的是非整數倍(125%/150%)下的框線、圖示、自行繪製內容,以及在螢幕之間拖曳視窗時的行為。事先掌握使用環境的分布(有多少百分比的使用者是多螢幕),投資判斷就不會搖擺不定。這個觀點我們也在「Windows 應用程式 UX 設計」中寫過。
9. 總結
WPF 靠 DIP 與自動縮放,從一開始就是 System DPI Aware,WinForms 的主戰場「與版面崩壞的戰鬥」在這裡幾乎不存在。儘管如此,仍會殘留 4 類問題──多螢幕暈邊(未支援 Per-Monitor)、點陣圖模糊、細線暈邊、混合內容──每一類都有已確立的對策。
- 多螢幕暈邊,只要是 .NET Framework 4.6.2 以後/.NET 上的 WPF,光靠應用程式清單宣告就能自動取得 Per-Monitor 追蹤
- 細線、框線的第一步是在根元素設定
UseLayoutRounding="True",其餘再用SnapsToDevicePixels - 圖示優先選用向量資產,點陣圖則需要多種解析度+依 DPI 切換
WriteableBitmap或螢幕座標等只有處理像素的程式碼才是真正需要改造的對象,其數量決定了改用 Per-Monitor 的工時- WindowsFormsHost/WebBrowser/第三方控制項掌握著支援程度的上限,要先盤點清楚
就算是停留在「因為是 WPF,應該沒問題」這種認知的應用程式,用這套整理方式來看,往往也只有寥寥幾處需要修正。如果不確定手上的應用程式能修到什麼程度,或是在包含混合內容的現狀調查、對應層級判斷上感到迷惘,我們都可以協助處理。
相關文章
- WinForms 的高 DPI 對應──在 4K 螢幕上模糊、跑版的原因與現實對策
- WinForms/WPF/WinUI 的選擇方式 - 實務判斷表
- 用一張表整理 WPF/WinForms 的 async 與 UI 執行緒
- Windows 應用程式 UX 設計 - 依使用環境的優先順位
相關諮詢領域
合同會社小村軟體承接 WPF/WinForms 應用程式的高 DPI 對應(現狀調查、Per-Monitor 化可行性判斷、混合內容的盤點與改造)、PC 更換/導入 4K 螢幕所伴隨的顯示問題原因調查,以及 UI 改版的相關諮詢。
參考連結
-
Microsoft Learn, Developing a Per-Monitor DPI-Aware WPF Application。關於 WPF 預設為 System DPI Aware、透過 DIP 實現自動縮放的機制、移動到 DPI 不同的螢幕時 OS 會進行縮放且在非整數倍時特別模糊,以及依 DPI 切換點陣圖資產的概念。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, What’s new in .NET Framework。關於 .NET Framework 4.6.2 的 WPF 啟用了 Per-Monitor DPI awareness、Switch.System.Windows.DoNotScaleForDpiChanges 開關,以及 GitHub 上的開發者指南。 ↩ ↩2
-
GitHub (microsoft/WPF-Samples),Per Monitor DPI Developer Guide。關於 Windows 10 Anniversary Update 與 .NET Framework 4.6.2 以後的要求、應用程式清單中 dpiAwareness/dpiAware 的寫法、針對早於 4.6.2 目標的 AppContextSwitchOverrides,以及載入在 HwndSource/ElementHost 中的 WPF 不支援 Per-Monitor 這一點。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Layout - WPF。關於以 DIP 進行版面配置與子像素繪製導致邊緣模糊的機制、版面配置四捨五入(UseLayoutRounding)預設為關閉,以及設定在根元素會傳播到整個視覺樹。 ↩ ↩2 ↩3
-
Microsoft Learn, UIElement.SnapsToDevicePixels Property。關於預設值為 false、設定在根元素會向子樹繼承,以及在超過 96 DPI 的環境下可減輕細線周圍因反鋸齒造成的視覺瑕疵。 ↩ ↩2
-
Microsoft Learn, BitmapScalingMode Enum。關於預設值(Unspecified)為 Linear,以及 HighQuality(Fant)、NearestNeighbor 兩種內插演算法的特性。 ↩ ↩2 ↩3
-
Microsoft Learn, Setting the default DPI awareness for a process。關於 dpiAwareness 元素(Windows 10 1607 以後)的優先順序高於 dpiAware,以及以逗號列舉的值中,會採用第一個能被辨識的值這一回退行為。 ↩
-
Microsoft Learn, What’s new in .NET Framework。關於 .NET Framework 4.8 為 WPF 新增了 Per-Monitor V2 DPI Awareness 與 Mixed-Mode DPI 縮放的支援、託管 HWND/WinForms 互通性的改善,以及啟用所需的 AppContext 開關。 ↩
-
Microsoft Learn, TextFormattingMode Enum。關於文字整形的 Ideal(理想度量)與 Display(GDI 相容度量)這兩種模式。 ↩
-
Microsoft Learn, High DPI Desktop Application Development on Windows。關於各 UI 框架的 Per-Monitor 支援對照表、載入在 WPF 中的其他框架與載入在其他框架中的 WPF 不會自動縮放,以及混合 DPI 環境下的測試觀點。 ↩ ↩2
-
Microsoft Learn, Layout Considerations for the WindowsFormsHost Element。關於 WindowsFormsHost 會轉換 DIP 與物理像素這兩套座標系統,以及縮放僅限於內部的 Windows Forms 控制項所能支援的範圍。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
WinForms 的高DPI支援 - 4K螢幕顯示模糊、版面跑掉的原因與實務對策
本文從 DPI 虛擬化與 DPI 感知模式(System Aware / Per-Monitor V2)的角度,整理 WinForms 應用程式在4K螢幕、150%縮放環境下顯示模糊、版面跑掉的原因,並說明 .NET 與 .NET Framework 各自的設定方式、Aut...
在 WinForms/WPF 應用程式中導入 Entra ID 驗證 ── MSAL.NET 與 WAM 代理的實務架構
從實務角度整理在 WinForms/WPF 桌面應用程式中導入 Entra ID(原 Azure AD)驗證的做法。內容涵蓋公用客戶端的概念、ROPC 廢除現況、應用程式註冊、MSAL.NET 的 AcquireTokenSilent 模式、WAM 代理、權杖快取持久化,以...
業務應用程式的日期時間與時區 ── 從 DateTime 的陷阱到 UTC 儲存原則、測試設計
伺服器搬遷後時間整整差了 9 個小時、只有海外據點的日期會變成前一天──本文從 DateTime 的 Kind 與隱式轉換整理日期時間事故的根本原因。內容涵蓋與 DateTimeOffset 的取捨、UTC 儲存與 ISO 8601 原則、TimeZoneInfo 與夏令時...
Windows 應用程式的工作列通知區常駐與 Toast 通知 —— NotifyIcon 的陷阱與 AppNotification 的選型
本文整理將業務用 Windows 應用程式常駐於工作列通知區,並透過 Toast 通知告知使用者的實作要點。內容涵蓋 NotifyIcon 的正確用法與「關閉後仍留在通知區」的設計、檔案總管重新啟動時的重新註冊、三種 Toast API(Windows App SDK Ap...
WinForms/WPF 應用程式的多語言化 ── resx、附屬組件與文化特性切換的實務
本文從實務角度整理 Windows 桌面應用程式的多語言化,包括 CurrentCulture 與 CurrentUICulture 的差異、resx 與附屬組件(Satellite Assembly)所構成的資源機制、WinForms 的 Localizable 屬性、W...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
UI 執行緒 & 計時器
整理 WPF / WinForms UI 執行緒、非同步流程、Dispatcher 使用、計時器判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。
常見問題
整理諮詢這個主題時常見的問題。
- 聽說 WPF 不需要做 DPI 對應,為什麼還是會暈邊?
- WPF 的版面配置單位是 1/96 英吋的裝置無關單位(DIP),且預設以 System DPI Aware 運作,因此對啟動時主螢幕的 DPI 能正確追蹤。但 System DPI 是在登入時就固定下來的,所以把視窗移到 DPI 不同的螢幕時,作業系統會把整個視窗以點陣圖方式放大或縮小來湊合,導致整體一致地暈邊。尤其在 125% 與 150% 這類非整數倍的組合下,劣化會特別明顯。根本解法是在應用程式清單(manifest)中宣告 Per-Monitor DPI 對應,.NET Framework 4.6.2 以後的 WPF 只要宣告,視窗的重新縮放就會自動完成。
- WPF 中 1px 的框線會暈邊、粗細不一致,是為什麼?
- 因為 WPF 以 DIP 為單位進行版面配置,元素的邊界不一定會落在物理像素的整數位置上。在 125% 環境下,1 DIP = 1.25px,落在像素中間的邊緣會被反鋸齒處理成半透明而暈邊。第一步是在根 Window 設定 UseLayoutRounding="True",讓版面配置階段把非整數的像素值四捨五入,並向下傳遞到整個視覺樹。若仍有殘留,再針對個別元素套用 SnapsToDevicePixels="True",於繪製時把邊緣對齊像素邊界。兩者的預設值都是關閉。
- WPF 中文字很清晰,卻只有圖示模糊,是為什麼?
- 因為文字是以向量字型繪製,任何 DPI 下都很銳利,而點陣圖影像是以 DIP 配置,會依 DPI 進行內插放大。16×16px 的圖示在 150% 環境下會放大到 24×24px,預設的內插演算法(Linear)會讓它明顯模糊。對策的優先順序是:(1) 改用 Path / Geometry / DrawingImage 或圖示字型等向量資產;(2) 準備多種解析度的點陣圖,透過 VisualTreeHelper.GetDpi 或 OnDpiChanged 依 DPI 切換;(3) 用 RenderOptions.BitmapScalingMode 調整內插演算法。
- WPF 的 Per-Monitor 對應要怎麼設定?
- 在應用程式清單中寫入 dpiAwareness 元素來宣告。WPF 沒有像 WinForms 的 ApplicationHighDpiMode 那種專案設定或程式碼切換手段。條件是 Windows 10 1607 以後的作業系統,以及以 .NET Framework 4.6.2 以後為目標的組建,宣告後從 WM_DPICHANGED 的處理到視窗重新縮放都會由框架自動完成。WPF 的 PerMonitorV2 支援是從 .NET Framework 4.8 才開始,因此在 4.6.2~4.7.2 要單獨宣告 PerMonitor。若目標仍停留在 4.6.1 以前,追蹤功能預設是關閉的,需要透過 AppContext 切換來啟用。另外,載入在 ElementHost 或 HwndSource 中的 WPF,其 Per-Monitor 行為官方明確不支援。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。