Высокий DPI в WPF — почему всё «должно быть само в порядке», а на деле размывается и плывёт, и что с этим делать

· · WPF, Высокий DPI, Windows, .NET, C#, .NET Framework, XAML, UI, Техническая консультация

«В WPF ведь вообще не нужно ничего делать для поддержки DPI?» — этот вопрос мы часто слышим на консультациях по высокому DPI. Наполовину это правда, наполовину нет. На практике к нам приходят с такими симптомами: «на ноутбуке само по себе всё чётко, но стоит подключить внешний монитор в переговорной — весь экран плывёт», «текст чёткий, а иконки на панели инструментов размыты», «линии в списке в одних местах толще, в других тоньше или вовсе пропадают», «старый элемент управления отчётами, встроенный в экран, — единственный, что мельче остальных». Всё это реально случается в приложениях на WPF, которые «должны быть устойчивы к DPI».

В предыдущей статье «Высокий DPI в WinForms» мы разобрали механизм масштабирования DPI в Windows, режимы DPI-осведомлённости (Unaware / System Aware / Per-Monitor V2) и то, как спасти WinForms-приложение из эпохи жёстко прошитых 96 DPI. Эта статья — версия для WPF. Общую теорию DPI-виртуализации и режимов осведомлённости оставим статье о WinForms, а здесь сосредоточимся на специфике WPF: почему и где остаются проблемы во фреймворке, который «должен быть DPI-осведомлён с самого начала». Разберём по порядку, в каком это применяется на практике: диагностику симптомов, объявление Per-Monitor DPI (отдельно для .NET Framework 4.6.2 и для .NET), приёмы против размытия линий и растровых изображений, ловушку смешанного контента с WindowsFormsHost и, наконец, таблицу для решения о том, насколько глубоко стоит заходить.

1. Сначала вывод

  • WPF с самого начала корректно следует за «DPI на момент запуска». Единица разметки — device-independent unit (DIP), 1/96 дюйма, и система отрисовки автоматически масштабируется под системный DPI, поэтому по умолчанию приложение — System DPI Aware. Настройка и проверка вроде AutoScaleMode в WinForms здесь не нужны. 1
  • Тем не менее остаются проблемы четырёх видов: (a) общее размытие при переносе окна на монитор с другим DPI, (b) размытие растровых изображений и иконок, (c) размытие тонких линий и рамок, (d) смешанный контент вроде WindowsFormsHost / WebBrowser. Причины и способы исправления у них разные, поэтому сначала определите свой случай по таблице в главе 3.
  • Радикальное решение для (a) — поддержка Per-Monitor DPI. WPF на .NET Framework 4.6.2 и новее поддерживает это на уровне фреймворка: достаточно объявить это в манифесте, и пересчёт масштаба окна выполняется автоматически. 23
  • WPF на .NET (Core 3.1–.NET 8) тоже по умолчанию остаётся System Aware. Аналога ApplicationHighDpiMode / SetHighDpiMode из WinForms у WPF нет, способ объявления такой же, как в .NET Framework, — через манифест.
  • (c) — это, по сути, проблема субпиксельного позиционирования, которая существовала ещё до появления высокого DPI. Первый шаг — UseLayoutRounding="True" на корневом элементе, а SnapsToDevicePixels — отдельный инструмент, привязка к пикселям уже при отрисовке. Оба по умолчанию выключены. 45
  • Для (b) приоритетный вариант — векторные активы, такие как Path / Geometry. Активы, существующие только как растр, готовят в нескольких разрешениях и переключают по DPI. Стандартная интерполяция WPF (Linear) сильнее всего размывает именно маленькие изображения вроде иконок, особенно при неинтегральном масштабе. 6
  • Смешанный контент из (d) задаёт потолок того, насколько далеко можно зайти с Per-Monitor. В частности, Per-Monitor поведение WPF, «загруженного» в ElementHost / HwndSource, официально описано как неподдерживаемое. 3
  • Общая теория режимов DPI-осведомлённости (DPI-виртуализация, разница между System Aware и Per-Monitor V2, переопределение пользователем через свойства exe-файла) и способ настройки тестового окружения со смешанным DPI общие с главами 2–3 и 7 статьи о WinForms, поэтому здесь не повторяются.

2. Почему WPF «устойчив к DPI» — DIP и System DPI Aware

Корень проблемы высокого DPI в WinForms — в том, что и координаты, и размеры жёстко прошиты в физических пикселях, а механизм, пересчитывающий разметку, рассчитанную на 96 DPI, во время выполнения (AutoScale), пристроен уже потом. У WPF другая отправная точка. 120 в записи Width="120" в XAML — это не физические пиксели, а device-independent unit (DIP), где 1 единица равна 1/96 дюйма. Вся разметка рассчитывается в этой единице и при отрисовке преобразуется в множитель, соответствующий системному DPI (при 150% — в 1,5 раза). Текст тоже рисуется как векторный шрифт, поэтому при увеличении не возникает деградации растрового типа. Благодаря такому устройству приложение на WPF работает как System DPI Aware без каких-либо объявлений. 1

Сведём различия с WinForms в таблицу.

  WinForms WPF
Единица координат и размеров Физические пиксели DIP (1/96 дюйма)
Поведение без объявлений Unaware (размывается растровым растяжением ОС) System Aware (чётко на главном мониторе)
Отслеживание DPI при запуске Пересчёт на уровне формы через AutoScaleMode. Требует настройки и проверки Автоматическое масштабирование системой отрисовки. Настройка не нужна
Типичный сбой на высоком DPI Наложение и обрезка элементов при фиксированной координатной разметке Разметка не ломается, проявляется как размытие
Проблемы, вызванные дизайнером Перезапись AutoScaleDimensions (классика) Практически отсутствуют (XAML сохраняется как есть, в DIP)

Как видно из таблицы, работа, которая в WinForms занимала основную часть трудозатрат — «чинить ломающуюся разметку» и «беречься от сбоев дизайнера», — в WPF практически не возникает. «WPF устойчив к DPI» — справедливое утверждение.

Однако автоматизм действует только в пределах System Aware. System Aware означает «подстроиться под DPI главного монитора на момент входа в систему», а не отслеживание (Per-Monitor) окружения, где у разных мониторов разный DPI. Кроме того, в DIP масштабируется только то, что WPF рисует само (текст, фигуры, элементы управления): растровые изображения при увеличении всё равно размываются, а смешанный контент, приносящий с собой HWND, оказывается вне масштабирования WPF. Обращения вида «должно же быть устойчиво к DPI» почти всегда возникают именно из-за этого остатка.

3. Проблемы, которые всё же возникают — определяем причину по симптому

Вот таблица диагностики, которую мы первой достаём на консультации. В WPF соответствие между симптомом и причиной ещё более чёткое, чем в WinForms, поэтому одной этой таблицы обычно достаточно, чтобы определить причину.

Симптом Причина Решение
При переносе на монитор с другим DPI всё равномерно размывается. При возврате на исходный монитор всё в порядке Осталось System Aware. ОС растягивает всё окно как растровое изображение Глава 4 (переход на Per-Monitor)
Размывается сразу после изменения настроек масштабирования или при RDP-подключении с высокого-DPI клиента То же самое (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

Поясним первую строку — «размывается равномерно везде». Это состояние, когда DPI-виртуализация (растровое растяжение ОС), описанная в главе 2 статьи о WinForms, действует на приложение System Aware, — само приложение не сломано. Приложение System Aware рисует под «DPI главного монитора на момент входа в систему», а на мониторе с другим DPI ОС масштабирует изображение, чтобы свести концы с концами. Разметка не ломается, но взамен размывается — это спасательная мера со стороны ОС. 1 Исправить это означает отказаться от этой подстраховки и заявить «я буду сам отслеживать DPI каждого монитора», то есть перейти на Per-Monitor.

И наоборот, симптомы начиная с третьей строки можно исправить, оставаясь System Aware. Если запрос звучит как «у нас нет мультимониторной эксплуатации, но на 150% иконки и линии выглядят неопрятно», можно пропустить главу 4 и сразу перейти к главе 5.

4. Размытие при мультимониторной работе — поддержка Per-Monitor DPI

4.1 Предел возможностей System Aware

System DPI фиксируется при входе в систему по DPI главного монитора. Если главный монитор — встроенный экран ноутбука с 150%, WPF рисует все окна с масштабом 1,5, а при переносе на внешний монитор с 100% ОС показывает изображение уменьшенным до 2/3. Такое масштабирование со стороны ОС выглядит особенно размытым при неинтегральных коэффициентах. 1 Если проверить на практике, становится заметно, что неудобное сочетание вроде 125% и 150% визуально деградирует сильнее, чем чистая разница в 2 раза между 200% и 100%.

Иными словами, приложение System Aware на WPF доставляет неудобства только в ситуациях перемещения между мониторами с разным DPI и изменения настройки масштабирования уже после входа в систему (смена главного монитора при подключении/отключении док-станции, DPI клиента, приносимый через RDP, и т. п.). Для внутреннего приложения, которым все пользуются на единственном мониторе с фиксированным рабочим столом, оставаться System Aware вполне достаточно на практике. К этому решению мы ещё вернёмся в таблице главы 8.

4.2 Способ объявления в .NET Framework 4.6.2 и новее

Поддержка Per-Monitor DPI в WPF появилась в .NET Framework 4.6.2. 2 До этого, даже если объявить Per-Monitor операционной системе, сам WPF не отслеживал изменение DPI, и пересчёт масштаба окна приходилось писать полностью самостоятельно (именно поэтому образцы времён Windows 8.1 строились на масштабной обвязке вокруг нативной вспомогательной DLL). Начиная с 4.6.2 WPF сам обрабатывает WM_DPICHANGED и автоматически берёт на себя изменение размера окна, повторную разметку и перерисовку.

Требований два: ОС не старше Windows 10 Anniversary Update (1607) и сборка с таргетом не старше .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 (как в руководстве разработчика).
         Поддержка PerMonitorV2 в WPF появилась только с .NET Framework 4.8,
         поэтому на 4.8+ / .NET ставим V2 первым: "PerMonitorV2, PerMonitor" -->
    <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"
      >PerMonitor</dpiAwareness>
    <!-- Для старых ОС, не распознающих dpiAwareness (откат к System Aware) -->
    <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
  </asmv3:windowsSettings>
</asmv3:application>

У этой двухуровневой конструкции есть смысл. Элемент dpiAwareness распознаётся начиная с Windows 10 1607, и из перечисленных через запятую значений используется первое из распознанных. 7 На старых ОС, которые вообще не знают элемент dpiAwareness, происходит откат к объявлению dpiAware (System Aware) — вот в чём тут механизм.

Здесь важно выбирать между PerMonitor и PerMonitorV2 в зависимости от целевого фреймворка. Официальная поддержка PerMonitorV2 (и Mixed-Mode DPI) в WPF начинается с .NET Framework 4.8 8, и даже пример в руководстве разработчика для 4.6.2 объявляет одиночный PerMonitor. 3 Если при таргете 4.6.2–4.7.2 поставить V2 первым, ОС начиная с Windows 10 1703 выберет режим V2, который фреймворк фактически не поддерживает, и это приведёт к неожиданному поведению, особенно вокруг смешанного контента вроде WindowsFormsHost. Наша рекомендация — сначала перейти на 4.8 и новее (или на WPF в составе .NET), а затем указывать V2 первым как PerMonitorV2, PerMonitor, отдавая масштабирование неклиентских областей вроде заголовка окна и полос прокрутки на откуп ОС (см. главу 3 статьи о WinForms).

Отдельно отметим одну ловушку, связанную с целевым фреймворком. Даже если установленный на машине .NET Framework не старше 4.6.2, если таргет проекта остался на 4.6.1 или раньше, отслеживание Per-Monitor по умолчанию отключено. В этом случае его нужно явно включить через AppContext-переключатель в app.config. 3

<configuration>
  <runtime>
    <!-- Осторожно, двойное отрицание: "не масштабировать при изменении DPI" выставляем в false = включаем -->
    <AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
  </runtime>
</configuration>

По нашему опыту, если «манифест написали, а не работает», причина почти всегда одна из двух: либо этот вопрос с целевым фреймворком, либо манифест фактически не попадает в сборку (в настройках проекта всё ещё указан манифест по умолчанию).

4.3 Способ объявления в .NET (Core 3.1–.NET 8)

WPF на .NET тоже по умолчанию, без манифеста, остаётся System Aware. В отличие от WinForms, у которой есть выделенная точка входа через ApplicationHighDpiMode в файле проекта или Application.SetHighDpiMode, у WPF нет официального механизма переключения режима DPI-осведомлённости из кода или настроек проекта. Способ объявления такой же, как в .NET Framework: добавьте в проект app.manifest (в Visual Studio — «Добавить новый элемент» → «Файл манифеста приложения»), напишите то же объявление dpiAwareness, что и в разделе 4.2, и сошлитесь на него из файла проекта.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <ApplicationManifest>app.manifest</ApplicationManifest>
  </PropertyGroup>
</Project>

Поскольку среда выполнения новее, AppContext-переключатель из раздела 4.2 не нужен. Обратите внимание только на то, что «перешли на .NET — и высокий DPI автоматически решился» — не так. Облегчение от миграции касается стороны WinForms (раздел 4.1 статьи о WinForms) — 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". Побочный эффект округления — колонки, разделённые звёздочным размером поровну, могут стать неравными на пиксель-другой, но на практике это редко становится проблемой на экранах бизнес-приложений. Для размытия в коде, где отрисовка идёт через DrawingContext вручную, есть и низкоуровневый инструмент GuidelineSet, но в большинстве случаев достаточно просто округлить координаты отрисовки.

5.2 Размытие текста — TextFormattingMode

Старое недовольство «текст в WPF бледный, размытый» на самом деле связано скорее с режимом форматирования текста, чем с DPI как таковым. У текстового форматирования в WPF два режима: Ideal (по умолчанию), где символы размещаются по истинным идеальным метрикам шрифта, и Display, где размещение идёт по метрикам, совместимым с GDI. 9 Ideal даёт красивые межсимвольные интервалы и хорошо масштабируется, но при отрисовке мелкого текста на 96 DPI (100%) может казаться размытым из-за сглаживания. Установка TextOptions.TextFormattingMode="Display" на корне даёт то самое чёткое ощущение в духе WinForms.

Однако в среде с высоким DPI ситуация переворачивается. Display форматирует символы под сетку пикселей 96 DPI, поэтому при масштабировании качество, наоборот, может ухудшиться. Практичное правило: если большинство пользователей на 100% — используйте Display, а в сегодняшней стандартной среде, где преобладает 125% и выше, оставляйте Ideal по умолчанию. Возможна и реализация, переключающая на Display только при 100%, но экранов, где это стоит трудозатрат (в духе текстового редактора), не так много.

5.3 Размытие изображений и иконок — приоритет вектора и несколько разрешений

WPF тоже размещает растровое изображение, назначенное Image, в DIP и масштабирует его под DPI. Иконка 16×16px при 150% интерполяцией увеличивается до 24×24px, и стандартный алгоритм интерполяции (Linear) заметно её размывает. 6 Внешний вид «текст чёткий, а иконки будто сонные» в приложении на WPF почти всегда объясняется именно этим.

Есть три меры, в порядке приоритета.

  1. Перевести в векторный актив (первый выбор). Иконки, хранимые как Path / Geometry / DrawingImage, отрисовываются чётко при любом DPI и не требуют кода переключения. Инструментов для конвертации из дизайнерских программ или SVG в XAML достаточно. Стоит закрепить правилом, что иконки нового приложения изначально хранятся в векторе. Такой же эффект дают значки-шрифты вроде Segoe MDL2 Assets.
  2. Переключать несколько разрешений растра по DPI. Для активов, которые нельзя векторизовать, — фотографии, скриншоты и подобное — готовят версии под 96 / 120 / 144 / 192 DPI (например, 16 / 20 / 24 / 32px) и выбирают в зависимости от текущего DPI. Официальное руководство разработчика тоже рекомендует переключение активов по DPI как способ борьбы с размытием. 1
  3. Подобрать интерполяцию через 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 оказывается код, который сам считает физические пиксели. Типичны три случая, и все они проявляются одинаково неприятно: «на машине разработчика (100%) идеально, а у заказчика на 150% размывается или смещается».

Первый случай — код, самостоятельно создающий пиксельный буфер, например WriteableBitmap / RenderTargetBitmap. Если создать его с количеством пикселей, равным размеру в DIP, при 150% он будет интерполяцией увеличен в 1,5 раза и размоется. Текущий DPI можно получить из структуры DpiScale, возвращаемой VisualTreeHelper.GetDpi, поэтому количество пикселей задавайте в физических пикселях, а 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, — все они в физических пикселях, и если смешать их с DIP на стороне XAML, при 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-потоком при выполнении такого пересоздания асинхронно описано в статье «async и UI-поток в WPF/WinForms».

7. Ловушка смешанного контента — WindowsFormsHost, WebBrowser, сторонние компоненты

Автоматическое масштабирование WPF распространяется только на то, что WPF рисует само. Элементы, приносящие с собой HWND, оказываются «вне» отрисовки WPF, из-за чего работа с DPI усложняется на порядок. В официальных материалах Windows тоже прямо сказано: «другой фреймворк, размещённый в WPF, и WPF, размещённый в другом фреймворке, автоматически не масштабируются». 10

Форма смешения Что происходит Практическое отношение
WindowsFormsHost (WinForms внутри WPF) Хост преобразует между двумя системами координат — DIP и физическими пикселями, но масштабирование содержимого работает лишь настолько, насколько это поддерживает сам элемент WinForms 11 Проверить поддержку DPI внутренним элементом WinForms (AutoScaleMode и т. п.) по критериям статьи о WinForms. Не рассчитывать на отслеживание Per-Monitor
Элемент управления WebBrowser (движок IE) Нативный контент в отдельном HWND. Уровень масштабирования может не совпадать с масштабированием приложения Считать legacy. Учитывать при рассмотрении перехода на WebView2
ElementHost / HwndSource (WPF внутри WinForms или Win32) Сценарий Per-Monitor официально не поддерживается 3 Проектировать вплоть до System Aware, ориентируясь на режим DPI-осведомлённости хостирующего приложения
Сторонние компоненты Уровень поддержки различается от продукта к продукту. Продукт без поддержки Per-Monitor задаёт потолок для этого экрана Провести инвентаризацию статуса поддержки и версий у вендоров, прежде чем принимать решение о переходе на Per-Monitor

На практике чаще всего встречается первая строка. Возьмём приложение, которое перешло на WPF, но всё ещё встраивает старый элемент управления WinForms / ActiveX для предпросмотра отчётов или графиков через WindowsFormsHost, — если перевести его на Per-Monitor, часть на WPF будет отслеживать DPI идеально, а только содержимое внутри хоста не подстроится под DPI после перемещения и останется мелким, грубым. На уровне Win32 есть механизм для хостинга смешанного DPI (SetThreadDpiHostingBehavior), но из WPF воспользоваться им непросто; наш подход — принять, что «смешанная часть задаёт потолок поддержки Per-Monitor», и начать с составления перечня смешанного контента. Материалы для принятия решения о движении в сторону избавления от смешения собраны в статьях «Как выбрать между WinForms, WPF и WinUI» и «Оставить, обернуть или заменить ActiveX/OCX».

8. Насколько глубоко заходить — таблица решений и поэтапный подход

В случае WPF вариантов фактически два (варианта «ничего не делать = Unaware», как в WinForms, здесь не существует, потому что без объявлений приложение и так System Aware).

  Остаться System Aware Перейти на Per-Monitor
Как выглядит На главном мониторе чётко. Размывается на мониторе с другим DPI, после смены масштабирования и по RDP Чётко на всех мониторах
Работа по объявлению Не нужна (по умолчанию) Только добавление манифеста (глава 4)
Оставшаяся работа после объявления Визуальная проверка всех экранов + переключение растровых активов по DPI (раздел 5.3) + обработка DpiChanged в пиксельном коде (глава 6) + проверка смешанного контента (глава 7)
Что задаёт потолок WindowsFormsHost, WebBrowser, сторонние компоненты
Подходящий случай Внутреннее приложение, ориентированное на единственный монитор и фиксированный рабочий стол. Приложения с большим объёмом смешанного контента Смешанное использование ноутбука и внешнего монитора, долгоживущее флагманское приложение, продукт, распространяемый среди заказчиков

Оси решения те же, что в главе 7 статьи о WinForms (срок жизни приложения, среда использования, бюджет на доработку), но разница в том, что у WPF предельная стоимость перехода на Per-Monitor невелика. Объявление — это один файл, разметку отслеживает сам фреймворк, а оставшаяся работа пропорциональна количеству пиксельного кода и активов. Для приложения с небольшим объёмом смешанного контента отдача от перехода на Per-Monitor заметно выше, чем в WinForms. Поэтапный подход, который мы реально рекомендуем на проектах, состоит из трёх шагов.

  1. Улучшение качества при сохранении System Aware: UseLayoutRounding на корне, векторизация и подготовка нескольких разрешений иконок, исправление DPI в коде вроде WriteableBitmap. Здесь снимается всё, кроме размытия при мультимониторной работе, и этот объём работы остаётся полезным активом даже при решении не переходить на Per-Monitor.
  2. Объявление Per-Monitor и проверка: добавляем манифест и прогоняем все экраны в среде со смешанным DPI, выявляя проблемные места. Найденное здесь должно классифицироваться по одной из глав 5–7.
  3. Реализация отслеживания изменения DPI: переключение активов и пересоздание кэша в OnDpiChanged. На этом этапе решают, насколько допустим смешанный контент.

Тестовое окружение можно использовать то же, что описано в главе 7 статьи о WinForms (два монитора с разным масштабированием, смена главного монитора с повторным входом в систему, изменение масштабирования во время работы, RDP с клиента с высоким DPI). 10 В WPF стоит уделить особое внимание линиям, иконкам и собственной отрисовке при неинтегральных коэффициентах (125% / 150%), а также поведению при перетаскивании окна между мониторами. Заранее выяснив распределение среды использования (какой процент пользователей работает с несколькими мониторами), проще принять взвешенное инвестиционное решение. Этот аспект также затронут в статье «Проектирование UX Windows-приложения».

9. Итог

Благодаря DIP и автоматическому масштабированию WPF с самого начала System DPI Aware, и битвы со сломанной разметкой, которая была основным полем боя в WinForms, здесь практически нет. Тем не менее остаются проблемы четырёх видов — размытие при мультимониторной работе (отсутствие Per-Monitor), размытие растровых изображений, размытие тонких линий и смешанный контент, — и для каждой есть отработанное решение.

  • Размытие при мультимониторной работе: на WPF под .NET Framework 4.6.2+ / .NET достаточно объявления в манифесте, чтобы автоматически получить отслеживание Per-Monitor
  • Тонкие линии и рамки: первый шаг — UseLayoutRounding="True" на корне, для остального — SnapsToDevicePixels
  • Иконки: приоритет — векторные активы, для растра — несколько разрешений плюс переключение по DPI
  • Реальным объектом доработки является только код, работающий с пикселямиWriteableBitmap, экранные координаты и подобное. Его объём определяет трудозатраты на переход к Per-Monitor
  • Потолок поддержки задают WindowsFormsHost / WebBrowser / сторонние компоненты. Сначала проведите их инвентаризацию

Даже приложение, застрявшее на предположении «раз это WPF, значит, всё в порядке», при таком разборе часто оказывается с считаными местами, которые действительно нужно чинить. Если сомневаетесь, насколько можно исправить конкретное приложение, включая смешанный контент, и как оценить нужный уровень поддержки, мы можем помочь с обследованием и принятием решения.

Похожие статьи

Смежные области консультаций

Komura Software Co., Ltd. занимается поддержкой высокого DPI для приложений на WPF / WinForms (обследование текущего состояния, оценка возможности перехода на Per-Monitor, инвентаризация и доработка смешанного контента), расследованием проблем отображения при замене ПК и внедрении 4K-мониторов, а также консультациями по обновлению UI.

Источники

  1. Microsoft Learn, Developing a Per-Monitor DPI-Aware WPF Application. О том, что WPF по умолчанию System DPI Aware, о механизме автоматического масштабирования через DIP, о том, что перенос на монитор с другим DPI заставляет ОС масштабировать изображение (особенно размыто при неинтегральных коэффициентах), и о практике переключения растровых активов по DPI.  2 3 4 5

  2. Microsoft Learn, What’s new in .NET Framework. О том, что Per-Monitor DPI awareness в WPF была включена в .NET Framework 4.6.2, о переключателе Switch.System.Windows.DoNotScaleForDpiChanges и о руководстве разработчика на GitHub.  2

  3. GitHub (microsoft/WPF-Samples), Per Monitor DPI Developer Guide. О требованиях Windows 10 Anniversary Update и .NET Framework 4.6.2+, о записи dpiAwareness / dpiAware в манифесте, о AppContextSwitchOverrides для таргетов старше 4.6.2 и о том, что Per-Monitor не поддерживается для WPF, размещённого в HwndSource / ElementHost.  2 3 4 5 6

  4. Microsoft Learn, Layout - WPF. О разметке через DIP и механизме субпиксельной отрисовки, вызывающем размытие краёв, о том, что округление разметки (UseLayoutRounding) по умолчанию выключено, и о том, что установка на корневом элементе распространяет его на визуальное дерево.  2 3

  5. Microsoft Learn, UIElement.SnapsToDevicePixels Property. О том, что значение по умолчанию — false, о том, что установка на корне наследуется поддеревом, и о том, что это может снизить визуальные артефакты от сглаживания вокруг тонких линий в средах выше 96 DPI.  2

  6. Microsoft Learn, BitmapScalingMode Enum. О том, что значение по умолчанию (Unspecified) — это Linear, и о характеристиках алгоритмов интерполяции HighQuality (Fant) и NearestNeighbor.  2 3

  7. Microsoft Learn, Setting the default DPI awareness for a process. О том, что элемент dpiAwareness (Windows 10 1607+) имеет приоритет над dpiAware, и о поведении отката, при котором используется первое распознанное значение из списка через запятую. 

  8. Microsoft Learn, What’s new in .NET Framework. О том, что .NET Framework 4.8 добавил в WPF поддержку Per-Monitor V2 DPI Awareness и Mixed-Mode DPI-масштабирования, об улучшениях взаимодействия с размещёнными HWND / WinForms и о необходимом для включения AppContext-переключателе. 

  9. Microsoft Learn, TextFormattingMode Enum. О двух режимах текстового форматирования — Ideal (идеальные метрики) и Display (метрики, совместимые с GDI). 

  10. Microsoft Learn, High DPI Desktop Application Development on Windows. О таблице поддержки Per-Monitor по UI-фреймворкам, о том, что другой фреймворк, размещённый в WPF, и WPF, размещённый в другом фреймворке, не масштабируются автоматически, и о точках проверки в среде со смешанным DPI.  2

  11. Microsoft Learn, Layout Considerations for the WindowsFormsHost Element. О том, что WindowsFormsHost преобразует между двумя системами координат — DIP и физическими пикселями, — и о том, что масштабирование работает лишь настолько, насколько это поддерживает размещённый внутри элемент управления Windows Forms. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Значки в области уведомлений и всплывающие (toast) уведомления в Windows-приложениях — подводные камни NotifyIcon и выбор правильного AppNotification

Практическое руководство о том, как удерживать бизнес-приложение Windows в области уведомлений (system tray) и оповещать пользователя с п...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Говорят, что WPF не требует отдельной поддержки DPI — почему тогда всё плывёт?
В WPF единица разметки - device-independent unit (DIP), 1/96 дюйма, и по умолчанию приложение работает как System DPI Aware, поэтому DPI главного монитора на момент запуска отслеживается корректно. Но System DPI фиксируется при входе в систему, и если перенести окно на монитор с другим DPI, ОС растягивает или сжимает всё окно как растровое изображение, чтобы свести концы с концами, - и картинка равномерно размывается. Особенно заметна деградация при неинтегральных сочетаниях вроде 125% и 150%. Радикальное решение - объявить Per-Monitor DPI в манифесте: начиная с .NET Framework 4.6.2 WPF выполняет пересчёт масштаба окна автоматически.
Почему в WPF размывается линия толщиной 1px и толщина отличается в разных местах?
Потому что разметка идёт в DIP, а границы элементов не обязательно попадают на целочисленные позиции физических пикселей. При 125% 1 DIP равен 1,25px, и край, оказавшийся между пикселями, отрисовывается сглаживанием как полупрозрачный - отсюда размытие. Первый шаг - установить UseLayoutRounding="True" на корневом Window: значения пикселей округляются на этапе разметки и распространяются по всему визуальному дереву. Для того, что осталось, точечно применяют SnapsToDevicePixels="True", которое привязывает края к границам пикселей уже при отрисовке. Оба свойства по умолчанию выключены.
Почему в WPF текст чёткий, а иконки размыты?
Текст рисуется как векторный шрифт и остаётся чётким при любом DPI, а растровые изображения размещаются в DIP и масштабируются интерполяцией под текущий DPI. Иконка 16×16px при 150% увеличивается до 24×24px, и стандартная интерполяция (Linear) её размывает. Порядок мер такой: (1) перевести актив в вектор - Path/Geometry/DrawingImage или значок-шрифт, (2) подготовить растровые версии под несколько разрешений и переключать их по DPI через VisualTreeHelper.GetDpi или OnDpiChanged, (3) подобрать алгоритм интерполяции через RenderOptions.BitmapScalingMode.
Как настроить поддержку Per-Monitor в WPF?
Объявление делается элементом dpiAwareness в манифесте приложения. В отличие от WinForms с её ApplicationHighDpiMode, у WPF нет отдельной настройки проекта или переключателя из кода. Требования - ОС не старше Windows 10 1607 и таргет не старше .NET Framework 4.6.2: после объявления фреймворк сам обрабатывает WM_DPICHANGED и пересчитывает масштаб окна. Поддержка PerMonitorV2 в WPF появилась только с .NET Framework 4.8, поэтому на 4.6.2-4.7.2 объявляют одиночный PerMonitor. Если таргет проекта остался на 4.6.1 или старше, отслеживание отключено по умолчанию и его нужно включать через AppContext-переключатель. Отдельно: Per-Monitor поведение WPF, размещённого внутри ElementHost или HwndSource, официально не поддерживается.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог