Недавно мы успешно портировали фреймворк Flutter на ТВ-приставку c открытой программной платформой RDK. В этой статье расскажем о трудностях, с которыми пришлось столкнуться, и предложим решения для успешного запуска и повышения производительности. 

Учитывая, что программный стек RDK или Reference Design Kit сейчас активно используется для разработки OTT-приложений, голосового управления приставками и других продвинутых функций для «видео по запросу» (VoD), мы хотели разобраться, сможет ли Flutter работать на ТВ-приставке. Оказалось, что да, но, как это обычно бывает, есть нюансы.

Далее мы по шагам распишем процесс портирования и запуска Flutter на встраиваемых Linux-платформах и разберемся, как этот SDK с открытым исходным кодом от Google чувствует себя на «железе» с ограниченными ресурсами и ARM-процессорами.

Но прежде чем переходить непосредственно к Flutter и его преимуществам скажем пару слов об исходном решении, которое было задействовано на ТВ-приставке. На плате работала связка «набор библиотек EFL + протокол Wayland», а рисование примитивов было реализовано из node.js на основе плагинного нативного модуля. Это решение неплохо себя показало с точки зрения производительности при отображении кадров, однако сам EFL — отнюдь не самый новый фреймворк для отрисовки. А в режиме выполнения node.js со своим огромным event-loopом казался уже не самой перспективной идеей. В то же время Flutter мог позволить нам задействовать более производительную связку рендеринга. 

Для тех, кто не в теме: первую версию этого SDK с открытым кодом Google представил еще шесть лет назад. Тогда этот набор средств разработки годился только для Android. Сейчас на нем можно писать приложения для веба, iOS, Linux и даже Google Fuchsia. :-) Рабочий язык для разработки приложений на Flutter — Dart, в свое время он был предложен в качестве альтернативы JavaScript. 

Перед нами стоял вопрос: даст ли переход на Flutter какой-то выигрыш по производительности? Ведь подход там совершенно иной, хоть в конечном счете и имеется та же графическая подсистема Wayland + OpenGL. Ну и как там с поддержкой процессоров с neon-инструкциями? Были и другие вопросы, например, нюансы по переносу UI на dart или то, что поддержка Linux находится в стадии альфы-беты.

Сборка Flutter Engine для ТВ-приставок на базе ARM 

Итак, начнем. Вначале Futter нужно запустить на чужеродной платформе с Wayland + OpenGL ES. В основе рендеринга у Flutter лежит библиотека Skia, которая прекрасно поддерживает OpenGL ES, поэтому в теории все выглядело хорошо. 

При сборке Flutter под наши целевые устройства (три ТВ-приставки с RDK), к нашему удивлению, проблемы возникли только на одной. Не будем с ней сражаться, т.к. из-за старой архитектуре intel x86 она для нас не является приоритетной. Лучше сосредоточимся на оставшихся двух ARM-платформах. 

Вот, с какими опциями мы собирали Flutter Engine:

./flutter/tools/gn       --embedder-for-target       --target-os linux       --linux-cpu arm \ 
      --target-sysroot DEVICE_SYSROOT
      --disable-desktop-embeddings \ 
      --arm-float-abi hard 
      --target-toolchain /usr 
      --target-triple arm-linux-gnueabihf 
      --runtime-mode debug
ninja -C out/linux_debug_unopt_arm

Большинство опций понятны: собираем под 32-битный ARM-процессор и Linux, выключая при этом все лишнее через --embedder-for-target --disable-desktop-embeddings

Для сборки в системе должен быть установлен clang версии 9 и выше, т.е. это стандартный сборочный механизм Flutter, инструментарий кросс-компиляции gcc не пойдет. Самое важное — подать корректный target-sysroot устройства с RDK.

Честно говоря, мы удивились, что при сборке не возникло вообще никаких нюансов. На выходе получаем заветную библиотеку flutter_engine.so и заголовок с необходимыми функциями для эмбеддера. 

Теперь можно собрать целевой проект flutter/dart с нашей библиотекой/движком. Это сделать легко:

flutter --local-engine-src-path PATH_TO_BUILDED_ENGINE_src 
--local-engine=host_debug_unopt build bundle

Важно! Сборка проекта должна происходить не на устройстве с собранной библиотекой, а на хостовой, т.е. x86_64!

Для этого достаточно еще раз пройти путь сборкой gn и ninja только под x86_64! Именно она указывается в параметре host_debug_unopt.

PATH_TO_BUILDED_ENGINE_src — это путь, где находится engine/src/out.

За запуск Flutter Engine под системой обычно отвечает embedder, именно он конфигурирует Flutter под целевую систему и дает основные контексты рендеринга библиотеке Skia и Dart-обработчику. Не так давно в состав Flutter добавили linux-embedder, и, в частности, GTK-embedder, так что можно воспользоваться им из коробки. На нашей платформе на момент портирования это был не вариант, нужно было что-то независимое от GTK. 

Рассмотрим некоторые особенности реализации, которые пришлось учесть с кастомным эмбеддером (все, кто  любит разбирать не нюансы, а исходники целиком, может сразу перейти к проекту нашего форка с доработками на github.com). К тому же по производительности наш вариант немного выигрывал у версии GTK, что было крайне важно для заказчика, и не тянул за собой весь зоопарк GTK-библиотек.

Так что же вообще нужно от эмбеддера для запуска flutter-приложения? Достаточно, чтобы он просто вызывал из библотеки flutter_engine.so

FlutterEngineRun(FLUTTER_ENGINE_VERSION, &config, &args, display /* userdata */, &engine_);

где в качестве параметров идет передача настроек проекта (директория с собранным flutter bundle) FlutterProjectArgs args и аргументов рендеринга FlutterRendererConfig config. 

В первой структуре как раз задается путь bundle-пакета, собранного flutter-утилитой, а во второй используются контексты OpenGL .

// пример использования на github.com (flutter_application.cc)

Все довольно примитивно, но этого достаточно для запуска приложения. 

Проблемы и их решение 

Теперь поговорим о нюансах, с которыми мы столкнулись на этапе портирования. А как же без них? Не только ведь библиотеки собирать :-) 

1. Краш эмбеддера и замена очередности вызова функций

Первая проблема, с которой мы столкнулись — краш эмбеддера под платформой. Казалось бы, инициализация egl-контекста в других приложения происходит нормально, FlutterRendererConfig инициализирован корректно, но нет — эмбеддер не заводится. Значит в связке что-то явно не так. Оказалось, eglBindAPI нельзя вызывать перед eglGetDisplay, на котором происходит особая инициализация nexus-драйвера дисплея (у нас платформа базируется на чипе BCM). В обычном Linux это не проблема, но на целевой платформе оказалась иначе. 

Корректная инициализация эмбеддера выглядит так:

egl_display_ = eglGetDisplay(display_);

if (egl_display_ == EGL_NO_DISPLAY) {
  LogLastEGLError();
  FL_ERROR("Could not access EGL display.");
  return false;
}

if (eglInitialize(egl_display_, nullptr, nullptr) != EGL_TRUE) {
  LogLastEGLError();
  FL_ERROR("Could not initialize EGL display.");
  return false;
}

if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {
  LogLastEGLError();
  FL_ERROR("Could not bind the ES API.");
  return false;
}

// github.com (wayland_display.cc) — корректная реализация, т.е. помогла измененная очередность вызова функций.

Теперь, когда нюанс запуска улажен, мы рады увидеть заветное демо-окно приложения на экране :-).

2. Оптимизация производительности

Настало время проверить производительность. И, честно говоря, она нас не сильно порадовала в режиме отладки (debug mode). Что-то работало шустро, что-то — наоборот, имело большие просадки по фреймам и тормозило гораздо больше, чем что-то похожее на EFL+Node.js.

Мы немного расстроились и начали копать дальше. В SDK Flutter есть специальный режим компиляции машинного кода AOT, это даже не jit, а именно компиляция в нативный код со всеми сопутствующими оптимизациями, именно это подразумевается под по релиз-версией  Flutter. Такой поддержки у нас в эмбеддере пока не было, добавляем.

Необходимы определенные инструкции, поданные аргументами к FlutterEngineRun

// полная реализация — github.com (elf.cc)

vm_snapshot_instructions_ = dlsym(fd, "_kDartVmSnapshotInstructions");

if (vm_snapshot_instructions_ == NULL) {
  error_ = strerror(errno);
  break;
}

vm_isolate_snapshot_instructions_ = dlsym(fd, "_kDartIsolateSnapshotInstructions");

if (vm_isolate_snapshot_instructions_ == NULL) {
  error_ = strerror(errno);
  break;
}

vm_snapshot_data_ = dlsym(fd, "_kDartVmSnapshotData");

if (vm_snapshot_data_ == NULL) {
  error_ = strerror(errno);
  break;
}

vm_isolate_snapshot_data_ = dlsym(fd, "_kDartIsolateSnapshotData");

if (vm_isolate_snapshot_data_ == NULL) {
  error_ = strerror(errno);
  break;
}
if (vm_snapshot_data_ == NULL || 
vm_snapshot_instructions_ == NULL || 
vm_isolate_snapshot_data_ == NULL || 
vm_isolate_snapshot_instructions_ == NULL) {
  return false;
}

*vm_snapshot_data = reinterpret_cast <
  const uint8_t * > (vm_snapshot_data_);

*vm_snapshot_instructions = reinterpret_cast <
  const uint8_t * > (vm_snapshot_instructions_);

*vm_isolate_snapshot_data = reinterpret_cast <
  const uint8_t * > (vm_isolate_snapshot_data_);

*vm_isolate_snapshot_instructions = reinterpret_cast <
  const uint8_t * > (vm_isolate_snapshot_instructions_);
FlutterProjectArgs args;
// передаем все необходимое в args
args.vm_snapshot_data = vm_snapshot_data;
args.vm_snapshot_instructions = vm_snapshot_instructions;
args.isolate_snapshot_data = vm_isolate_snapshot_data;
args.isolate_snapshot_instructions = vm_isolate_snapshot_instructions;

Теперь, когда все есть, нужно собрать приложение особым образом, чтобы получить AOT-скомпилированный модуль под целевую платформу. Это можно сделать, выполнив команду из корня dart-проекта:

$HOST_ENGINE/dart-sdk/bin/dart --disable-dart-dev $HOST_ENGINE/gen/frontend_server.dart.snapshot --sdk-root $DEVICE_ENGINE}/flutter_patched_sdk/ --target=flutter -Ddart.developer.causal_async_stacks=false -Ddart.vm.profile=release -Ddart.vm.product=release --bytecode-options=source-positions --aot --tfa --packages .packages --output-dill build/tmp/app.dill --depfile build/kernel_snapshot.d package:lib/main.dart

$DEVICE_ENGINE/gen_snapshot                                   --deterministic                                                 --snapshot_kind=app-aot-elf                                     --elf=build/lib/libapp.so                                       --no-causal-async-stacks                                        --lazy-async-stacks                                             build/tmp/app.dill

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

-Ddart.vm.profile=release \
-Ddart.vm.product=release \

указывают на то, что нам не нужен профайлер в комплекте и у нас продуктовая версия.

output-dill нужен для построения нативной библитеки libapp.so. 

Самыми важными для нас являются пути $DEVICE_ENGINE и $HOST_ENGINE — два собранных движка под целевую (ARM) и хост-системы (x86_64) соответственно. Тут важно ничего не перепутать и убедиться, что libapp.so получается именно 32-битной ARM-версией:

$ file libapp.so 
libapp.so: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), 
dynamically linked

Запускаем и-и-и-и... вуаля! — все работает! 

И работает шустрее значительно! Теперь уже можно говорить о сравнимой производительности и эффективности рендеринга с исходным приложением на базе набора библиотек EFL. Рендеринг работает почти без запинки и почти идеально на простых приложениях. 

3. Подключение устройств ввода

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

4. Интерфейс на ТВ-приставке под Linux и Android и как увеличить производительность в 2—3 раза 

Коснемся еще нескольких нюансов производительности, с которыми столкнулись в продуктовом UI-приложении. Нас очень обрадовала идентичность работы UI как на целевом устройстве, так и на Linux и Android. Уже сейчас Flutter может вполне может похвастаться очень гибкой портируемостью.

Еще отметим интересный опыт оптимизации самого dart-приложения под целевую платформу. Нас разочаровала довольно низкая производительность продуктового приложения (в отличии от демок). Мы взяли в руки профайлер и начали копать идовольно быстро обнаружили активное использование функций __brcm_cpu_dcache_flush и khrn_copy_8888_to_tf32 во время анимаций (на платформе используется чип процессора Broadcom/BCM ). Явно происходило какое-то очень жесткое пиксельное программное трансформирование или копирование во время анимаций. В итоге виновник был найден: в одной из панелей был задействован эффект размытия: 

//...
filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
//...

Комментирование одного этого эффекта дало увеличение производительности приложения в два—три раза и вывело приложение на стабильные 50-60 fps. Это хороший пример того, как один специфический эффект может уничтожить производительность во всем приложении на Flutter при том, что он был зайствован всего-лишь в одной панели, которая большую часть времени была скрыта.

Итого

В результате мы получили не просто работающее продуктовое приложение, а работающее приложение с качественным фреймрейтом на Flutter на целевом устройстве. Форк и наша версия эмбеддера под RDK и другие платформы на основе Wayland находится тут:

github.com/DEgITx/flutter_wayland

Надеемся, опыт нашей команды в разработке и портировании ПО для ТВ-приставок и Smart TV пригодится вам в своих проектах и послужит отправной точкой для портирования Flutter на других устройствах.

[!?] Вопросы и комментарии приветствуются. На них будет отвечать автор статьи Алексей Касьянчук, наш инженер-программист