Веб установщик на чистом WINAPI с поддержкой Hi DPI и векторным лого

В наше время это, наверно, одно из основных применений для приложений написанных на чистом WINAPI. Писать что-то серьёзнее нескольких простых окон на чистом WINAPI уже не так весело, а вот маленький установщик — самое то.

Так как на дворе 2018 год, писать просто приложение как-то не очень. Давайте уж соответствовать веяниям времени — установщик будет с поддержкой Hi DPI режимов. Даже в ноутбуках уже 4К экраны не редкость, чего уж говорить про десктопы. Ну и так как установщик — это то, что должно быстро загрузиться будем экономить на том, что действительно не сложно сделать и самому. Ну и попробуем схитрить чтобы использовать векторную графику без дополнительных библиотек — нам же нужен красивый логотип!

nv2fgnyzchl6wyfelhtebm7lao0.png

Для начала последнее (на сегодня) руководство от Microsoft о том — как же надо писать High DPI Desktop приложения. Из него видно, какая нелёгкая судьба у режима поддержки разных DPI в приложениях под Windows. А уж сколько всяких функций уже добавлено… До Windows 10 система умела только рисовать шрифт в соответствии с DPI, но при этом отступы и прочие размеры жутко «плыли», а всё остальное просто растягивалось. Этот режим называется XP style DPI scaling. Начиная c Vista — система рисовала приложение в буфер и растягивала уже итоговую картинку, но результат был тоже так себе — всё было размыто. Начиная с Windows 8.1 система стала присылать сообщение об изменении DPI налету, но только главному окну приложения. Только теперь не надо закрывать пользовательскую сессию, чтобы увидеть изменения DPI. И только в Windows 10 система научилась перерисовывать всё, что делается через GDI (текст и common controls) и то не сразу, а после Creators Update, но даже Windows 10 не может всего:

ze8y50fz_hurlfy84heiey4qory.png

Так что если захочется написать DPI aware приложение, то согласно документу придётся делать UWP — только этот фреймворк поддерживает DPI и всё за вас сам сделает. Всё остальное потребует ручного управления в том или ином виде.

На самом деле нам это особо и не нужно — мы же и так сами всё рисуем. На старте получаем текущий DPI и будем слушать сообщение от системы — чтобы менять DPI налету (если система это поддерживает). Далее просто рисуем окна/компоненты как нам надо с учётом DPI ну и обновляем если пользователь решит изменить DPI во время работы программы или перетащить окно на другой монитор, где DPI отличается. Использовать Сommon Сontrols не получится, они до Windows 10 1703 не умеют реагировать на изменение DPI — так что всё сами.

Писать оконные процедуры в «С» стиле мне не нравится, потому были написаны обёртки на все компоненты — окна и элементы управления. Тут работы-то не много даже с нуля (это если всё заранее знаешь), а уж у каждого WINAPI программиста подобные давно уже есть в запасниках. Что же нам понадобится:

  1. Что-нибудь абстрактное для окон (windows)
  2. Что-нибудь абстрактное для элементов управления (controls)
  3. Текстовый элемент (textbox)
  4. Кнопка текстовая и с эффектами (button)
  5. Индикатор прогресса (progress)
  6. Ссылка (link)
  7. Флажки с текстом для выбора каких-нибудь настроек (checkbox)
  8. Хитрый элемент для векторного логотипа
  9. Ну и плюс разные их сочетания


Рассказывать всю теорию работы окон и элементов управления (это тоже, окна, кстати) в WINAPI тут не к месту. Вкратце примерно так: когда создаётся какой-то GUI элемент — система возвращает его HANDLE (HWND) и он указывает на данные созданного элемента. У элементов и окон есть главная процедура — которая разбирает обращения к элементу и делает (или не делает) то, что от неё хотят. Обращение к элементу идёт через сообщения (само сообщение и два параметра к нему: wparam и lparam). Чтобы что-то переделать, переопределяем главную процедуру на свою, обрабатываем нужные нам сообщения и отдаём управление основной процедуре сигнализируя — надо дальше что-то разбирать или мы и так всё сделали. В остальных системах GUI примерно так же устроен — ничего сложного.

Ну вот, теперь вы WINAPI программист. Почти.

Что мы в итоге делаем — главное окно, где можно нажать пару кнопок и выбрать несколько опций. По нажатию кнопки Install показываем модный индикатор прогресса с размытым фоном и работать это всё у нас будет начиная с Windows XP. За основу возьмём диалоговое окно — так проще всего.

ptmxmbds_mepca4rj3wkeek0cls.png

Теперь давайте по порядку — что у нас с Hi DPI. Сама windows определяет поддержку DPI (dpi aware) со стороны приложения с помощью манифеста приложения — он с некоторого времени практически обязателен. Помимо DPI в Windows c 8.1, например, изменили и функцию GetVersion — если в манифесте не будет написано, какие системы Вы поддерживаете — GetVersion будет возвращать 6.2 (будто ваше приложение запущено на Windows 8 на всех системах старше Windows 8) или максимально подходящую из списка систем который вы добавите. Так что манифест нужен по многим причинам. Пример манифеста можно найти тут, а все возможные опции манифеста перечислены вот тут.

Наше приложение может иметь 4 варианта работы с DPI:

  1. DPI Unaware — ничего не знает о DPI (всё будет делать система, но уж как получится)
  2. System DPI Awareness — берём системный DPI и на старте инициализируем наше приложение под него (Widows Vista)
  3. Per-Monitor — берём не системный DPI, а DPI монитора, на котором отображается приложение. Обрабатываем сообщения когда пользователь перетаскивает приложение между разными мониторами с разным DPI, но они приходят только к top level window (Widows 8.1)
  4. Per-Monitor (V2) — тоже что и Per-Monitor, но сообщения система присылает всем нашим окнам. Плюс система сама подстраивает размеры не клиентской части окна, common controls и начальные размеры диалоговых окон созданных через CreateDialog (Windows 10 начиная с Creators Update сборка 1703).


В последних трёх режимах система «убирает руки» от нашего приложения (почти), ничего не трогает и отдаёт нам все координаты и размеры в пикселях не виртуализируя их. Конечно, кроме вышеперечисленных моментов. Для нас лучше всего подойдёт Per-Monitor режим, а возможности Per-Monitor V2 будет только мешать.

Но тут не без проблем, конечно. Так как я люблю делать диалоговые окна в дизайнере Visual Studio — появляется серьёзная проблема. Сама Microsoft предлагает создавать диалоговые приложения вручную и все элементы в окно добавлять тоже вручную — на это я, простите, не подписывался. Проблема в том, что размеры диалогового окна в ресурсах (dialog template) хранятся в DLU (DIALOG UNITS). А текущее значение DLU меняются в зависимости от DPI системы. Получается у нас и DPI и DIALOG UNITS наслаиваются друг на друга. Причём налету они (DIALOG UNITS) не меняются в отличии от DPI и после изменения DPI срабатывают только в новой сессии. Мало того, если использовать функцию GetDialogBaseUnits () и попробовать посчитать изменения — размер поплывёт. Получается вычисление реального размера окна зависит ещё от каких-то параметров, что косвенно подтверждает вот это сообщение. То есть мы не сможем указать начальный и корректный размер на старте приложения. От дизайнера диалогов я отказываться не хочу и так появился код, который приводит начальный размер окна и элементов в окне будто он всегда в 96 DPI, а потом уже мы будем менять его в зависимости от DPI. Пришлось для этого написать обход ресурсов диалога и его элементов. Далее размеры окон/элементов перемножаются на DLU по умолчанию и наше окно на инициализации приводится к этому виду.

А вот теперь всё просто — после корректировки размеров окна и элементов на старте, берём DPI по умолчанию (96) и делаем начальную инициализацию размера окна под текущий DPI нашего окна, так же поступаем со всеми дочерними элементами в окне. Можно это сделать в один этап, но я оставил в два. А то совсем не понятно будет — что там происходит.

Но надо не забыть что, когда окну приходит сообщение WM_DPICHANGED, первым параметром будет новый DPI, а вторым рекомендуемый размер и положение окна, которое нам присылает система. Если этого не учитывать и считать самому — окно будет шарахаться на пол экрана (ну смотря как реализуете), когда будете его перетаскивать между мониторами с разным DPI.
То есть заниматься размером самого окна — не надо, система нам пришлёт нужный размер. А вот дочерние окна надо обработать (у нас же per-monitor режим версии 1).

Приложении реализовано как одно окно плюс InstallManager с разнообразными общими свойствами и опциями (реализован через singleton). Так в моём проекте гораздо удобнее, а тут тоже вроде не мешает.

Теперь детальное описание файлов проекта. Всё для окон в папке: webinstaller\windows\

Абстрактный, общий класс окон

class AbstractDialog


Базовый класс — удобно шарить основной функционал если окон несколько

class BaseDialog : public AbstractDialog


Обратите внимание на OnEraseBackground, там реализована возможность градиентного фона для окна. Для этого используется WINAPI функция GradientFill.

Если что, то фон можно поменять на обычный в webinstaller\install\installer_constants.h, достаточно убрать объявление GRADIENT_BG.

Конкретное окно — в нашем случае основное

class MainDialog : public BaseDialog


Всякие утилиты разной степени полезности

namespace WindowsUtils


Например RectHolder, который наследуется от структуры RECT и содержит несколько дополнительных методов типа — Width и Height. Иначе очень надоедает писать blablabla.bottom — blablabla.top и т.д.

И замена MulDiv (MultiplyThenDivide), которая не округляет результат, иначе могу вылезать неприятные скачки размеров при небольших значениях (важно для шрифтов).

Да, кстати, всё это частично выдрано из живого проекта, который заметно сложнее этого, так что не обессудьте — всякие странности в наличии. Местами приходилось на лету дописывать нужное взамен убранного и выглядит нелогично. В конце концов это же пример. Ну и загрузка, распаковка, установка сделаны заглушкой — это всё очень легко делается через WinInet функции и к основной теме статьи не очень относится.

А что у нас с элементами управления… По части поддержки DPI — всё просто, это делает окно. Да и остальная реализация тоже не особо сложная.

всё для лежит в папке: webinstaller\ui\

Базовый класс для элементов ui

class BaseControl


Наш static элемент, ну просто текст

class StaticControl : public BaseControl


Что интересного, так как может понадобиться перерисовать элемент без перерисовки окна, а фон у нас может быть с градиентом — приходится при первой отрисовке сохранить фон под элементом и рисовать каждый раз на нём. И там же ещё маленькая недоработка, иногда этот буфер читается, когда окно его не очистило и при перерисовке поверх — текст становится чуть жирнее. Вдруг найдёте как обойти :)

Текст, который ссылка

class LinkControl : public StaticControl


Хитрый элемент, который позволяет добавить и текст и несколько ссылок (нижняя строчка в окне)

class CompositeLinkControl : public LinkControl


Сделано чтобы удобно было писать про соглашение с пользователем и политику конфиденциальности. Если делать отдельными элементами, тогда будет морока с отступами и выравниванием.

Индикатор прогресса

class ProgressControl : public BaseControl


Обычный базовый элемент, который рисует полоску двумя цветами в зависимости от данных. Плюс режим marquee (трудно подобрать какое-то правильное описание по-русски в одно слово). Это когда мы точно не знаем сколько будет длиться операция и просто рисуем короткую полосу, которая по кругу ползает по индикатору. Для этого надо вызывать функцию элемента по таймеру из окна с параметрами 0, 0.

Квадратная кнопка

class RectButtonControl : public LinkControl


Кнопка условно квадратная, если установить цвет конкретно кнопки, то будет фон и края фона будут скруглены и размазаны — для красоты, конечно.
Текст в кнопке может рисоваться с тенью.

Наш хитрый элемент для векторного лого (и т.п.)

class LayeredStaticControl : public StaticControl


Начнём издалека. Так как размер у нас не один будет и не два (100%, 125%, 150%, 175%, 200% …), запастись картинками на все разрешения выглядит расточительно, в тоже время логотип обычно векторный. Но ничего популярного Windows не поддерживает кроме EMF, а это по сути — лог вызовов GDI/GDI+ для EMF/EMF+. Вариант неплохой, есть кое какие ограничения, но, если надо что-то достаточно сложное сделать и вы сможете завернуть в EMF+ нужное вам изображение — самое оно. Подкрутить отрисовку для приличного размытия краёв и может будет даже неплохо. Нужно только использовать GDI+ 1.1 ну и минимум это Windows Vista (суть в функции Metafile.ConvertToEmfPlus). Обычный EMF не сделает размытия краёв (antialiasing).

Если не использовать GDI+, тогда остаётся только один векторный компонент Windows — это отрисовка шрифтов. А поскольку она осуществляется одним цветом — придётся разбирать логотип на цвета.

Вот от того это и layered_static_control. Берётся несколько символов и рисуется последовательно один на другой с нужными цветами. На самом деле начертания символов и так составные в шрифте — так что тут ничего особо нового. Разве что мы этот механизм не сможем использовать, так как он внутри компонента и цвет поменять нельзя.

Для примера я взял SVG мыши из Twitter«a, разобрал на 4 части по количеству цветов, взял шрифт OpenSans и положил их туда вместо символов скобок подправив совсем чуть-чуть. Всё это делается за полчаса в удобном редакторе с открытым исходным кодом — FontForge. Заодно у нас будет симпатичный шрифт в окне.

glfxmnao3f9baqpl5_vvycsc1hs.png

Я загрузил SVG в Illustrator, выгрузил в SVG же уже по частям. Загрузил в FontForge и в самом FontForge подправил чтобы контуры замыкались. Для этого надо вызвать проверку ошибок элемента в FontForge: Элемент → Проверка ошибок → Контуры → Открытые контуры. После проверки он подсветит нужную точку и надо просто схватить, подвинуть и положить на тоже место — он сам замкнёт этот контур.

Далее два важных шага: не забыть выставить символам одинаковые размеры/метрики (тем, которые мы используем для слоёв) и поменять имя шрифта (его свойства в FontForge) иначе, когда мы попробуем его загрузить уже в приложение, может оказаться, что он уже установлен в системе и будет использоваться системный (оригинальный).

Кстати, раз мы место экономим — можно удалить все неиспользуемые символы из шрифта, он станет тогда совсем маленьким (русские + английские буквы + цифры и знаки — 29 кб). Главное осторожно, многие символы в шрифте — составные. Верхняя чёрточка в букве й, например, находится в самом конце шрифта OpenSans и видна как отдельный элемент.

В сам элемент мы добавляем слои и их цвет — всё просто.

Части логотипа/символы можно рисовать с градиентами — для этого надо задействовать GDI+, там есть градиентные кисти. Надо сделать только новую реализацию StaticControl и LayeredStaticControl под GDI+.

Кстати, кнопка закрытия окна всё же не совсем похожа на букву Х, поэтому тоже сделана отдельно в шрифте (вместо \).

Элемент для выбора опций и настроек (checkbox)

class CompositeCheckboxControl : public CompositeLinkControl


Тут тоже без вектора не обошлось, надо же как-то рисовать галку и всё остальное. Как я писал ранее — перерисовывать common controls может только Windows 10.1703. Всё аналогично примеру с мышью в логотипе, нужна будет рамка, чистый фон и сам флажок.

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

Элемент, для выбора который сам меняет статус выбранной опции в InstallManager — в остальном полностью наследует CompositeCheckboxControl

class OptionCheckboxControl : public CompositeCheckboxControl

Манифест приложения

webinstaller\installer\install.manifest


Обратите внимание на строку:

True/PM


Именно этим мы говорим системе, что поддерживаем режим DPI per Monitor. Все остальные DPI опции перечислены тут.

Синглтон менеджер

class InstallerManager


Различные системные функции (обёртки)

namespace SystemUtils


Из ресурсов у нас только шрифт и иконка

webinstaller\installer\resources\webinstaller.ttf
webinstaller\installer\resources\webinstaller.ico


Ну вот в общем-то и всё. 160 кб на всё про всё (конечно без загрузки из сети, но это несколько кб от силы).

Что у нас в итоге по части DPI в WINAPI приложениях:

Если не рисовать все элементы окна самому — тогда можно переложить почти все проблемы с отрисовкой на Windows, но только начиная с Windows 10.1703. Решать проблемы в этом случае надо только с растровой графикой. Достаточно добавить в манифест приложения специальную опцию — gdiscaling и установить её в true. Тут подробно описано — как это работает. Конечно, если вы используете для отрисовки GDI или ваш фреймворк использует GDI.

Если не добавлять наборы растровых картинок в приложение — можно взять размером побольше и просто уменьшать через GDI+ под нужное разрешение — там есть достаточно приличный алгоритм интерполяции. Сама Microsoft его и советует.

В целом получается или делаем сами, или оставляем на откуп системе. Тем более Microsoft готовит новую опцию в Spring Creators Update — разрешить Windows исправлять размытость приложений. Как она работает пока не ясно, но скорее всего принудительно включает GDI scaling.

А, ну и конечно репозитарий с приложением на github.

© Habrahabr.ru