Сердце фреймворка Flutter (который для разработчиков часто представляется только в виде набора классов на языке Dart) написано на языке С и компилируется в двоичный артефакт, известный как Flutter Engine, подключаемый к приложению и используемый из Dart-классов через механизм нативного связывания (аннотации @pragma('vm:entry-point') для вызовов из Flutter Engine в Dart, @Native и external для обращения к C++ коду во Flutter Engine из Dart).

Но в действительности Flutter Engine не имеет никакого платформо-специфического кода (при этом собран под целевую аппаратную архитектуру) и не знает, как работает платформенный event loop, как создавать потоки, на какой поверхности выполнять отрисовку сцены и не может получать информацию о действиях пользователя (касание экрана, перемещение указателя мыши, нажатие клавиш) и системных событиях.
Такое архитектурное решение было сделано для того, чтобы иметь возможность запускать Flutter-приложения потенциально на любом устройстве с экраном (даже светодиодной панелью). В этой статье мы поговорим про Flutter Embedder, его роль в запуске приложения и привязке к системным event loop, а также рассмотрим сборку простого embedder для публикации Flutter приложения как VNC-сервера.

До перехода к Flutter рассмотрим простое приложение на C++, которое будет запускаться на Linux и использовать возможности библиотеки GTK+ для управления визуальными элементами в окне приложения. Прежде всего нам понадобится определить функцию main (точку входа в приложение), которая должна будет вызвать последовательность методов из библиотеки libgtk для настройки GTK-контекста:

  • создать окно приложения (задать заголовок, размер);

  • получить поверхность для рисования на окне (DrawingArea);

  • зарегистрировать callback-функции для обработки событий мыши и нажатий клавиш;

  • присоединить обработку события нового кадра для подготовки следующего изображения (например, при анимации);

  • при необходимости - создать дополнительные потоки для выполнения вычислительных задач (не блокирующих анимацию) и операций ввода-вывода (обработка файлов и сетевое взаимодействие).

После этого общая логика работы приложения основывается на изменении состояния при получении внешних событий и подготовке последовательности команд для создания интерфейса на новом кадре (если необходимо и были сделаны изменения).
Поскольку ответственность за создание UI находится на стороне Dart-кода (Flutter-фрэймворка) и обработка пользовательских событий тоже привязана к Dart (Listener и арена жестов), то аналогичную задачу во Flutter-приложении можно решить следующим образом:

  • создать Dart Runtime и передать в него выполняемый код и предкомпилированные константы (snapshot) нашего приложения и Dart-части Flutter Framework;

  • получить необходимые системные привязки (event loop, frame scheduler), подписаться на события указателя (мыши или касание пальцем) и клавиатуры и при их возникновении передавать информацию в Dart Runtime;

  • на каждый новый кадр вызывать функцию из Dart, которая будет инициировать проход конвейера Build -> Render.

В действительности между Dart Runtime и базовым кодом запуска (функции которого выполняет embedder) располагается Flutter Engine, который внутри себя включает Dart VM и набор классов, отвечающих за обработку событий и отрисовку сцены на различных целевых платформах (например, в растровом буфере Framebuffer или с использованием OpenGL / Angle). Flutter Engine предоставляет для эмбеддера набор функций и способы регистрации callback-функций, которые описаны в ABI flutter_embedder.h.
Файл может быть получен как в результате сборки собственного Flutter Engine, так и из предсобранных под разные архитектуры движков по этой ссылке. Обратите внимание, что для использования Flutter Engine с arm64-архитектурой (например, при запуске на MacOS Desktop-приложений с процессорами M1 и более новыми) собранный движок может быть получен из этого репозитория.

Очень важно, чтобы версия Flutter Engine, который будет использоваться для запуска, в точности совпадала с версией Flutter SDK, которая была использована для компиляции исходных текстов в snapshot. Актуальная версия Flutter Engine может быть извлечена из файла bin/internal/engine.version.
Возьмем для примера Flutter 3.19.4, хэш-код для версии - a5c24f538d05aaf66f7972fb23959d8cafb9f95a. Найдем подходящий движок для нашей операционной системы и аппаратной архитектуры, загрузим архив и распакуем из него несколько файлов:

  • двоичный артефакт библиотеки Flutter (в случае с Windows - DLL, Linux/Android - so, MacOS/iOS - Framework).

  • embedder.h для использования из нашего кода запуска приложения

  • icudtl.dat данные для запуска Flutter Engine

Как собрать Flutter Engine?

Для компиляции движка Flutter используется сборочная система Ninja, которая настраивается через утилиту gn. Весь пакет сборочных инструментов может быть получен из depot_tools:

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=`pwd`/depot_tools:$PATH

Дальше исходные тексты Flutter Engine и всех third-party пакетов можно извлечь командой:

fetch flutter
cd src

При таком использовании извлекается последняя версия flutter из ветки main в github (можно изменить в сгенерированном .gclient версию в ссылке на репозиторий, например добавить @3.16.1 и выполнить после этого gclient sync). Далее нужно подготовить сборочные инструменты и задать флаги конфигурации в gn. Здесь может быть определена целевая платформа и аппаратная архитектура, включены экспериментальные функции (например, --enable-impeller-3d), выполнена замена Dart SDK на кастомный, а также добавлена генерация примеров для различных embedder’ов (включая простой GLFW с поддержкой OpenGL). Например, для сборки embedder для MacOS arm64 с поддержкой impeller-3d можно использовать следующую комбинацию флагов:

flutter/tools/gn --target-os mac --mac-cpu arm64 --enable-impeller-3d --unoptimized

В результате будет создан фреймворк (FlutterMacOS.framework) для связывания с embedder, а также большое количество утилит (включая бенчмарки и юнит-тесты) для компиляции кода и шейдеров. Файл flutter_embedder.h может быть извлечен с github.

Создаем свой embedder

Рассмотрим основные группы функций, определенные в flutter_embedder.h:

Запуск и остановка Flutter Engine

  • при запуске FlutterEngineRun передаются аргументы командной строки и структура данных с конфигурацией поверхности для рисования и большим количество callback-методов для получения уведомлений от Flutter Engine (например, для перехвата сообщений, выдаваемых в консоль). В результате выполнения заполняется структура FlutterEngine, которая будет использоваться во всех последующих вызовах. Под капотом вызывает методы FlutterEngineInitialize (подготовка движка к работе) и FlutterEngineRunInitialized (запуск основного Dart-приложения с функции main). Использование разделенных методов полезно, когда есть необходимость задать кастомные TaskRunner до того, как начнется выполнение какого-либо кода. Дополнительно можно передать user_data, произвольная структура, которая будет передаваться в виде указателя во все callback-методы.

  • подготовка к остановке `FlutterEngineDeinitialize’ останавливает выполнение всех запланированных задач и подготавливает движок к остановке

  • остановка FlutterEngineShutdown принимает структуру FlutterEngine, освобождает ресурсы, ранее выделенные под движок Flutter. Это действие обычно выполняется при завершении приложения (или переходе с экрана, где используется Flutter-приложение).

Для запуска embedder обязательно заполнить структуру данных с конфигурацией FlutterRendererConfig, которая включает следующие поля:

  • type тип драйвера для отрисовки сцены (самый простой без поддержки ускорения - FlutterRendererType.kSoftware, также может быть kOpenGL, kMetal, kVulkan)

  • одна из структур конфигурации драйвера: FlutterSoftwareRendererConfig, FlutterOpenGLRendererConfig, FlutterMetalRendererConfig, FlutterMetalRendererConfig). В своем примере мы будет использовать Software Rendering, но на платформах с поддержкой ускорения более рационально использовать возможности соответствующих библиотек). Для FlutterSoftwareRendererConfig определяется только один callback-метод surface_present_callback, который принимает пиксельный буфер (framebuffer) и отвечает за перенос цветов пикселей на устройство отображения.

Кроме конфигурации renderer должен быть передан объект FlutterProjectArgs, определяющий количество и содержание аргументов командной строки, путь к assets и ICU (файлу icudtl.dat, распространяется вместе с архивом Flutter Engine). Каталог assets определяется относительно корня проекта в каталоге build/flutter_assets (будет создан при компиляции через flutter build bundle).

Рассмотрим простой пример embedder, который запустит Flutter Engine на MacOS:

#include <sstream>
#include <vector>

int main(int argc, char** argv) { 
  FlutterRendererConfig config = {};
  config.type = kSoftware;
  config.software.struct_size = sizeof(FlutterSoftwareRendererConfig);
  config.software.surface_present_callback = present_callback;

  std::vector<const char*> engine_command_line_args = {
      "--disable-observatory",
      "--dart-non-checked-mode",
  };
  FlutterProjectArgs args = {
      .struct_size = sizeof(FlutterProjectArgs),
      .assets_path = argv[1] ,
      .icu_data_path = argv[2],
      .command_line_argc = static_cast<int>(engine_command_line_args.size()),
      .command_line_argv = engine_command_line_args.data(),
  };
  FlutterEngine engine = nullptr;
  auto result = FlutterEngineRun(FLUTTER_ENGINE_VERSION, &config, &args, this, &engine_);
  if (result != kSuccess) {
    std::cout << "Error in starting flutter engine" << std::endl
    return 1;
  }
  //----------------------------------
  //здесь мы будем рисовать
  //----------------------------------
  auto result = FlutterEngineShutdown(engine_);

  if (result != kSuccess) {
    std::cout << "Error in shutting down." << std::endl;
  }
}

Для запуска скомпилируем наш проект и передадим два аргумента в командную строку:

cd /project
flutter build bundle
cd /embedder
myembedder /project/build/flutter_assets `pwd`/icudtl.dat

Таким образом мы запустили Flutter Engine (внутри которого будет запущен Dart VM и загружен снимок из каталога assets) и настроили его для программного отображения (без использования ускорителя) через фреймбуфер, который будет передаваться в функцию present_callback, этого достаточно для того, чтобы приложение начало передавать изображение, полученное при отрисовке сцены. Теперь посмотрим как передавать пользовательские и системные события (в случае использования графического интерфейса они могут быть перехвачены через GTK Event Loop, в других случаях источником события может быть прерывание или иной механизм опроса устройств ввода).

Сообщения о событиях

  • FlutterEngineSendWindowMetricsEvent - сообщить об изменениях метрики области отображения (например, изменении размера окна) через структуру FlutterWindowMetricsEvent;

  • FlutterEngineSendPointerEvent - отправить событие указателя (например, касание пальцем экрана или перемещение мыши, также может быть любое событие сенсорной панели, где можно определить двумерные координаты), используется структура FlutterPointerEvent (координаты, время события, тип устройства, тип кнопки, положение прокрутки, смещения, масштабирования и поворота, также может быть передан идентификатор View, который получил событие, это используется с платформенными View);

  • FlutterEngineSendKeyEvent - передать событие нажатия/отпускание кнопки, работает асинхронно (по завершению обработки вызывает callback и передает user_data, переданный при инициализации движка);

  • FlutterEngineUpdateSemanticsEnabled - включить или выключить поддержку возможностей подсистемы доступности;

  • FlutterEngineUpdateAccessibilityFeatures - определить используемые возможности подсистемы доступности (битовая маска);

  • FlutterEngineDispatchSemanticsAction - отправить семантическое действие (FlutterSemanticAction action) для указанного семантического узла (node_id). В реальных embedder используется для передачи действий от accessibility-приложений (например, голосового управления или перемещение свайпом в TalkBack), но также можно использовать для передачи сообщений о взаимодействии с узлами семантического дерева от специальных управляющих устройств или датчиков;

  • FlutterEngineReloadSystemFonts - сообщение в Flutter Engine о необходимости перечитать системные шрифты;

  • FlutterEngineUpdateLocales - обновить данные системных локалей (передается список доступных локалей в порядке предпочтений);

  • FlutterEngineNotifyLowMemoryWarning - сообщить во Flutter Engine о недостатке виртуальной памяти;

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

Синхронизация кадров

  • FlutterEngineOnVsync - сообщить в Flutter Engine о необходимости нарисовать следующий кадр (запустить конвейер Build Owner), передается точное время начала следующего кадра (для получения времени от Flutter Engine можно использовать метод FlutterEngineGetCurrentTime);

  • FlutterEngineScheduleFrame - запланировать форсированную отрисовку кадра (например, можно использовать если изменилась текстура или другие факторы, влияющие на отображение сцены);

  • FlutterEngineSetNextFrameCallback - зарегистрировать callback, который будет вызван из Flutter Engine после завершения обработки кадра (например, в этом месте может подсчитываться статистика).

Получение информации от Flutter Engine

  • FlutterEngineGetCurrentTime - вернуть текущее системное время (с точки зрения Flutter Engine)

  • FlutterEngineRunsAOTCompiledDartCode - проверить режим работа кода, возвращает false в JIT-режиме (debug), true - в AOT-режиме (profile/release)

Этих функций нам будет достаточно для того, чтобы сообщить об изменениях размера окна, передать коды нажимаемых клавиш и взаимодействия с поверхностью для рисования. Все, что нам необходимо сделать - при инициализации GTK присоединить обработку событий и перенести информацию из них в структуры, совместимые с Flutter Engine (описаны в embedder.h), например для обработки событий касания экрана можно использовать следующий обработчик:

bool SendFlutterPointerEvent(FlutterPointerPhase phase,
                                                 double x,
                                                 double y) {
  FlutterPointerEvent event = {};
  event.struct_size = sizeof(event);
  event.phase = phase;
  event.x = x;
  event.y = y;
  event.timestamp =
      std::chrono::duration_cast<std::chrono::microseconds>(
          std::chrono::high_resolution_clock::now().time_since_epoch())
          .count();
  return FlutterEngineSendPointerEvent(engine_, &event, 1) == kSuccess;
}

Здесь phase можно определять сравнение последнего идентификатора кнопки с предыдущим (при нажатии на экран эмулируется событие левой кнопки мыши и фаза kDown, при перемещении с зажатой кнопкой или без нее - kMove, при отпускании кнопки - kUp).

Аналогично можно подписаться на события изменение размера и отправлять их во Flutter Engine с заполнением соответствующей структуры:

bool SetWindowSize(size_t width, size_t height) {
  FlutterWindowMetricsEvent event = {};
  event.struct_size = sizeof(event);
  event.width = width;
  event.height = height;
  event.pixel_ratio = 1.0;
  return FlutterEngineSendWindowMetricsEvent(engine_, &event) == kSuccess;
}

В примере вызовы методов привязываются к callback-методам для rfb (создается через библиотеку vncserver), в случае использования GTK можно отслеживать появление GdkEventMotion и других событий и превращать их в соответствующие вызовы движка Flutter. Полный исходный текст проекта можно посмотреть в репозитории.

Для использования OpenGL и других 3D-библиотек конфигурация более сложная, использует набор инструкций и правил композиции. Пример подключения OpenGL можно найти в реализации GLFW здесь.

Какие embedder сейчас уже созданы?

Кроме стандартных реализаций Embedder + Shell (используется для взаимодействия с платформенной технологией - Java/Kotlin и Swift) есть несколько кастомных реализаций Flutter Embedder:

  • eLinux - набор инструментов + embedder для запуска Flutter на встраиваемых устройствах (может работать над Wayland, X11, GBM и EGLStream), используется также для запуска Flutter-приложений в проекте AGL.

  • Flutter PI - embedder для запуска flutter-приложений на микрокомпьютерах Raspberry PI

  • https://github.com/flutter-tizen/embedder - для запуска на платформе Tizen (Samsung)

В этой части статьи мы рассмотрели основные функции Flutter Embedder на примере реализации VNC-сервера как основы для запуска Flutter-приложений. Во второй части статьи мы рассмотрим использование текстур (могут отображаться через виджет Texture), отправку и обработку сообщений через платформенные каналы, управление Task Runner и запуск задач со стороны embedder (а также механизмы связывания Task Runner и платформенных потоков), способы взаимодействия с изолятами, сбор трассировки и статистики из Dart VM.

Напоследок, хочу пригласить вас на бесплатный вебинар, на котором я расскажу, как создать многопользовательскую игру по типу "Имаджинариум" с искусственным интеллектом на Flutter. Регистрируйтесь, будет интересно.

Комментарии (1)


  1. timiryazevec
    17.04.2024 11:00

    Материал интересный. Спасибо. Буду ждать второй части про текстуры и запуск задач.