WPF 高 DPI 對應──「不是應該對 DPI 很強嗎」卻仍模糊、暈邊的原因與對策
· 小村 豪 · WPF, 高DPI, Windows, .NET, C#, .NET Framework, XAML, UI, 技術諮詢
「用 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 的工作階段隔離 ── Session 0・RDP・多使用者同時執行
整理 Windows 應用程式開發者容易混淆的「工作階段」概念。說明服務無法顯示 UI 的 Session 0 隔離原因、RDP 連線時工作階段的行為、具名物件的工作階段隔離,以及共用 PC・RDS 環境常見的設計失誤,從實務角度解說。
防止 Windows 應用程式重複啟動 ── 具名 Mutex 與重複執行時的視窗前置
整理業務用 Windows 應用程式的常見需求「不讓同一個應用程式啟動兩次」,如何用具名 Mutex 實作。涵蓋 Global\ 與 Local\ 命名空間差異在 RDP 環境中的陷阱、擁有執行緒限制與 AbandonedMutexException、考量 SetForeg...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。