Если вы разрабатываете мобильные приложения, то почти наверняка сталкивались с Flutter — мегапопулярным открытым фреймворком от Google. Наша команда Mobile SDK and Applications Development, конечно, тоже захотела использовать Flutter при создании приложений для KasperskyOS — собственной микроядерной операционной системы «Лаборатории Касперского» — но был нюанс…
Особенности архитектуры KasperskyOS задают условия, при которых мы не можем пойти проторенными дорожками и свободно интегрировать фреймворк на своей платформе. Подсмотреть решения где-то вовне мы не можем тоже — таких кейсов в индустрии просто не было. А сам Google практически не раскрывает внутреннюю архитектуру Flutter. Так что интеграцию требовалось выстраивать с нуля.
И мы залезли под капот Flutter и нашли решения, которые, с одной стороны, были бы удобны разработчикам, а с другой — устраивали бы нас с точки зрения безопасности и производительности.
Если вы тоже разрабатываете приложения, где требуется разбираться во внутренней архитектуре Flutter, статья точно будет вам полезна — ныряйте под кат!
Проанализировав доступные фреймворки для разработки пользовательского интерфейса, мы решили взяться за Flutter. Когда принимали это решение, он был наиболее зрелым для использования. Но нужно было понять, как его затащить к себе на платформу.
Мы обратились к архитектурной документации, доступной на сайте Flutter (https://docs.flutter.dev/resources/architectural-overview).
Картинка красивая, но глубокого понимания она не дает. Единственное, что из нее понятно, — нам нужен Flutter Engine. Он «плюсовый», его необходимо собрать и запустить на нашей платформе. А интегрироваться придется на слое Embedder.
Вот что представляет собой Flutter Engine:
Точка входа — это небольшой файлик, который с помощью утилиты .gclient вытягивает один репозиторий. В нем лежит файл DEP, который начинает тянуть за собой другие репозитории. Полное дерево исходников состоит из нескольких десятков репозиториев (включая сторонние) и занимает около 15 ГБ. Только после этого инструмент можно начинать строить.
Для сборки во Flutter Engine используется гугловый инструмент GN, который генерирует исходные файлы для Ninja (это аналог cmake для make). Во Flutter он немного кастомизирован. А Ninja собирает весь проект, утилизируя доступные ресурсы CPU. Язык скриптов GN (BUILD.gn) достаточно развитой и позволяет создавать модули и писать сложную логику. В чем-то он похож на Python или Basel. В целом внедрить в него что-то проще, чем ковыряться в cmake.
У нас же есть собственная самописная система сборки Kaspersky Build — так исторически сложилось. Основной ее посыл в том, чтобы добиться повторяемых сборок, поэтому на стадии построения мы уже ничего не скачиваем из Сети. Теоретически это сделать можно, но запрещено политиками безопасности. Поэтому мы взяли исходники Flutter — срез в определенный момент времени — и сделали из них монорепозиторий для сборки.
С точки зрения разработчика KasperskyOS SDK, это DEB-пакет, который устанавливается на Linux-машину. Внутри находятся два sysroot под x86-64 и aarch64 и набор хостовых toolchain для построения. Соответственно, в систему сборки Flutter Engine нам пришлось добавить новую платформу — KasperskyOS — где мы переключились на sysroot и toolchain из SDK. Для сборки мы сначала пробовали gcc, но потом перешли на clang (сейчас стратегия — полностью отказываться от gcc и переходить на clang).
А дальше пришлось повозиться: поискать все платформозависимые части Flutter и Dart, добавив их реализацию для KasperskyOS. Их оказалось много, и они не лежат в одном месте. Обычно это папки c именем платформы (например, linux) и файлы с суффиксами в имени файлов (например, thread_linux.cc). С этим мы справились, но далее необходимо было понять, что представляет собой приложение Flutter — как его собрать и запустить.
Также пришлось поковыряться с ключами компилятора Flutter, потому что мы не используем Flutter run, — нам нужно запускаться на железе и под QEMU. Мы поняли, что компилятор Flutter умеет генерировать специальный набор asset-папок (в их терминологии). Внутри — некий черный ящик, не ELF-файлы, а что-то собственное, просто набор файлов и подпапок, который нужно положить куда-то в файловую систему. Создавая инстанс Flutter Engine, мы передаем ему путь к этой папке, и он сам парсит ее содержимое и запускает приложение, за исключением случая, когда используется режим сборки Release и ahead of time компиляция Ahead-of-Time.
В режиме Release Flutter Engine собирает не какое-то там промежуточное представление для раскручивания JIT-компилятором, а непосредственно ELF-файл под конкретную архитектуру. В этом случае запуск приложения происходит быстрее, но нет отладочных сервисов. А вот в режиме сборки Debug активен отладочный сервис, через который можно профилировать, отлаживать, ставить точки останова или делать hot reload. Это очень удобная штука.
Мы реализовали интеграцию через Embedder. Он позволил прокинуть мышку и получать готовый кадр в виде callback, когда Flutter его отрисовал. То была первоначальная интеграция, позволившая посмотреть, как вообще вся эта машинерия работает. В итоге мы получили приложение, которое без учета оконной системы целиком пишет кадр во Frame Buffer. Но дальше пришло понимание, что нам необходима более глубокая интеграция с платформой, чтобы вписать все в оконную систему и обеспечить общение с Application Lifecycle Management.
Любая развитая платформа, в которую глубоко интегрирован Flutter (например, Android, Windows…), имеет свой Shell. Так мы пришли к тому, что надо писать полноценный компонент для Flutter Engine, и назвали его KOS Shell (KasperskyOS Shell).
KOS Shell — это часть Flutter Engine, отдельная папка внутри него и примерно 5 тысяч строк кода. Благодаря ему мы получили доступ к внутреннему API Embedder-а и вспомогательным классам, которые помогают взаимодействовать с платформенными каналами.
Канал — это, грубо говоря, именованный пайп. По нему можно гонять бинарные BLOB-ы. У каждого канала есть соглашение, в каком формате он передает данные. Это либо бинарный кодек, когда все сериализуется в некие бинарные представления, либо простой string, либо JSON в виде объекта. Практически все кодеки были готовы, за исключением string, который нам пришлось добавить. Кроме того, у канала есть мессенджер, который непосредственно передает сформированный пакет.
Помимо внутренностей Flutter Engine, из KOS Shell мы, естественно, можем напрямую взаимодействовать с ОС. Например, нам нужно было интегрироваться с Wayland. Пожалуй, это самая большая часть работы по графическим протоколам. Кроме того, доступен Setting Manager, позволяющий получать из системы текущую локаль и системную тему, а также Application Lifecycle Management — это лайфтайм-сервер. Любая сущность, запущенная в ОС, устанавливает с ним связь по определенным протоколам (и совершает хендшейк, иначе процесс будет прибит, поскольку он ведет себя некорректно).
Отдельно хотел рассказать про интеграцию с графической подсистемой по протоколам Wayland.
Мы проанализировали, что есть в open source, но решили не тянуть это к себе, а просто написали свое. Wayland, в принципе, штука хорошая, но имеет чисто «сишный» интерфейс. И это не очень хорошо, поскольку по своей природе вейландовские интерфейсы объектно-ориентированные. Поэтому мы писали на C++ небольшую библиотеку Wayland Plus. «Мозга» в ней практически нет, это обертка для интерфейсов C. С ее помощью можно сначала зарегистрировать базовые сущности — дисплей и те интерфейсы, с которыми предстоит работать. После этого можно коннектиться к дисплею. Все инстансы создаются автоматически, а ты взаимодействуешь с ними, как с готовыми объектами. Со временем мы планируем опубликовать эту библиотеку в open source — возможно, кому-то она также будет полезна.
Примеры кода:
Далее нам пришлось написать полноценный Wayland-клиент именно для Flutter с поддержкой XDG Shell v6. С виртуальной клавиатурой мы интегрировались через протокол ZWP Text Input v2. Ну и как я уже говорил, мы использовали специфический мобильный протокол KOS Shell для расширения стандартных возможностей.
Wayland-сервер содержит в себе Compositor. Он готовит итоговый фрейм, который выводится на экран. Если говорить упрощенно, каждый клиент создает свое окно, а композитор знает, что, например, в мобильной системе сверху есть строка состояния, внизу — навигационная панель, а где-то сбоку — окно приложения или виртуальная клавиатура. Он все это композирует и выводит на экран.
Как я уже упоминал, Shared Memory у нас запрещено, но в данном случае в доверенном коде мы сделали исключение, потому что гонять кадры по IPC через монитор безопасности — плохая идея. Обычно Wayland-клиент создает буфер в Shared Memory с помощью специального (стандартного) протокола и регистрирует его на сервере. После этого начинает отрисовку и кладет кадры в Shared Memory, откуда сервер через композитор забирает их и отправляет во Framebuffer. Но в KOS есть свои особенности, связанные с безопасностью.
Пожалуй, самый большой объем кода пришлось написать для интеграции с клавиатурой. У Flutter пришлось задействовать следующие каналы:
Было много нюансов с композингом текста, типом клавиатуры, подсказкой. Недавно сделали режим оверлея — в этом режиме главное окно приложения не меняет свой размер, когда мы показываем виртуальную клавиатуру. Для этого мы сообщаем Flutter, какая часть экрана перекрыта, и он адаптирует контент, чтобы это перекрытие не мешало взаимодействию с пользователем. Аналогично, когда приложение уходит в фон, мы сообщаем Flutter, что оно свернуто, и он снижает свою активность, уменьшая энергопотребление.
Интегрироваться нужно было и с навигационной панелью. Когда мы внизу нажимаем Back, Flutter должен понимать, что пора переходить между окнами. А когда мы меняем тему, то передаем ее через канал Flutter/Settings.
Flutter взлетел, но по перформансу все обстояло не очень хорошо, особенно на ARM. Анимации практически не было видно. Стали разбираться, в чем дело, построили Flame Graph.
Первое, что бросилось в глаза, — нативный цикл обработки сообщений. В нем был использован довольно тяжеловесный системный вызов pselect. Мы переписали его, используя более легковесные примитивы синхронизации.
Следующим шагом мы подняли уровень оптимизаций в компиляторе при сборке Flutter Engine (O0 -> O3). Также в Skia (это достаточно известная библиотека, которую Flutter использует для отрисовки) включили инструкции SIMD/NEON. И в результате мы были удивлены — на ARM все залетало, это был серьезный шаг вперед.
Следующее «горячее» место — система отрисовки. Она работает по протоколу, заложенному во внешний Embedder API. Он создает внутри буфер Backing Store, в нем все отрисовывает, а потом вызывает callback, чтобы кадр забрали и скопировали.
Поскольку он не давал возможности поменять формат пикселей, в котором рендерит кадр, нам приходилось каждый пиксель конвертировать.
Схема работает с двумя буферами — один из них привязан к Wayland Surface. Мы отрисовываем в один буфер, а активный при этом другой (его трогать нельзя). После чего мы переключаем буфер и Wayland забирает готовую картинку.
Мы поменяли эту схему, отказавшись от промежуточного буфера, чтобы Flutter Engine рисовал напрямую в буфер, который находится в Shared Memory (даже не на стороне Wayland-клиента, а на стороне сервера). Это самый быстрый путь. Для этого нам пришлось внести изменения в интерфейс Flutter Engine Embedder API, сохранив при этом обратную совместимость.
То есть если мы задаем старый callback, то работаем в обычном режиме. Если же мы задаем вновь добавленный callback, то все пойдет иначе. Flutter Engine, вместо того чтобы отрисовывать в существующий буфер, будет запрашивать новый буфер уже у нас.
Были определенные сложности, как обеспечить Lifecycle, потому что буфер нельзя удалять, пока его не освободил Flutter Engine. А тот его не может освободить, пока буфер использует Wayland Client или Wayland Server. Все эти моменты пришлось продумать. Мы также добавили возможность выбрать тип пикселей для отрисовки, когда создаем и конфигурируем Flutter Engine.
В результате этих преобразований у нас прекратились конвертации — мы пишем напрямую в Shared Memory, благодаря чему скорость еще возросла настолько, что Wayland Server перестал справляться с количеством кадров, которые генерирует Flutter при интенсивной анимации. Нам пришлось даже добавить синхронизацию по протоколу Wayland: мы запрашиваем callback у сервера, который тот посылает, когда готов принять следующий кадр. После этого все стало работать хорошо.
Текущая версия Flutter на платформе — 3.24.2. Кажется, не так давно вышла 24.3, но в целом это достаточно свежая версия.
Нам приходится брать Flutter SDK с сайта, адаптировать его и класть в SDK KasperskyOS for Mobile. Чтобы сделать из upstream-версии Flutter Engine соответствующую версию для KasperskyOS, нужно применить 400+ патчей с изменениями около 14 тысяч строк кода.
Мы попытались немного автоматизировать этот процесс. Есть скрипт, который пытается накладывать эти патчи, но возникающие конфликты необходимо фиксить руками. Если это делать регулярно, не пропуская много времени, обычно все накладывается неплохо.
Как я уже писал, сборка оркестрируется Kaspersky Build, но каждый компонент может иметь свою систему сборки внутри. В рецепте для каждого компонента указано, какими командами его нужно собирать. Большинство компонентов внутри у нас собираются cmake. Но скачать что-то из Сети не получится физически, так что перед началом сборки все сорцы должны быть уже скачаны.
Чтобы пользователям было проще, все команды Flutter, которые нужны для генерации asset-ов, мы спрятали внутрь, поскольку в основном у нас используется именно эта система сборки для всех приложений. В модуле cmake при этом можно указать локализованное название приложения, которое будет отображаться на рабочем столе, а сама иконка также задается через cmake.
После компиляции приложение вместе с метаданными упаковывается в KPA-файл (аналог APK для KasperskyOS), который может быть установлен непосредственно на ARM-железо или на графический эмулятор QEMU. Корпоративная система разработки для KasperskyOS — это VS Code. И у нас есть полноценный плагин для настройки эмулятора, который позволяет делать это достаточно быстро и удобно. То есть можно собрать приложение и сразу же установить его в эмулятор.
Ниже — эмулятор из состава SDK на базе QEMU, в котором запущено первое полноценное графическое приложение «Заметки» на мобильной версии KasperskyOS.
«Заметки» — первое приложение, которое мы писали сами для проверки. Надо отметить, что во Flutter затраты на написание приложения намного меньше, чем при создании его на Qt Qml, и выглядит оно симпатичнее.
На этом все. Надеюсь, что мой опыт будет вам полезен и теперь вам не придется по крупицам собирать документацию по внутренней архитектуре Flutter и его склейке с платформой.
Если вам интересно узнать больше о подкапотном устройстве KasperskyOS, то оставляю пару полезных ссылок:
А если вы хотите приложить руку к созданию и развитию нашей оси и продуктов под нее, приходите в нашу команду. Пройти все этапы собеседований можно за пару дней; спектр возможных задач, как вы уже успели убедиться, весьма интересен, а список новых фич огромен :) До встречи!
Особенности архитектуры KasperskyOS задают условия, при которых мы не можем пойти проторенными дорожками и свободно интегрировать фреймворк на своей платформе. Подсмотреть решения где-то вовне мы не можем тоже — таких кейсов в индустрии просто не было. А сам Google практически не раскрывает внутреннюю архитектуру Flutter. Так что интеграцию требовалось выстраивать с нуля.
И мы залезли под капот Flutter и нашли решения, которые, с одной стороны, были бы удобны разработчикам, а с другой — устраивали бы нас с точки зрения безопасности и производительности.
Если вы тоже разрабатываете приложения, где требуется разбираться во внутренней архитектуре Flutter, статья точно будет вам полезна — ныряйте под кат!
Для полноценного понимания контекста: несколько слов о KasperskyOS
Пожалуй, самый популярный вопрос, который задают люди, впервые услышавшие о KasperskyOS: на каком дистрибутиве Linux она базируется. Но KasperskyOS — это не Linux, это полностью оригинальная операционная система, написанная с нуля.
Ответ на следующий по популярности вопрос — «зачем писать с нуля новую ОС» — лежит в плоскости безопасности и связан с многолетней экспертизой «Лаборатории Касперского». Компания пришла к выводу, что безопасность, обеспеченная наложенными средствами, такими как антивирусы, хороша; но это как постоянно вычерпывать воду из дырявой лодки — работа полезная, но конца-краю ей не видно. Лучше изначально базироваться на принципе, который мы назвали кибериммунностью, то есть попытаться на уровне архитектуры ОС минимизировать риски любых угроз, чтобы вредоносный код в принципе не мог сделать ничего кардинально плохого. Научная база, на которой основаны эти рассуждения, придумана не нами. Мы просто попробовали их реализовать.
Первый принцип — микроядерность. KasperskyOS построена на базе микроядерной архитектуры, что позволяет свести к минимуму уязвимости ядра. Важная деталь: драйверы устройств находятся в user space, а не в ядре, и не могут привести систему к «синему экрану смерти», как в Windows, или kernel panic, как в Linux. При сбое драйвер может быть просто перезапущен.
Микроядро компактно и не растет со временем (в отличие от Linux, Android, Windows). Сейчас в Linux ~ 27 миллионов строк кода, в KasperskyOS ~ 100 тысяч.
В мире очень мало ОС, которые могут похвастаться микроядерностью, — их можно пересчитать по пальцам. Родоначальник этого направления — семейство микроядер L4 Йохена Лидтке. Также известны MiINIX — обучающая ОС, QNX — коммерчески успешная закрытая ОС, и несколько других.
KasperskyOS базируется на научных концепциях MILS (Multiple Independent Levels of Security) и FLASK (Flux Advanced Security Kernel), которые в совокупности наделяют ее уникальными свойствами.
KasperskyOS — продукт не новый, его идея возникла давно, но долгое время он был исследовательским. Только где-то к 2018 году было принято решение превратить KasperskyOS в платформу и делать на ее основе несколько продуктов для встроенных, графических и мобильных систем.
Я, как уже отметил выше, занимаюсь мобильной версией ОС — KasperskyOS for Mobile. Этот проект развивался достаточно долго и прошел большой путь, прежде чем превратился в нечто, готовое к использованию. На данный момент в ОС есть базовый набор возможностей:
Прямые сравнения с Android в данный момент некорректны, потому что KasperskyOS — очень молодой продукт, и сейчас мы видим его скорее в сфере корпоративных (профессиональных) устройств, где важны высокая безопасность и надежность. Хотя в целом, конечно, надеемся, что продукт будет развиваться в нечто большее и когда-нибудь придет и на обычный рынок.
Kaspersky не занимается производством железа, но под это направление нам пришлось создать небольшую лабораторию, где мы эмулируем базовые станции, термокамеру, систему контроля энергопотребления, спутниковую навигацию (кстати, ранее устройство с KasperskyOS было успешно протестировано в независимой лаборатории на соответствие стандартам сотовой связи 3GPP). У нас даже есть такая интересная штука, которая автоматически кликает на экран, — Tapster.
Точка роста любой новой молодой ОС — это экосистема, которую нужно развивать, привлекая разработчиков. Мы стремимся облегчить жизнь новым коллегам, которые будут подключаться к развитию системы, и посчитали, что лучше всего людям давать что-то, к чему они привыкли. Поэтому кроссплатформенные фреймворки, которые одинаково выглядят на разных платформах, — это здорово, они уменьшают порог входа. На данный момент у нас есть:
Rust, Dart и Flutter — это наши последние достижения.
Мы постоянно расширяем экосистему KasperskyOS, стараемся сделать ее максимально комфортной для новых разработчиков, но иногда нужного инструмента может не оказаться. Поэтому сейчас в проработке концепция, когда разработчик сможет сам с минимальными усилиями принести на платформу привычный для себя фреймворк.
KasperskyOS — частично POSIX-совместимая система, за исключением небезопасных подсистем стандарта. Например, fork() мы не поддерживаем, но есть документация с примерами, чем его можно заменить. Аналогичная история с Shared Memory, поскольку в общем случае через нее можно сделать что-то в обход монитора безопасности.
Кроме того, у нас есть поддержка базовых протоколов Wayland, а также гарантируется совместимость со стандартным расширением XDG v6. Для мобильно-специфичных вещей добавлено небольшое расширение KOS Shell.
Ответ на следующий по популярности вопрос — «зачем писать с нуля новую ОС» — лежит в плоскости безопасности и связан с многолетней экспертизой «Лаборатории Касперского». Компания пришла к выводу, что безопасность, обеспеченная наложенными средствами, такими как антивирусы, хороша; но это как постоянно вычерпывать воду из дырявой лодки — работа полезная, но конца-краю ей не видно. Лучше изначально базироваться на принципе, который мы назвали кибериммунностью, то есть попытаться на уровне архитектуры ОС минимизировать риски любых угроз, чтобы вредоносный код в принципе не мог сделать ничего кардинально плохого. Научная база, на которой основаны эти рассуждения, придумана не нами. Мы просто попробовали их реализовать.
Первый принцип — микроядерность. KasperskyOS построена на базе микроядерной архитектуры, что позволяет свести к минимуму уязвимости ядра. Важная деталь: драйверы устройств находятся в user space, а не в ядре, и не могут привести систему к «синему экрану смерти», как в Windows, или kernel panic, как в Linux. При сбое драйвер может быть просто перезапущен.
Микроядро компактно и не растет со временем (в отличие от Linux, Android, Windows). Сейчас в Linux ~ 27 миллионов строк кода, в KasperskyOS ~ 100 тысяч.
В мире очень мало ОС, которые могут похвастаться микроядерностью, — их можно пересчитать по пальцам. Родоначальник этого направления — семейство микроядер L4 Йохена Лидтке. Также известны MiINIX — обучающая ОС, QNX — коммерчески успешная закрытая ОС, и несколько других.
KasperskyOS базируется на научных концепциях MILS (Multiple Independent Levels of Security) и FLASK (Flux Advanced Security Kernel), которые в совокупности наделяют ее уникальными свойствами.
- MILS — это по сути разделение на уровни;
- FLASK — говорит о том, что есть монитор безопасности, который контролирует любые взаимодействия между процессами в системе. Это позволяет написать политики, благодаря которым для какого-то конкретного продукта будет обеспечена безопасность.
KasperskyOS — продукт не новый, его идея возникла давно, но долгое время он был исследовательским. Только где-то к 2018 году было принято решение превратить KasperskyOS в платформу и делать на ее основе несколько продуктов для встроенных, графических и мобильных систем.
Я, как уже отметил выше, занимаюсь мобильной версией ОС — KasperskyOS for Mobile. Этот проект развивался достаточно долго и прошел большой путь, прежде чем превратился в нечто, готовое к использованию. На данный момент в ОС есть базовый набор возможностей:
Прямые сравнения с Android в данный момент некорректны, потому что KasperskyOS — очень молодой продукт, и сейчас мы видим его скорее в сфере корпоративных (профессиональных) устройств, где важны высокая безопасность и надежность. Хотя в целом, конечно, надеемся, что продукт будет развиваться в нечто большее и когда-нибудь придет и на обычный рынок.
Kaspersky не занимается производством железа, но под это направление нам пришлось создать небольшую лабораторию, где мы эмулируем базовые станции, термокамеру, систему контроля энергопотребления, спутниковую навигацию (кстати, ранее устройство с KasperskyOS было успешно протестировано в независимой лаборатории на соответствие стандартам сотовой связи 3GPP). У нас даже есть такая интересная штука, которая автоматически кликает на экран, — Tapster.
Точка роста любой новой молодой ОС — это экосистема, которую нужно развивать, привлекая разработчиков. Мы стремимся облегчить жизнь новым коллегам, которые будут подключаться к развитию системы, и посчитали, что лучше всего людям давать что-то, к чему они привыкли. Поэтому кроссплатформенные фреймворки, которые одинаково выглядят на разных платформах, — это здорово, они уменьшают порог входа. На данный момент у нас есть:
Rust, Dart и Flutter — это наши последние достижения.
Мы постоянно расширяем экосистему KasperskyOS, стараемся сделать ее максимально комфортной для новых разработчиков, но иногда нужного инструмента может не оказаться. Поэтому сейчас в проработке концепция, когда разработчик сможет сам с минимальными усилиями принести на платформу привычный для себя фреймворк.
KasperskyOS — частично POSIX-совместимая система, за исключением небезопасных подсистем стандарта. Например, fork() мы не поддерживаем, но есть документация с примерами, чем его можно заменить. Аналогичная история с Shared Memory, поскольку в общем случае через нее можно сделать что-то в обход монитора безопасности.
Кроме того, у нас есть поддержка базовых протоколов Wayland, а также гарантируется совместимость со стандартным расширением XDG v6. Для мобильно-специфичных вещей добавлено небольшое расширение KOS Shell.
Flutter глазами разработчика
Проанализировав доступные фреймворки для разработки пользовательского интерфейса, мы решили взяться за Flutter. Когда принимали это решение, он был наиболее зрелым для использования. Но нужно было понять, как его затащить к себе на платформу.
Мы обратились к архитектурной документации, доступной на сайте Flutter (https://docs.flutter.dev/resources/architectural-overview).
Картинка красивая, но глубокого понимания она не дает. Единственное, что из нее понятно, — нам нужен Flutter Engine. Он «плюсовый», его необходимо собрать и запустить на нашей платформе. А интегрироваться придется на слое Embedder.
Вот что представляет собой Flutter Engine:
Точка входа — это небольшой файлик, который с помощью утилиты .gclient вытягивает один репозиторий. В нем лежит файл DEP, который начинает тянуть за собой другие репозитории. Полное дерево исходников состоит из нескольких десятков репозиториев (включая сторонние) и занимает около 15 ГБ. Только после этого инструмент можно начинать строить.
Для сборки во Flutter Engine используется гугловый инструмент GN, который генерирует исходные файлы для Ninja (это аналог cmake для make). Во Flutter он немного кастомизирован. А Ninja собирает весь проект, утилизируя доступные ресурсы CPU. Язык скриптов GN (BUILD.gn) достаточно развитой и позволяет создавать модули и писать сложную логику. В чем-то он похож на Python или Basel. В целом внедрить в него что-то проще, чем ковыряться в cmake.
У нас же есть собственная самописная система сборки Kaspersky Build — так исторически сложилось. Основной ее посыл в том, чтобы добиться повторяемых сборок, поэтому на стадии построения мы уже ничего не скачиваем из Сети. Теоретически это сделать можно, но запрещено политиками безопасности. Поэтому мы взяли исходники Flutter — срез в определенный момент времени — и сделали из них монорепозиторий для сборки.
С точки зрения разработчика KasperskyOS SDK, это DEB-пакет, который устанавливается на Linux-машину. Внутри находятся два sysroot под x86-64 и aarch64 и набор хостовых toolchain для построения. Соответственно, в систему сборки Flutter Engine нам пришлось добавить новую платформу — KasperskyOS — где мы переключились на sysroot и toolchain из SDK. Для сборки мы сначала пробовали gcc, но потом перешли на clang (сейчас стратегия — полностью отказываться от gcc и переходить на clang).
А дальше пришлось повозиться: поискать все платформозависимые части Flutter и Dart, добавив их реализацию для KasperskyOS. Их оказалось много, и они не лежат в одном месте. Обычно это папки c именем платформы (например, linux) и файлы с суффиксами в имени файлов (например, thread_linux.cc). С этим мы справились, но далее необходимо было понять, что представляет собой приложение Flutter — как его собрать и запустить.
Также пришлось поковыряться с ключами компилятора Flutter, потому что мы не используем Flutter run, — нам нужно запускаться на железе и под QEMU. Мы поняли, что компилятор Flutter умеет генерировать специальный набор asset-папок (в их терминологии). Внутри — некий черный ящик, не ELF-файлы, а что-то собственное, просто набор файлов и подпапок, который нужно положить куда-то в файловую систему. Создавая инстанс Flutter Engine, мы передаем ему путь к этой папке, и он сам парсит ее содержимое и запускает приложение, за исключением случая, когда используется режим сборки Release и ahead of time компиляция Ahead-of-Time.
В режиме Release Flutter Engine собирает не какое-то там промежуточное представление для раскручивания JIT-компилятором, а непосредственно ELF-файл под конкретную архитектуру. В этом случае запуск приложения происходит быстрее, но нет отладочных сервисов. А вот в режиме сборки Debug активен отладочный сервис, через который можно профилировать, отлаживать, ставить точки останова или делать hot reload. Это очень удобная штука.
Мы реализовали интеграцию через Embedder. Он позволил прокинуть мышку и получать готовый кадр в виде callback, когда Flutter его отрисовал. То была первоначальная интеграция, позволившая посмотреть, как вообще вся эта машинерия работает. В итоге мы получили приложение, которое без учета оконной системы целиком пишет кадр во Frame Buffer. Но дальше пришло понимание, что нам необходима более глубокая интеграция с платформой, чтобы вписать все в оконную систему и обеспечить общение с Application Lifecycle Management.
Глубокая интеграция через создание собственного shell
Любая развитая платформа, в которую глубоко интегрирован Flutter (например, Android, Windows…), имеет свой Shell. Так мы пришли к тому, что надо писать полноценный компонент для Flutter Engine, и назвали его KOS Shell (KasperskyOS Shell).
KOS Shell — это часть Flutter Engine, отдельная папка внутри него и примерно 5 тысяч строк кода. Благодаря ему мы получили доступ к внутреннему API Embedder-а и вспомогательным классам, которые помогают взаимодействовать с платформенными каналами.
Канал — это, грубо говоря, именованный пайп. По нему можно гонять бинарные BLOB-ы. У каждого канала есть соглашение, в каком формате он передает данные. Это либо бинарный кодек, когда все сериализуется в некие бинарные представления, либо простой string, либо JSON в виде объекта. Практически все кодеки были готовы, за исключением string, который нам пришлось добавить. Кроме того, у канала есть мессенджер, который непосредственно передает сформированный пакет.
Помимо внутренностей Flutter Engine, из KOS Shell мы, естественно, можем напрямую взаимодействовать с ОС. Например, нам нужно было интегрироваться с Wayland. Пожалуй, это самая большая часть работы по графическим протоколам. Кроме того, доступен Setting Manager, позволяющий получать из системы текущую локаль и системную тему, а также Application Lifecycle Management — это лайфтайм-сервер. Любая сущность, запущенная в ОС, устанавливает с ним связь по определенным протоколам (и совершает хендшейк, иначе процесс будет прибит, поскольку он ведет себя некорректно).
Интеграция с Wayland
Отдельно хотел рассказать про интеграцию с графической подсистемой по протоколам Wayland.
Мы проанализировали, что есть в open source, но решили не тянуть это к себе, а просто написали свое. Wayland, в принципе, штука хорошая, но имеет чисто «сишный» интерфейс. И это не очень хорошо, поскольку по своей природе вейландовские интерфейсы объектно-ориентированные. Поэтому мы писали на C++ небольшую библиотеку Wayland Plus. «Мозга» в ней практически нет, это обертка для интерфейсов C. С ее помощью можно сначала зарегистрировать базовые сущности — дисплей и те интерфейсы, с которыми предстоит работать. После этого можно коннектиться к дисплею. Все инстансы создаются автоматически, а ты взаимодействуешь с ними, как с готовыми объектами. Со временем мы планируем опубликовать эту библиотеку в open source — возможно, кому-то она также будет полезна.
Примеры кода:
// Регистрируем необходимые нам для работы интерфейсы Wayland
display_.RegisterProvider<wayland::interface::wl::Compositor>();
display_.RegisterProvider<wayland::interface::wl::Output>();
display_.RegisterProvider<wayland::interface::wl::Shm>();
display_.RegisterProvider<wayland::interface::wl::Seat>();
display_.RegisterProvider<wayland::interface::xdg::Shell>();
display_.RegisterProvider<wayland::interface::xdg::ShellV6>();
// Запрашиваем у Display инстанс интерфейса
auto output = display_.Interface<wayland::interface::wl::Output>();
Check(output, "Failed to create output");
output->OnScale.connect([this, &engine](auto factor) {
// Лямбда для обработки события
}
// Создаем инстанс интерфейса wl::Surface
surface_ = display_.CreateSurface();
Check(surface_, "Failed to create wayland surface");
// Подписываемся на событие от интерфейса и тут же в лямбде пишем его обработчик
surface_->OnActive.connect([&engine](auto active) {
engine.Pause(!active);
});
// Создание XDG top level через вызов метода интерфейса XDG Shell
auto top_level = xdg_shell->CreateTopLevel(*surface_);
Check(top_level, "Failed to create XDG top level");
Далее нам пришлось написать полноценный Wayland-клиент именно для Flutter с поддержкой XDG Shell v6. С виртуальной клавиатурой мы интегрировались через протокол ZWP Text Input v2. Ну и как я уже говорил, мы использовали специфический мобильный протокол KOS Shell для расширения стандартных возможностей.
Wayland-сервер содержит в себе Compositor. Он готовит итоговый фрейм, который выводится на экран. Если говорить упрощенно, каждый клиент создает свое окно, а композитор знает, что, например, в мобильной системе сверху есть строка состояния, внизу — навигационная панель, а где-то сбоку — окно приложения или виртуальная клавиатура. Он все это композирует и выводит на экран.
Как я уже упоминал, Shared Memory у нас запрещено, но в данном случае в доверенном коде мы сделали исключение, потому что гонять кадры по IPC через монитор безопасности — плохая идея. Обычно Wayland-клиент создает буфер в Shared Memory с помощью специального (стандартного) протокола и регистрирует его на сервере. После этого начинает отрисовку и кладет кадры в Shared Memory, откуда сервер через композитор забирает их и отправляет во Framebuffer. Но в KOS есть свои особенности, связанные с безопасностью.
Интеграция с клавиатурой
Пожалуй, самый большой объем кода пришлось написать для интеграции с клавиатурой. У Flutter пришлось задействовать следующие каналы:
Было много нюансов с композингом текста, типом клавиатуры, подсказкой. Недавно сделали режим оверлея — в этом режиме главное окно приложения не меняет свой размер, когда мы показываем виртуальную клавиатуру. Для этого мы сообщаем Flutter, какая часть экрана перекрыта, и он адаптирует контент, чтобы это перекрытие не мешало взаимодействию с пользователем. Аналогично, когда приложение уходит в фон, мы сообщаем Flutter, что оно свернуто, и он снижает свою активность, уменьшая энергопотребление.
Интегрироваться нужно было и с навигационной панелью. Когда мы внизу нажимаем Back, Flutter должен понимать, что пора переходить между окнами. А когда мы меняем тему, то передаем ее через канал Flutter/Settings.
Нюансы перформанса
Flutter взлетел, но по перформансу все обстояло не очень хорошо, особенно на ARM. Анимации практически не было видно. Стали разбираться, в чем дело, построили Flame Graph.
Первое, что бросилось в глаза, — нативный цикл обработки сообщений. В нем был использован довольно тяжеловесный системный вызов pselect. Мы переписали его, используя более легковесные примитивы синхронизации.
Следующим шагом мы подняли уровень оптимизаций в компиляторе при сборке Flutter Engine (O0 -> O3). Также в Skia (это достаточно известная библиотека, которую Flutter использует для отрисовки) включили инструкции SIMD/NEON. И в результате мы были удивлены — на ARM все залетало, это был серьезный шаг вперед.
Следующее «горячее» место — система отрисовки. Она работает по протоколу, заложенному во внешний Embedder API. Он создает внутри буфер Backing Store, в нем все отрисовывает, а потом вызывает callback, чтобы кадр забрали и скопировали.
Поскольку он не давал возможности поменять формат пикселей, в котором рендерит кадр, нам приходилось каждый пиксель конвертировать.
Схема работает с двумя буферами — один из них привязан к Wayland Surface. Мы отрисовываем в один буфер, а активный при этом другой (его трогать нельзя). После чего мы переключаем буфер и Wayland забирает готовую картинку.
Мы поменяли эту схему, отказавшись от промежуточного буфера, чтобы Flutter Engine рисовал напрямую в буфер, который находится в Shared Memory (даже не на стороне Wayland-клиента, а на стороне сервера). Это самый быстрый путь. Для этого нам пришлось внести изменения в интерфейс Flutter Engine Embedder API, сохранив при этом обратную совместимость.
То есть если мы задаем старый callback, то работаем в обычном режиме. Если же мы задаем вновь добавленный callback, то все пойдет иначе. Flutter Engine, вместо того чтобы отрисовывать в существующий буфер, будет запрашивать новый буфер уже у нас.
Были определенные сложности, как обеспечить Lifecycle, потому что буфер нельзя удалять, пока его не освободил Flutter Engine. А тот его не может освободить, пока буфер использует Wayland Client или Wayland Server. Все эти моменты пришлось продумать. Мы также добавили возможность выбрать тип пикселей для отрисовки, когда создаем и конфигурируем Flutter Engine.
В результате этих преобразований у нас прекратились конвертации — мы пишем напрямую в Shared Memory, благодаря чему скорость еще возросла настолько, что Wayland Server перестал справляться с количеством кадров, которые генерирует Flutter при интенсивной анимации. Нам пришлось даже добавить синхронизацию по протоколу Wayland: мы запрашиваем callback у сервера, который тот посылает, когда готов принять следующий кадр. После этого все стало работать хорошо.
Текущая версия Flutter на платформе — 3.24.2. Кажется, не так давно вышла 24.3, но в целом это достаточно свежая версия.
Нам приходится брать Flutter SDK с сайта, адаптировать его и класть в SDK KasperskyOS for Mobile. Чтобы сделать из upstream-версии Flutter Engine соответствующую версию для KasperskyOS, нужно применить 400+ патчей с изменениями около 14 тысяч строк кода.
Мы попытались немного автоматизировать этот процесс. Есть скрипт, который пытается накладывать эти патчи, но возникающие конфликты необходимо фиксить руками. Если это делать регулярно, не пропуская много времени, обычно все накладывается неплохо.
Как я уже писал, сборка оркестрируется Kaspersky Build, но каждый компонент может иметь свою систему сборки внутри. В рецепте для каждого компонента указано, какими командами его нужно собирать. Большинство компонентов внутри у нас собираются cmake. Но скачать что-то из Сети не получится физически, так что перед началом сборки все сорцы должны быть уже скачаны.
Чтобы пользователям было проще, все команды Flutter, которые нужны для генерации asset-ов, мы спрятали внутрь, поскольку в основном у нас используется именно эта система сборки для всех приложений. В модуле cmake при этом можно указать локализованное название приложения, которое будет отображаться на рабочем столе, а сама иконка также задается через cmake.
После компиляции приложение вместе с метаданными упаковывается в KPA-файл (аналог APK для KasperskyOS), который может быть установлен непосредственно на ARM-железо или на графический эмулятор QEMU. Корпоративная система разработки для KasperskyOS — это VS Code. И у нас есть полноценный плагин для настройки эмулятора, который позволяет делать это достаточно быстро и удобно. То есть можно собрать приложение и сразу же установить его в эмулятор.
Результат
Ниже — эмулятор из состава SDK на базе QEMU, в котором запущено первое полноценное графическое приложение «Заметки» на мобильной версии KasperskyOS.
«Заметки» — первое приложение, которое мы писали сами для проверки. Надо отметить, что во Flutter затраты на написание приложения намного меньше, чем при создании его на Qt Qml, и выглядит оно симпатичнее.
На этом все. Надеюсь, что мой опыт будет вам полезен и теперь вам не придется по крупицам собирать документацию по внутренней архитектуре Flutter и его склейке с платформой.
Если вам интересно узнать больше о подкапотном устройстве KasperskyOS, то оставляю пару полезных ссылок:
А если вы хотите приложить руку к созданию и развитию нашей оси и продуктов под нее, приходите в нашу команду. Пройти все этапы собеседований можно за пару дней; спектр возможных задач, как вы уже успели убедиться, весьма интересен, а список новых фич огромен :) До встречи!