WPF 多屏与 DPI 感知
大约 17 分钟约 4952 字
WPF 多屏与 DPI 感知
简介
在高分辨率显示器日益普及的今天,WPF 应用程序必须正确处理 DPI 缩放和多显示器场景。Windows 从 Windows 8.1 开始支持 Per-Monitor DPI Awareness(每显示器 DPI 感知),允许应用程序在不同 DPI 的显示器之间正确缩放。WPF 从 .NET 4.6.2 开始原生支持 Per-Monitor DPI,而 .NET 6/7/8 进一步完善了 Per-Monitor V2 支持。本文将深入探讨 WPF 的 DPI 感知机制、多屏窗口管理、DPI 变更处理以及常见问题的解决方案。
特点
DPI 基本概念
DPI 与 DIP 的关系
/// <summary>
/// DPI(Dots Per Inch)表示每英寸像素数。
/// Windows 默认 DPI 为 96(100% 缩放)。
/// DIP(Device Independent Pixel)是 WPF 的逻辑单位。
///
/// 换算关系:
/// 物理像素 = DIP × (DPI / 96)
/// DIP = 物理像素 × (96 / DPI)
///
/// 示例(150% 缩放,DPI = 144):
/// 100 DIP = 100 × (144 / 96) = 150 物理像素
/// </summary>
public static class DpiHelper
{
// 将 DIP 转换为物理像素
public static double DipToPixels(double dip, double dpi)
{
return dip * (dpi / 96.0);
}
// 将物理像素转换为 DIP
public static double PixelsToDip(double pixels, double dpi)
{
return pixels * (96.0 / dpi);
}
// 获取缩放比例
public static double GetScaleFactor(double dpi)
{
return dpi / 96.0;
}
}DPI 感知级别
/// <summary>
/// Windows 定义了三种 DPI 感知级别:
///
/// 1. DPI Unaware(不感知)
/// - 应用不声明 DPI 感知
/// - Windows 通过位图缩放模拟 96 DPI 环境
/// - 结果模糊,不推荐
///
/// 2. System DPI Aware(系统 DPI 感知)
/// - 应用在启动时获取主显示器 DPI
/// - 全局使用该 DPI 值进行缩放
/// - 拖到不同 DPI 的显示器上可能被位图缩放
///
/// 3. Per-Monitor DPI Aware(每显示器 DPI 感知)
/// - 应用感知每个显示器的 DPI
/// - 窗口移动到不同 DPI 的显示器时自动调整
/// - V1: 通知 DPI 变化,应用自行处理
/// - V2: 系统自动缩放非客户区,通知客户区 DPI 变化
/// </summary>
public enum DpiAwarenessLevel
{
Unaware = 0,
SystemDpiAware = 1,
PerMonitorDpiAwareV1 = 2,
PerMonitorDpiAwareV2 = 3
}Manifest 配置
声明 Per-Monitor DPI V2 感知
<!-- 在项目文件中添加应用清单 -->
<!-- 项目 .csproj 中启用清单 -->
<!--
<Project>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
-->
<!-- app.manifest 文件内容 -->
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApp"/>
<!-- 声明 DPI 感知 -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>.NET 6+ 简化配置
<!-- .NET 6+ 可以在 csproj 中直接配置 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
</PropertyGroup>
</Project>// 或者在 App.xaml.cs 中通过代码设置(需在 App 构造函数之前)
public partial class App : Application
{
// .NET 6+ 推荐通过 csproj 配置,此方式作为备选
static App()
{
// Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
// 注意:SetHighDpiMode 必须在 Application 实例化之前调用
}
}DPI 变更处理
Per-Monitor DPI 变更通知
/// <summary>
/// 监听窗口 DPI 变更事件
/// </summary>
public class DpiAwareWindow : Window
{
private double _currentDpi = 96.0;
public DpiAwareWindow()
{
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
// 获取当前窗口所在显示器的 DPI
var source = PresentationSource.FromVisual(this);
if (source?.CompositionTarget != null)
{
_currentDpi = 96.0 * source.CompositionTarget.TransformToDevice.M11;
}
// 监听 DPI 变更
if (source != null)
{
source.ContentRendered += OnContentRendered;
}
// .NET 4.6.2+ 方式
this.DpiChanged += OnDpiChanged;
}
private void OnDpiChanged(object sender, DpiChangedEventArgs e)
{
_currentDpi = e.NewDpi.DpiScaleX * 96.0;
// 获取缩放比例
double scaleFactor = e.NewDpi.DpiScaleX / e.OldDpi.DpiScaleX;
Console.WriteLine(
$"DPI 变更: {e.OldDpi.DpiScaleX * 96} -> {_currentDpi}, " +
$"缩放比: {scaleFactor:F2}");
// 更新自定义绘制内容
UpdateCustomRendering(_currentDpi);
// 更新字体大小等
UpdateFontSize(_currentDpi);
}
private void UpdateCustomRendering(double dpi)
{
// 根据新 DPI 更新自定义渲染逻辑
double scale = dpi / 96.0;
// 例如更新 DrawingVisual 的缩放
}
private void UpdateFontSize(double dpi)
{
double scale = dpi / 96.0;
// 动态调整字体大小(通常 WPF 自动处理)
}
}获取显示器 DPI 信息
/// <summary>
/// 获取显示器 DPI 信息的工具类
/// </summary>
public static class MonitorDpiHelper
{
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
[DllImport("shcore.dll")]
private static extern int GetDpiForMonitor(
IntPtr hmonitor, DpiType dpiType,
out uint dpiX, out uint dpiY);
private enum DpiType : int
{
Effective = 0, // 有效 DPI(考虑缩放设置)
Angular = 1, // 角 DPI
Raw = 2 // 物理 DPI
}
/// <summary>
/// 获取指定窗口所在显示器的 DPI
/// </summary>
public static (uint DpiX, uint DpiY) GetDpiForWindow(Window window)
{
var hwnd = new WindowInteropHelper(window).Handle;
var monitor = MonitorFromWindow(hwnd, 2 /* MONITOR_DEFAULTTONEAREST */);
int result = GetDpiForMonitor(monitor, DpiType.Effective, out uint dpiX, out uint dpiY);
if (result != 0)
{
throw new Win32Exception(result);
}
return (dpiX, dpiY);
}
/// <summary>
/// 获取所有显示器的 DPI 信息
/// </summary>
public static List<MonitorInfo> GetAllMonitorDpi()
{
var monitors = new List<MonitorInfo>();
MonitorEnumCallback callback = (hMonitor, hdcMonitor, lprcMonitor, dwData) =>
{
int result = GetDpiForMonitor(hMonitor, DpiType.Effective, out uint dpiX, out uint dpiY);
if (result == 0)
{
monitors.Add(new MonitorInfo
{
DpiX = dpiX,
DpiY = dpiY,
ScalePercent = (dpiX / 96.0) * 100
});
}
return true;
};
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, callback, IntPtr.Zero);
return monitors;
}
private delegate bool MonitorEnumCallback(
IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData);
[DllImport("user32.dll")]
private static extern bool EnumDisplayMonitors(
IntPtr hdc, IntPtr lprcClip,
MonitorEnumCallback lpfnEnum, IntPtr dwData);
}
public class MonitorInfo
{
public uint DpiX { get; set; }
public uint DpiY { get; set; }
public double ScalePercent { get; set; }
public override string ToString() =>
$"DPI: {DpiX}x{DpiY}, Scale: {ScalePercent:F0}%";
}LayoutTransform vs RenderTransform
两种变换的区别与 DPI 场景应用
/// <summary>
/// LayoutTransform 和 RenderTransform 在 DPI 场景下的使用
///
/// LayoutTransform:
/// - 影响布局计算(先变换再布局)
/// - 其他元素会响应变换后的尺寸
/// - 适合需要布局系统感知的场景
///
/// RenderTransform:
/// - 不影响布局(先布局再变换)
/// - 其他元素不受影响
/// - 适合纯视觉效果的缩放
/// </summary>
public partial class DpiTransformDemo : Window
{
public DpiTransformDemo()
{
Title = "DPI Transform Demo";
var panel = new StackPanel { Margin = new Thickness(10) };
// LayoutTransform 示例:DPI 缩放后保持布局正确
var layoutBorder = new Border
{
Background = Brushes.LightBlue,
Padding = new Thickness(10),
Child = new TextBlock { Text = "LayoutTransform 缩放" }
};
// 使用 LayoutTransform 缩放(影响布局)
var layoutScale = new ScaleTransform(1.5, 1.5);
layoutBorder.LayoutTransform = layoutScale;
panel.Children.Add(layoutBorder);
// RenderTransform 示例:纯视觉缩放
var renderBorder = new Border
{
Background = Brushes.LightCoral,
Padding = new Thickness(10),
Child = new TextBlock { Text = "RenderTransform 缩放" }
};
// 使用 RenderTransform 缩放(不影响布局)
var renderScale = new ScaleTransform(1.5, 1.5);
renderBorder.RenderTransform = renderScale;
renderBorder.RenderTransformOrigin = new Point(0, 0);
panel.Children.Add(renderBorder);
Content = panel;
}
}动态响应 DPI 变化的缩放
/// <summary>
/// 根据 DPI 动态调整缩放的自定义控件
/// </summary>
public class DpiAwareControl : ContentControl
{
private ScaleTransform _scaleTransform;
private double _designTimeDpi = 96.0;
static DpiAwareControl()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(DpiAwareControl),
new FrameworkPropertyMetadata(typeof(DpiAwareControl)));
}
public DpiAwareControl()
{
_scaleTransform = new ScaleTransform(1.0, 1.0);
this.LayoutTransform = _scaleTransform;
this.Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
var window = Window.GetWindow(this);
if (window != null)
{
window.DpiChanged += OnWindowDpiChanged;
UpdateScale(window);
}
}
private void OnWindowDpiChanged(object sender, DpiChangedEventArgs e)
{
double newDpi = e.NewDpi.DpiScaleX * 96.0;
double scale = newDpi / _designTimeDpi;
// 不直接缩放 WPF 控件(WPF 自动处理)
// 仅对自定义绘制内容应用缩放
_scaleTransform.ScaleX = 1.0;
_scaleTransform.ScaleY = 1.0;
}
private void UpdateScale(Window window)
{
var source = PresentationSource.FromVisual(window);
if (source?.CompositionTarget != null)
{
double currentDpi = 96.0 * source.CompositionTarget.TransformToDevice.M11;
// WPF 自动处理 DPI 缩放,无需手动干预
}
}
}位图缩放处理
高 DPI 下的图片处理策略
/// <summary>
/// 高 DPI 下图片的正确处理
/// </summary>
public static class DpiBitmapHelper
{
/// <summary>
/// 根据 DPI 加载合适分辨率的图片
/// </summary>
public static BitmapImage LoadImageForDpi(string basePath, double dpi)
{
double scale = dpi / 96.0;
// 根据缩放比选择不同分辨率的图片
string scaleFactor = scale switch
{
>= 2.0 => "@3x",
>= 1.5 => "@2x",
>= 1.25 => "@1.5x",
_ => ""
};
// 构建文件名:image@2x.png
string directory = Path.GetDirectoryName(basePath) ?? "";
string fileName = Path.GetFileNameWithoutExtension(basePath);
string extension = Path.GetExtension(basePath);
string scaledPath = Path.Combine(directory, $"{fileName}{scaleFactor}{extension}");
// 如果缩放版本不存在,使用原始版本
string finalPath = File.Exists(scaledPath) ? scaledPath : basePath;
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(finalPath, UriKind.RelativeOrAbsolute);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
if (bitmap.CanFreeze)
bitmap.Freeze();
return bitmap;
}
/// <summary>
/// 使用 RenderOptions 优化图片渲染
/// </summary>
public static Image CreateDpiAwareImage(string imagePath)
{
var image = new Image
{
Source = new BitmapImage(new Uri(imagePath, UriKind.RelativeOrAbsolute)),
Width = 32, // 逻辑尺寸(DIP)
Height = 32,
Stretch = Stretch.Uniform
};
// 设置高质量缩放模式
RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality);
// 使用 EdgeMode.Aliased 避免文本模糊
RenderOptions.SetEdgeMode(image, EdgeMode.Aliased);
return image;
}
}DrawingImage 的 DPI 感知
/// <summary>
/// 矢量图标(DrawingImage)天然支持 DPI 缩放
/// </summary>
public class VectorIconHelper
{
/// <summary>
/// 创建 DPI 无关的矢量图标
/// </summary>
public static DrawingImage CreateVectorIcon(Color fillColor, double size)
{
// 矢量图形不受 DPI 影响,始终清晰
var group = new DrawingGroup();
// 使用 Geometry 绘制矢量图标
var geometry = Geometry.Parse(
"M12,2 L22,22 L2,22 Z"); // 三角形示例
var brush = new SolidColorBrush(fillColor);
var pen = new Pen(Brushes.Transparent, 0);
group.Children.Add(new GeometryDrawing(brush, pen, geometry));
if (group.CanFreeze)
group.Freeze();
return new DrawingImage(group);
}
}多屏窗口管理
窗口位置记忆与恢复
/// <summary>
/// 多屏环境下的窗口位置管理
/// </summary>
public class WindowPlacementManager
{
private readonly string _settingsPath;
public WindowPlacementManager(string appName)
{
_settingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
appName, "window_placement.json");
}
/// <summary>
/// 保存窗口位置和 DPI 信息
/// </summary>
public void SavePlacement(Window window)
{
var source = PresentationSource.FromVisual(window);
double dpiScale = source?.CompositionTarget?.TransformToDevice.M11 ?? 1.0;
var placement = new WindowPlacement
{
Left = window.Left,
Top = window.Top,
Width = window.Width,
Height = window.Height,
WindowState = window.WindowState,
DpiScale = dpiScale,
MonitorName = GetMonitorName(window)
};
string json = JsonSerializer.Serialize(placement, new JsonSerializerOptions
{
WriteIndented = true
});
string? dir = Path.GetDirectoryName(_settingsPath);
if (dir != null) Directory.CreateDirectory(dir);
File.WriteAllText(_settingsPath, json);
}
/// <summary>
/// 恢复窗口位置(验证显示器仍存在)
/// </summary>
public bool RestorePlacement(Window window)
{
if (!File.Exists(_settingsPath))
return false;
try
{
string json = File.ReadAllText(_settingsPath);
var placement = JsonSerializer.Deserialize<WindowPlacement>(json);
if (placement == null)
return false;
// 验证目标位置是否在有效的显示器范围内
if (IsPositionOnScreen(placement.Left, placement.Top))
{
window.Left = placement.Left;
window.Top = placement.Top;
window.Width = Math.Min(placement.Width, SystemParameters.PrimaryScreenWidth * 0.9);
window.Height = Math.Min(placement.Height, SystemParameters.PrimaryScreenHeight * 0.9);
window.WindowState = placement.WindowState;
return true;
}
// 显示器已断开,恢复默认位置
return false;
}
catch
{
return false;
}
}
/// <summary>
/// 检查坐标是否在某个屏幕范围内
/// </summary>
private bool IsPositionOnScreen(double x, double y)
{
foreach (var screen in WpfScreen.AllScreens())
{
var bounds = screen.Bounds;
if (x >= bounds.Left && x <= bounds.Right &&
y >= bounds.Top && y <= bounds.Bottom)
{
return true;
}
}
return false;
}
private string GetMonitorName(Window window)
{
var hwnd = new WindowInteropHelper(window).Handle;
var monitor = MonitorFromWindow(hwnd, 2);
// 简化实现,实际需要 GetMonitorInfo 获取名称
return monitor.ToString();
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
}
public class WindowPlacement
{
public double Left { get; set; }
public double Top { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public WindowState WindowState { get; set; }
public double DpiScale { get; set; }
public string MonitorName { get; set; } = "";
}显示器信息工具类
/// <summary>
/// 封装 System.Windows.Forms.Screen 的 WPF 兼容版本
/// </summary>
public class WpfScreen
{
private readonly System.Windows.Forms.Screen _screen;
private WpfScreen(System.Windows.Forms.Screen screen)
{
_screen = screen;
}
/// <summary>
/// 获取显示器边界(WPF 坐标)
/// </summary>
public Rect Bounds
{
get
{
var bounds = _screen.Bounds;
var source = PresentationSource.FromVisual(Application.Current.MainWindow);
double dpiX = source?.CompositionTarget?.TransformFromDevice.M11 ?? 1.0;
double dpiY = source?.CompositionTarget?.TransformFromDevice.M22 ?? 1.0;
return new Rect(
bounds.X * dpiX,
bounds.Y * dpiY,
bounds.Width * dpiX,
bounds.Height * dpiY);
}
}
/// <summary>
/// 获取工作区(排除任务栏)
/// </summary>
public Rect WorkingArea
{
get
{
var area = _screen.WorkingArea;
var source = PresentationSource.FromVisual(Application.Current.MainWindow);
double dpiX = source?.CompositionTarget?.TransformFromDevice.M11 ?? 1.0;
double dpiY = source?.CompositionTarget?.TransformFromDevice.M22 ?? 1.0;
return new Rect(
area.X * dpiX,
area.Y * dpiY,
area.Width * dpiX,
area.Height * dpiY);
}
}
public bool IsPrimary => _screen.Primary;
public string DeviceName => _screen.DeviceName;
/// <summary>
/// 获取所有显示器
/// </summary>
public static IEnumerable<WpfScreen> AllScreens()
{
return System.Windows.Forms.Screen.AllScreens
.Select(s => new WpfScreen(s));
}
/// <summary>
/// 获取包含指定点的显示器
/// </summary>
public static WpfScreen FromPoint(Point point)
{
var screen = System.Windows.Forms.Screen.FromPoint(
new System.Drawing.Point((int)point.X, (int)point.Y));
return new WpfScreen(screen);
}
/// <summary>
/// 获取包含指定窗口的显示器
/// </summary>
public static WpfScreen FromWindow(Window window)
{
var hwnd = new WindowInteropHelper(window).Handle;
var screen = System.Windows.Forms.Screen.FromHandle(hwnd);
return new WpfScreen(screen);
}
}Per-Monitor DPI V2 特性
Top-Level Window 的自动缩放
/// <summary>
/// Per-Monitor DPI V2 的关键特性:
/// 1. 非客户区(标题栏、菜单、滚动条)自动缩放
/// 2. Win32 菜单和对话框自动缩放
/// 3. 拖放操作自动缩放坐标
/// 4. 系统渲染的位图自动缩放
/// </summary>
public class PerMonitorV2Window : Window
{
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var hwndSource = PresentationSource.FromVisual(this) as HwndSource;
if (hwndSource != null)
{
// 订阅 WM_DPICHANGED 消息
hwndSource.AddHook(WndProc);
}
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
const int WM_DPICHANGED = 0x02E0;
const int WM_DPICHANGED_BEFOREPARENT = 0x02E2;
const int WM_DPICHANGED_AFTERPARENT = 0x02E3;
switch (msg)
{
case WM_DPICHANGED:
{
// wParam 低字 = X DPI, 高字 = Y DPI
uint dpiX = (uint)(wParam.ToInt64() & 0xFFFF);
uint dpiY = (uint)((w wParam.ToInt64() >> 16) & 0xFFFF);
// lParam 指向建议的窗口矩形
var rect = Marshal.PtrToStructure<RECT>(lParam);
Console.WriteLine($"WM_DPICHANGED: DPI={dpiX}x{dpiY}");
// Per-Monitor V2 中,WPF 自动处理大部分工作
// 此处可以执行自定义逻辑
break;
}
case WM_DPICHANGED_BEFOREPARENT:
{
// 子窗口在 DPI 变更前收到此消息
break;
}
case WM_DPICHANGED_AFTERPARENT:
{
// 子窗口在父窗口 DPI 变更后收到此消息
break;
}
}
return IntPtr.Zero;
}
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left, Top, Right, Bottom;
}
}常见 DPI 问题与修复
模糊问题排查清单
/// <summary>
/// DPI 问题诊断工具
/// </summary>
public static class DpiDiagnosticTool
{
/// <summary>
/// 诊断当前应用的 DPI 状态
/// </summary>
public static string Diagnose(Window window)
{
var sb = new StringBuilder();
// 1. 检查应用 DPI 感知级别
var dpiAwareness = GetDpiAwareness();
sb.AppendLine($"DPI 感知级别: {dpiAwareness}");
// 2. 检查当前窗口 DPI
var source = PresentationSource.FromVisual(window);
if (source?.CompositionTarget != null)
{
double dpiX = 96.0 * source.CompositionTarget.TransformToDevice.M11;
double dpiY = 96.0 * source.CompositionTarget.TransformToDevice.M22;
sb.AppendLine($"当前窗口 DPI: {dpiX:F1} x {dpiY:F1}");
}
// 3. 检查显示器信息
var screen = WpfScreen.FromWindow(window);
sb.AppendLine($"显示器边界: {screen.Bounds}");
sb.AppendLine($"是否主显示器: {screen.IsPrimary}");
// 4. 检查 UseLayoutRounding
sb.AppendLine($"UseLayoutRounding: {window.UseLayoutRounding}");
// 5. 检查 SnapToDevicePixels
sb.AppendLine($"SnapToDevicePixels: {window.SnapToDevicePixels}");
return sb.ToString();
}
private static string GetDpiAwareness()
{
int result = GetProcessDpiAwareness(
GetCurrentProcess(), out PROCESS_DPI_AWARENESS awareness);
if (result == 0)
{
return awareness.ToString();
}
return "Unknown";
}
[DllImport("shcore.dll")]
private static extern int GetProcessDpiAwareness(
IntPtr hprocess, out PROCESS_DPI_AWARENESS awareness);
[DllImport("kernel32.dll")]
private static extern IntPtr GetCurrentProcess();
private enum PROCESS_DPI_AWARENESS
{
DPI_UNAWARE = 0,
SYSTEM_DPI_AWARE = 1,
PER_MONITOR_DPI_AWARE = 2
}
}常用修复手段
/// <summary>
/// 修复常见 DPI 问题的扩展方法
/// </summary>
public static class DpiFixExtensions
{
/// <summary>
/// 为窗口启用 DPI 感知优化
/// </summary>
public static void EnableDpiAwareness(this Window window)
{
// 1. 启用布局舍入(避免亚像素模糊)
window.UseLayoutRounding = true;
// 2. 启用像素对齐(文字更清晰)
window.SnapToDevicePixels = true;
// 3. 设置文本渲染模式
TextOptions.SetTextFormattingMode(window, TextFormattingMode.Display);
TextOptions.SetTextRenderingMode(window, TextRenderingMode.Auto);
}
/// <summary>
/// 为所有控件启用 DPI 优化
/// </summary>
public static void EnableDpiAwarenessForAll()
{
// 全局设置 UseLayoutRounding
FrameworkElement.UseLayoutRoundingProperty.OverrideMetadata(
typeof(FrameworkElement),
new FrameworkPropertyMetadata(true));
// 全局设置文本渲染模式
TextOptions.TextFormattingModeProperty.OverrideMetadata(
typeof(Window),
new FrameworkPropertyMetadata(TextFormattingMode.Display));
}
}混合模式窗口的 DPI 处理
/// <summary>
/// 处理 WPF + WinForms 混合模式的 DPI 问题
/// </summary>
public class MixedModeDpiHandler
{
/// <summary>
/// 在 WPF 中嵌入 WinForms 控件时的 DPI 处理
/// </summary>
public static void SetupWindowsFormsHost(WindowsFormsHost host, double currentDpi)
{
// WinForms 控件需要手动设置 AutoScaleMode
var winFormsControl = host.Child as System.Windows.Forms.Control;
if (winFormsControl != null)
{
winFormsControl.AutoScaleDimensions =
new System.Drawing.SizeF((float)currentDpi / 96f * 6F, (float)currentDpi / 96f * 13F);
winFormsControl.AutoScaleMode =
System.Windows.Forms.AutoScaleMode.Font;
}
}
}测试策略
跨显示器 DPI 测试
/// <summary>
/// DPI 测试辅助工具 — 模拟不同 DPI 场景
/// </summary>
public static class DpiTestHelper
{
/// <summary>
/// 测试矩阵:常见 DPI 设置
/// </summary>
public static readonly double[] CommonDpiValues = { 96, 120, 144, 168, 192, 240, 288 };
/// <summary>
/// 验证窗口在不同 DPI 下的布局
/// </summary>
public static void TestDpiLayout(Window window, double targetDpi)
{
double scale = targetDpi / 96.0;
// 模拟 DPI 变更
var args = new DpiChangedEventArgs(
new DpiScale(1.0, 1.0), // 旧 DPI
new DpiScale(scale, scale), // 新 DPI
new Size(window.Width, window.Height));
// 触发 DPI 变更
// 注意:实际测试应在不同缩放设置的显示器上进行
Console.WriteLine($"测试 DPI: {targetDpi}, 缩放: {scale:P0}");
// 验证内容是否完整可见
VerifyContentVisibility(window);
}
private static void VerifyContentVisibility(Window window)
{
// 检查所有子元素是否在可视区域内
if (window.Content is FrameworkElement content)
{
double actualWidth = content.ActualWidth;
double actualHeight = content.ActualHeight;
Console.WriteLine(
$"内容尺寸: {actualWidth:F0} x {actualHeight:F0}");
}
}
}优点
缺点
性能注意事项
总结
WPF 的 DPI 感知机制随着 .NET 版本迭代日趋完善。Per-Monitor DPI V2 是当前推荐的感知级别,它让 WPF 应用在多显示器环境下表现一致。关键要点:通过 manifest 声明 DPI 感知级别、使用 DIP 单位系统、提供多分辨率图片资源、正确处理 DPI 变更事件、使用 UseLayoutRounding 和 SnapToDevicePixels 提升渲染质量。
关键知识点
- DPI(Dots Per Inch)是物理概念,DIP(Device Independent Pixel)是 WPF 逻辑单位
- Per-Monitor V2 是当前最完善的 DPI 感知级别,推荐所有 WPF 应用使用
- WPF 的 DIP 系统让大部分控件自动适配不同 DPI
- LayoutTransform 影响布局,RenderTransform 仅影响渲染
- 高 DPI 下必须提供多分辨率图片或使用矢量图形
- UseLayoutRounding = true 可解决亚像素模糊问题
- 混合 WinForms 控件需要额外的 DPI 处理
- 窗口位置记忆必须验证目标显示器仍然连接
常见误区
- 误区:WPF 自动处理所有 DPI 问题,无需额外配置
纠正:需要通过 manifest 声明 DPI 感知级别,否则 Windows 会进行位图缩放导致模糊 - 误区:使用固定像素值设置控件大小
纠正:应使用 DIP 单位,WPF 会自动根据 DPI 进行换算 - 误区:所有图片都会自动适配高 DPI
纠正:光栅图片需要提供多分辨率版本,或使用矢量图形 - 误区:Per-Monitor V1 和 V2 效果相同
纠正:V2 自动缩放非客户区,V1 需要手动处理 - 误区:使用 RenderTransform 缩放可以解决 DPI 问题
纠正:RenderTransform 不影响布局,可能导致布局错乱
进阶路线
- 初级:理解 DPI/DIP 概念,配置 manifest,启用 Per-Monitor V2
- 中级:处理 DPI 变更事件,多屏窗口管理,图片多分辨率支持
- 高级:自定义渲染的 DPI 适配,混合 WinForms 的 DPI 处理
- 专家级:Per-Monitor V2 源码级理解,自定义 DPI 缩放策略,跨版本兼容方案
适用场景
- 混合显示器环境(高分屏 + 普通屏)
- 工业控制软件需要在不同分辨率的面板上部署
- 医疗影像软件需要精确的像素级渲染
- 多显示器交易终端
- 4K/5K 显示器上的桌面应用
落地建议
- 新项目一律使用 .NET 8 + Per-Monitor V2,在 csproj 中配置
- 设计阶段使用矢量图标(DrawingImage/SVG),避免光栅图片
- 必须使用光栅图片时,准备 @1x、@1.5x、@2x 三套资源
- 窗口基类中统一处理 DPI 变更和位置记忆
- 在不同缩放比例(100%、125%、150%、200%)下全面测试
- CI/CD 中加入 DPI 相关的自动化 UI 测试
排错清单
复盘问题
- 应用在 100% 缩放下正常,150% 下布局错乱,根因是什么?
- 窗口拖到第二个显示器后变模糊,如何解决?
- 为什么设置了 Per-Monitor V2 但标题栏仍然模糊?
- WindowsFormsHost 中的控件在高 DPI 下显示异常,如何修复?
- 自定义绘制的内容在 DPI 变更后尺寸不对,如何处理?
- 如何在不重启应用的情况下切换 DPI 感知级别?
- 使用 ScaleTransform 缩放窗口内容与 WPF 原生 DPI 缩放有什么区别?
- 多屏环境下如何确保弹出的对话框出现在正确的显示器上?
