Разработка настольных приложений с высоким DPI в Windows

Этот материал предназначен для разработчиков, которые хотят обновить настольные приложения, чтобы они могли динамически обрабатывать изменения коэффициента масштабирования дисплея (DPI, dots per inch — точек на дюйм), благодаря чему приложения будут чётко отображаться на любом дисплее, на котором они отображаются.

Для начала, если вы создаете новое приложение Для Windows с нуля, настоятельно рекомендуется создать приложение универсальной платформы Windows (UWP). Приложения UWP автоматически и динамически масштабируются для каждого отображения, на котором они работают.

Настольные приложения, использующие более старые технологии программирования для Windows (низкоуровневое программирование Win32, Windows Forms, Windows Presentation Framework (WPF) и т. д.) не могут автоматически обрабатывать масштабирование DPI без дополнительных усилий со стороны разработчика. Без выполнения этой работы приложения будут отображаться размытыми или неправильного размера во многих типичных сценариях использования. Этот документ содержит контекстную информацию и сведения о том, что требуется для обновления настольного приложения, чтобы оно отображалось корректно.

Масштаб дисплея & DPI

По мере развития технологии отображения производители панелей отображения упаковали все большее количество пикселей в каждую единицу физического пространства на своих панелях. Это привело к тому, что точки на дюйм (DPI) современных панелей дисплея значительно выше, чем они исторически были. Раньше большинство дисплеев имели 96 пикселей на дюйм физической длины (96 DPI); в 2017 году дисплеи с плотностью около 300 DPI и выше уже были широко доступны.

Большинство устаревших платформ пользовательского интерфейса рабочего стола имеют встроенные предположения о том, что DPI отображения не изменится в течение всего времени существования процесса. Это предположение больше не соответствует действительности, поскольку значение DPI дисплея нередко меняется несколько раз на протяжении всего времени существования процесса приложения. Некоторые распространенные сценарии изменения коэффициента масштабирования или DPI отображения:

  • Установки с несколькими мониторами, в которых каждый дисплей имеет другой коэффициент масштабирования, и приложение перемещается из одного дисплея в другой (например, 4K и 1080p)
  • Закрепление и отключение ноутбука с высоким уровнем DPI с внешним дисплеем с низким уровнем DPI (или наоборот)
  • Подключение через удаленный рабочий стол с ноутбука или планшета с высоким уровнем DPI к устройству с низким уровнем DPI (или наоборот)
  • Изменение параметров коэффициента отображения во время работы приложений

В этих сценариях приложения UWP автоматически перерисовываются при новом значении DPI. По умолчанию и без дополнительной работы разработчика классические приложения не выполняются. Настольные приложения, которые не выполняют эту дополнительную работу для реакции на изменения DPI, могут выглядеть размытыми или иметь неправильный размер.

Режим осведомленности о DPI

Классические приложения должны сообщать Windows о поддержке масштабирования DPI. По умолчанию система считает, что классические приложения не учитывают DPI, и выполняет растровое масштабирование их окон. Задав один из следующих доступных режимов осведомленности О DPI, приложения могут явно сообщить Windows, как они хотят обрабатывать масштабирование DPI:

DPI не знает

Приложения, не учитывающие DPI, отрисовываются с фиксированным значением DPI 96 (100%). Всякий раз, когда эти приложения выполняются на экране с масштабом отображения больше 96 DPI, Windows растянет растровое изображение приложения до ожидаемого физического размера. Это приводит к тому, что приложение отображается размытым.

Осведомленность о DPI системы

Настольные приложения, учитывающие системный DPI, обычно получают значение DPI основного подключенного монитора на момент входа пользователя в систему. Во время инициализации они корректно настраивают макет своего пользовательского интерфейса (задавая размеры элементов управления, выбирая размеры шрифтов, загружая ресурсы и т. д.), используя это системное значение DPI. Таким образом, приложения, учитывающие системный DPI, не масштабируются Windows (путем растягивания растрового изображения) на дисплеях, работающих с этим единственным значением DPI. При перемещении приложения на дисплей с другим коэффициентом масштабирования или при изменении коэффициента масштабирования дисплея Windows масштабирует окна приложения, что делает их размытыми. По сути, настольные приложения, учитывающие только системный DPI, отображаются чётко только при одном коэффициенте масштабирования дисплея и становятся размытыми при любом изменении DPI.

Per-Monitor и Per-Monitor (версия 2) осведомленности о DPI

Рекомендуется обновлять классические приложения для использования режима осведомленности о DPI на мониторе, что позволяет им немедленно отображаться правильно при изменении DPI. Когда приложение сообщает Windows, что оно хочет работать в этом режиме, Windows не будет масштабировать приложение как растровое изображение при изменении DPI, а вместо этого отправит WM_DPICHANGED в окно приложения. После этого приложение несет полную ответственность за то, чтобы самостоятельно выполнять масштабирование с учетом нового значения DPI. Большинство платформ пользовательского интерфейса, используемых классическими приложениями (общие элементы управления Windows (comctl32), Windows Forms, Windows Presentation Framework и т. д.) не поддерживают автоматическое масштабирование DPI, требуя от разработчиков изменять размер и изменять положение содержимого своих окон.

Существуют две версии режима Per-Monitor awareness, в котором приложение может зарегистрироваться: версия 1 и версия 2 (PMv2). Регистрация процесса в режиме осведомленности PMv2 приводит к следующим результатам:

  1. Уведомление приложения об изменении DPI (как для окна верхнего уровня, так и для дочерних HWND)
  2. Приложение, отображающее необработанные пиксели каждого дисплея
  3. Приложение никогда не подвергается растровому масштабированию системой Windows
  4. Автоматическое масштабирование DPI средствами Windows для неклиентской области (заголовка окна, полос прокрутки и т. д.)
  5. Диалоговые окна Win32, созданные с помощью CreateDialog, автоматически масштабируются Windows с учётом DPI
  6. Растровые графические ресурсы, отрисовываемые темой в стандартных элементах управления (флажки, фоны кнопок и т. д.), автоматически отображаемые в соответствии с текущим коэффициентом масштабирования DPI

При запуске в режиме осведомленности Per-Monitor версии 2 приложения уведомляются об изменении DPI. Если приложение не изменяет размер для нового DPI, пользовательский интерфейс приложения будет отображаться слишком маленьким или слишком большим (в зависимости от разницы в предыдущих и новых значениях DPI).

Заметка

Поддержка режима Per-Monitor V1 (PMv1) крайне ограничена. Рекомендуется использовать PMv2 для приложений.

В следующей таблице показано, как приложения будут отображаться в различных сценариях:

Режим осведомленности о DPI Появилась версия Windows Представление приложения о DPI Поведение при изменении DPI
Не подозревающий N/A Все дисплеи имеют разрешение 96 DPI Растяжение растровых изображений (размытое)
Система Vista Все отображения имеют один и тот же DPI (DPI основного дисплея во время запуска текущего сеанса пользователя) Растяжение растровых изображений (размытое)
Для каждого монитора 8.1 DPI дисплея, на котором преимущественно расположено окно приложения
  • HWND верхнего уровня получает уведомление об изменении DPI
  • Нет масштабирования DPI элементов пользовательского интерфейса.

Per-Monitor версии 2 Windows 10 Creators Update (1703) DPI дисплея, на котором преимущественно расположено окно приложения
  • HWND верхнего уровня и дочерние HWND уведомляются об изменении DPI

автоматическое масштабирование DPI:
  • Неклиентская область
  • Растровые изображения, отрисованные темой, в стандартных элементах управления (comctl32 V6)
  • Диалоговые окна (CreateDialog)

Осведомленность о DPI на мониторе (V1)

Режим осведомлённости о DPI для каждого монитора версии 1 (PMv1) был введён в Windows 8.1. Этот режим осведомленности о DPI очень ограничен и предоставляет только перечисленные ниже функциональные возможности. Рекомендуется, чтобы настольные приложения использовали режим осведомлённости Per-Monitor v2, который поддерживается в Windows 10 версии 1703 и более поздних версиях.

Первоначальная поддержка учета параметров отдельных мониторов предоставляла приложениям только следующие возможности:

  1. HWND верхнего уровня уведомляются об изменении DPI и предоставляют новый предлагаемый размер.
  2. Windows не будет растягивать пользовательский интерфейс приложения
  3. Приложение воспринимает все дисплеи в физических пикселях (см. виртуализацию)

Начиная с Windows 10 версии 1607, приложения PMv1 также могут вызывать EnableNonClientDpiScaling во время WM_NCCREATE, чтобы Windows корректно масштабировала неклиентскую область окна.

Поддержка масштабирования DPI для каждого монитора в зависимости от фреймворка/технологии UI

В таблице ниже показан уровень поддержки осведомленности о DPI для каждого монитора, предоставляемой различными платформами пользовательского интерфейса Windows по состоянию на Windows 10 1703:

Платформа / технология Поддержка Версия ОС Масштабирование DPI обрабатывается Дальнейшее чтение
Универсальная платформа Windows (UWP) Полный 1607 Платформа пользовательского интерфейса Универсальная платформа Windows (UWP)
Необработанные элементы управления Win32/Common Controls V6 (comctl32.dll)
  • Сообщения-уведомления об изменении DPI, отправляемые всем HWND
  • Элементы интерфейса, отрисованные темой, корректно отображаются в стандартных элементах управления
  • Автоматическое масштабирование DPI для диалоговых окон
1703 Приложение пример GitHub
Windows Forms Ограниченное автоматическое масштабирование DPI на монитор для некоторых элементов управления 1703 Платформа пользовательского интерфейса поддержка высокого уровня DPI в Windows Forms
Windows Presentation Framework (WPF) Собственные WPF-приложения не выполняют автоматическое DPI-масштабирование для WPF, размещенного в других фреймворках, и для других фреймворков, размещенных в WPF. 1607 Платформа пользовательского интерфейса пример GitHub
GDI Нет N/A Приложение См. масштабирование High-DPI в GDI
GDI+ Нет N/A Приложение См. масштабирование High-DPI в GDI
MFC Нет N/A Приложение N/A

Обновление существующих приложений

Чтобы обновить существующее классическое приложение для правильного масштабирования DPI, необходимо обновить его, чтобы, как минимум, важные части пользовательского интерфейса обновлялись для реагирования на изменения DPI.

Большинство настольных приложений работают в режиме осведомлённости системы о DPI. Приложения, учитывающие системный DPI, обычно масштабируются в соответствии с DPI основного дисплея (дисплея, на котором находилась область уведомлений на момент запуска сеанса Windows). При изменении DPI Windows будет масштабировать пользовательский интерфейс этих приложений как растровое изображение, из-за чего они часто выглядят размытыми. При переводе приложения, учитывающего DPI на уровне системы, в режим учета DPI для каждого монитора необходимо изменить код, отвечающий за компоновку пользовательского интерфейса, так, чтобы она выполнялась не только во время инициализации приложения, но и всякий раз при получении уведомления об изменении DPI (WM_DPICHANGED в случае Win32). Обычно это включает пересмотр всех допущений в коде о том, что пользовательский интерфейс нужно масштабировать только один раз.

Кроме того, в случае программирования Win32 многие API Win32 не имеют никакого DPI или контекста отображения, поэтому они будут возвращать только значения относительно системного DPI. Может быть полезно выполнить grep по коду, чтобы найти некоторые из этих API и заменить их вариантами с поддержкой DPI. Ниже приведены некоторые распространенные API с поддержкой DPI:

Одна версия DPI Версия для каждого монитора
GetSystemMetrics GetSystemMetricsForDpi
AdjustWindowRectEx AdjustWindowRectExForDpi
SystemParametersInfo SystemParametersInfoForDpi
GetDpiForMonitor GetDpiForWindow

Также рекомендуется искать жестко закодированные размеры в базе кода, предполагающие постоянный DPI, заменяя их кодом, который правильно учитывает масштабирование DPI. Ниже приведен пример, который включает все эти предложения:

Пример:

В приведенном ниже примере показан упрощенный случай создания дочернего HWND Win32. Вызов CreateWindow предполагает, что приложение работает при 96 DPI (USER_DEFAULT_SCREEN_DPI — константа), и ни размер кнопки, ни её положение не будут правильными при более высоких значениях DPI:

case WM_CREATE: 
{ 
    // Add a button 
    HWND hWndChild = CreateWindow(L"BUTTON", L"Click Me",  
        WS_CHILD|WS_VISIBLE|BS_PUSHBUTTON,  
        50,  
        50,  
        100,  
        50,  
        hWnd, (HMENU)NULL, NULL, NULL); 
} 

В обновленном коде ниже показано:

  1. Код создания окна, выполняющий DPI-масштабирование положения и размера дочернего HWND с учётом DPI родительского окна
  2. Реакция на изменение DPI путем перемещения и изменения размера дочернего окна HWND
  3. Жестко закодированные размеры удалены и заменены кодом, который отвечает на изменения DPI
#define INITIALX_96DPI 50 
#define INITIALY_96DPI 50 
#define INITIALWIDTH_96DPI 100 
#define INITIALHEIGHT_96DPI 50 

// DPI scale the position and size of the button control 
void UpdateButtonLayoutForDpi(HWND hWnd) 
{ 
    int iDpi = GetDpiForWindow(hWnd); 
    int dpiScaledX = MulDiv(INITIALX_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    int dpiScaledY = MulDiv(INITIALY_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    int dpiScaledWidth = MulDiv(INITIALWIDTH_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    int dpiScaledHeight = MulDiv(INITIALHEIGHT_96DPI, iDpi, USER_DEFAULT_SCREEN_DPI); 
    SetWindowPos(hWnd, hWnd, dpiScaledX, dpiScaledY, dpiScaledWidth, dpiScaledHeight, SWP_NOZORDER | SWP_NOACTIVATE); 
} 
 
... 
 
case WM_CREATE: 
{ 
    // Add a button 
    HWND hWndChild = CreateWindow(L"BUTTON", L"Click Me",  
        WS_CHILD|WS_VISIBLE|BS_PUSHBUTTON, 
        0, 
        0, 
        0, 
        0, 
        hWnd, (HMENU)NULL, NULL, NULL); 
    if (hWndChild != NULL) 
    { 
        UpdateButtonLayoutForDpi(hWndChild); 
    } 
} 
break; 
 
case WM_DPICHANGED: 
{ 
    // Find the button and resize it 
    HWND hWndButton = FindWindowEx(hWnd, NULL, NULL, NULL); 
    if (hWndButton != NULL) 
    { 
        UpdateButtonLayoutForDpi(hWndButton); 
    } 
} 
break; 

При обновлении приложения с поддержкой системного DPI рекомендуется выполнить следующие распространенные действия:

  1. Обозначьте процесс как поддерживающий DPI для каждого монитора (V2) с помощью манифеста приложения (или другим способом — в зависимости от используемых платформ пользовательского интерфейса).
  2. Сделайте логику макета пользовательского интерфейса повторно используемым и переместите его из кода инициализации приложения, чтобы его можно было повторно использовать при изменении DPI (WM_DPICHANGED в случае программирования Windows (Win32).
  3. Сделайте недействительным любой код, который предполагает, что данные, зависящие от DPI (DPI/шрифты/размеры и т. д.), никогда не требуют обновления. Это очень распространенная практика кэширования размеров шрифтов и значений DPI при инициализации процесса. При обновлении приложения, чтобы оно стало поддерживать DPI отдельно для каждого монитора, необходимо пересчитывать данные, зависящие от DPI, при каждом обнаружении нового значения DPI.
  4. При изменении DPI перезагрузите (или повторно растеризуйте) все растровые ресурсы для нового значения DPI или, при необходимости, масштабируйте уже загруженные растровые ресурсы до нужного размера.
  5. Найдите API, не поддерживающие Per-Monitor DPI, и замените их на API с поддержкой Per-Monitor DPI (где применимо). Пример. Замените GetSystemMetrics на GetSystemMetricsForDpi.
  6. Протестируйте приложение в системе с несколькими дисплеями и несколькими DPI.
  7. Для всех окон верхнего уровня в приложении, которые не удается обновить до правильного масштабирования DPI, используйте масштабирование DPI в смешанном режиме (описано ниже), чтобы разрешить растяжение растровых изображений этих окон верхнего уровня системой.

Масштабирование DPI в смешанном режиме (масштабирование DPI дочернего процесса)

При обновлении приложения для поддержки осведомленности о DPI на мониторе иногда может стать непрактичным или невозможным обновить каждое окно в приложении в одном окне. Это может быть связано с временем и усилиями, необходимыми для обновления и тестирования всего пользовательского интерфейса, или из-за того, что вы не владеете всем кодом пользовательского интерфейса, который необходимо запустить (если приложение, возможно, загружает сторонний пользовательский интерфейс). В таких ситуациях Windows предлагает способ постепенно перейти к поддержке DPI для отдельных мониторов, позволяя оставить некоторые окна приложения (только верхнего уровня) в их исходном режиме поддержки DPI, пока вы направляете время и силы на обновление наиболее важных частей интерфейса.

Ниже приведена иллюстрация того, как это может выглядеть: вы обновляете основной пользовательский интерфейс приложения ("Главное окно" на рисунке) для запуска с осведомленностью о DPI для каждого монитора при запуске других окон в существующем режиме ("Дополнительное окно").

различия в масштабировании dpi между режимами осведомленности

До Юбилейного обновления Windows 10 (1607) режим учета DPI был общим свойством процесса. Начиная с юбилейного обновления Windows 10 это свойство теперь можно задать на окне верхнего уровня. (Дочерние окна должны продолжать соответствовать размеру масштабирования родительского элемента.) Окно верхнего уровня определяется как окно без родительского элемента. Обычно это «обычное» окно с кнопками свертывания, развертывания и закрытия. Сценарий, для которого предназначена DPI-осведомлённость подпроцессов, заключается в том, что вторичный интерфейс масштабируется средствами Windows (с растягиванием растрового изображения), пока вы сосредоточиваете своё время и ресурсы на обновлении основного интерфейса.

Чтобы включить осведомлённость подпроцесса о DPI, вызовите SetThreadDpiAwarenessContext до и после любых вызовов, создающих окна. Созданное окно будет связано с осведомленностью о DPI, заданной с помощью SetThreadDpiAwarenessContext. Используйте второй вызов для восстановления режима DPI-awareness текущего потока.

Хотя использование DPI-масштабирования на уровне подпроцесса позволяет полагаться на Windows в выполнении части DPI-масштабирования в вашем приложении, это может усложнить ваше приложение. Важно понимать недостатки этого подхода и характера сложностей, которые он вводит. Дополнительные сведения об осведомленности подпроцесса о DPI см. в разделе Смешанное масштабирование DPI и API с поддержкой DPI.

Тестирование изменений

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

  1. Перемещение окон приложений между отображением различных значений DPI
  2. Запуск приложения на дисплеях различных значений DPI
  3. Изменение коэффициента масштабирования монитора во время работы приложения
  4. Изменение отображения, используемого в качестве основного дисплея, выход из Windows, а затем повторное тестирование приложения после входа. Это особенно полезно при поиске кода, использующего жестко закодированные размеры или измерения.

Распространенные ловушки (Win32)

Не используется предлагаемый прямоугольник, предоставляемый в WM_DPICHANGED

Когда Windows отправляет окну приложения сообщение WM_DPICHANGED, оно содержит рекомендуемые границы, которые следует использовать для изменения размеров окна. Важно, чтобы приложение использовало этот прямоугольник для изменения размера, так как это приведет к следующему:

  1. Убедитесь, что курсор мыши останется в той же относительной позиции в окне при перетаскивании между дисплеями
  2. Предотвратить переход окна приложения в рекурсивный цикл изменения dpi, в котором одно изменение DPI активирует последующее изменение DPI, которое активирует еще одно изменение DPI.

Если у вас есть требования для конкретного приложения, которые не позволяют использовать предлагаемый прямоугольник, который Windows предоставляет в сообщении WM_DPICHANGED, см. WM_GETDPISCALEDSIZE. Это сообщение можно использовать для предоставления windows требуемого размера, который вы хотите использовать после изменения DPI, при этом все равно избегая проблем, описанных выше.

Отсутствие документации по виртуализации

Если HWND или процесс работает в режиме, не поддерживающем DPI, или в режиме поддержки только системного DPI, Windows может масштабировать его как растровое изображение. В этом случае Windows масштабирует и преобразует информацию, зависящую от DPI, получаемую из некоторых API, в координатное пространство вызывающего потока. Например, если поток, не зависящий от DPI, запрашивает размер экрана во время работы на дисплее с высоким уровнем DPI, Windows будет виртуализировать ответ, предоставленный приложению, как если бы экран был в 96 единицах DPI. Кроме того, когда поток с поддержкой системного DPI взаимодействует с дисплеем в другом DPI, отличном от того, что использовался при запуске сеанса текущего пользователя, Windows будет масштабировать некоторые вызовы API в пространство координат, которое HWND будет использовать, если бы он работал на исходном коэффициенте масштабирования DPI.

Когда вы обновляете своё настольное приложение так, чтобы оно корректно масштабировалось с учётом DPI, может быть трудно определить, какие вызовы API могут возвращать виртуализированные значения в зависимости от контекста потока; эта информация в настоящее время недостаточно подробно документирована Microsoft. Имейте в виду, что если вы вызываете любой системный API из потока, не поддерживающего DPI, или из потока, учитывающего системный DPI, возвращаемое значение может быть виртуализировано. Таким образом, убедитесь, что поток работает в контексте DPI, который вы ожидаете при взаимодействии с экраном или отдельными окнами. При временном изменении контекста DPI потока с помощью SetThreadDpiAwarenessContext обязательно восстановите старый контекст после завершения работы, чтобы избежать некорректной работы в других частях приложения.

Многие API Windows не имеют контекста DPI

Многие устаревшие API Windows не включают контекст DPI или HWND в составе интерфейса. В результате разработчикам часто приходится выполнять дополнительную работу для обработки масштабирования любой конфиденциальной информации DPI, например размеров, точек или значков. Например, разработчики, использующие LoadIcon, должны либо растягивать загруженные значки как растровые изображения, либо использовать альтернативные API для загрузки значков правильного размера для соответствующего DPI, например LoadImage.

Начиная с Windows 11 (сборка 22000), приложения, создающие курсоры из данных в памяти, могут использовать SetThreadCursorCreationScaling для включения автоматического масштабирования DPI на монитор, аналогично курсорам, загруженным из ресурсов модуля.

Принудительный сброс осведомленности о DPI на уровне процесса

Как правило, режим DPI-осведомлённости процесса нельзя изменить после инициализации процесса. Однако Windows может принудительно изменить режим DPI-осведомлённости вашего процесса, если вы попытаетесь нарушить требование о том, что все HWND в дереве окон используют один и тот же режим DPI-осведомлённости. Во всех версиях Windows, начиная с Windows 10 версии 1703, невозможно, чтобы разные HWND в одном дереве HWND работали в разных режимах DPI-осведомлённости. Если вы попытаетесь создать отношение между дочерним и родительским элементом, нарушающее это правило, осведомлённость всего процесса о DPI может быть сброшена. Это может быть вызвано следующими способами:

  1. Вызов CreateWindow, в котором переданное родительское окно имеет иной режим DPI-осведомлённости, чем вызывающий поток.
  2. Вызов SetParent, при котором два окна связаны с разными режимами DPI-осведомленности.

В следующей таблице показано, что происходит, если вы пытаетесь нарушить это правило:

Операция Windows 8.1 Windows 10 (1607 и более ранние версии) Windows 10 (1703 и более поздние версии)
CreateWindow (In-Proc) N/A Дочерний наследует (смешанный режим) Дочерний наследует (смешанный режим)
CreateWindow (Cross-Proc) Принудительный сброс (вызывающего процесса) Дочерний наследует (смешанный режим) Принудительный сброс (вызывающего процесса)
SetParent (In-Proc) N/A Принудительный перезапуск (текущего процесса) Сбой (ERROR_INVALID_STATE)
SetParent (Cross-Proc) Принудительный перезапуск (процесса дочернего окна) Принудительный перезапуск (процесса дочернего окна) Принудительный перезапуск (процесса дочернего окна)

Справочник по API для высокого DPI

Смешанное масштабирование DPI и DPI-осведомлённые API.