Flutter Web и WebAssembly — ключ к тайной комнате

от автора

Web для Flutter-платформы с одной стороны является очень хорошо изученной платформой (поскольку Dart создавался как альтернатива JavaScript и изначально компилировался в JS и предусматривал возможности взаимодействия с JS-объектами и функциями, а также с DOM браузера), но в действительности и сейчас это Terra Incognita из-за большого потенциала интеграции с веб-платформой (как на уровне API HTML5, так и с использованием технологий WebAssembly). В этой статье мы обсудим некоторые аспекты взаимодействия Dart.Flutter-кода с WebAssembly-модулями, поговорим о компиляции Flutter-приложений в WASM и о том, как можно компилировать C-библиотеку для использования во Flutter-приложениях.

Сначала поговорим о самой технологии WebAssembly. Она создавалась для выполнения произвольного кода в среде браузера (и не только, например есть wasmtime — среда выполнения для автономного выполнения wasm-кода или встраивания в приложения на C/C++, Python, Go, Rust, .Net), при этом технология основана на стековой виртуальной машине и модели памяти, независимой от исходного языка программирования. Согласно исследованию расхода энергии, выполнение WebAssembly-кода на мобильном браузере более энергоэффективно, чем аналогичного JavaScript. Для компиляции исходных текстов (есть мнемонический язык самого wasm, а также скриптовый язык AssemblyScript, но в большинстве случаев приложения разрабатываются на других языках и компилируются в байткод с помощью emscripten или с указанием целевой платформы для компилятора языка, если она поддерживается). Для доступа к возможностям среды выполнения (например, браузера) используется интерфейс WASI (Web Assembly System Interface), который предоставляет доступ к абстракциям, таким как объектная модель документа, файлы и информация о системе, объекты WebGL, доступ к информации от сенсоров, камере и т.п. Поддержку функций WebAssembly в браузерах можно посмотреть здесь. Поскольку wasm может выполняться не только на веб-платформе, то он может стать альтернативным способом создания переносимых приложений.

Из важных ограничений WebAssembly можно обозначить отсутствие изначальной поддержки автоматической сборки мусора (которая может быть реализована только непосредственно в байт-коде), но сейчас проводится работа над реализацией wasm-gc в Chrome и Firefox и есть экспериментальные флаги в V8 для включения поддержки сборки мусора.

Перед тем, как обсуждать использование WASM в Flutter приложениях, посмотрим какие возможности есть по взаимодействию Dart и WebAssembly. Для этого создадим простое приложение на C и скомпилируем его в байт-код wasm.

#include "stdio.h"  int sum(int a,int b) {   return a+b; }  int main() {   printf("Hello, Web Assembly, 2+2=%d\n", sum(2,2));   return 0; }

Установим emscripten SDK по инструкции с этой страницы.

git clone https://github.com/emscripten-core/emsdk.git  cd emsdk ./emsdk install latest export PATH=`pwd`:$PATH emcc test.c -o test.html

Для тестирования нужно помнить, что в браузере по умолчанию запрещено обращаться к file URI и нужно либо открыть браузер с отключением CORS (в Chrome —disable-web-security), либо запустить локальный nginx (например, через docker run -d -p 8000:80 -v `pwd`:/usr/share/nginx/html nginx). После перехода по адресу http://localhost:8000/test.html будет запущена страница emscripten по умолчанию, которая показывает вывод консоли Hello, Web Assembly, 2+2=4.

Для консольных приложений Dart можно использовать пакет wasm (аналог dart:ffi) для загрузки wasm-модулей, поиска и вызова экспортированных функций. Пакет использует wasmer runtime и предоставляет возможность вызова системных функций и взаимодействия с файловой системой. Для простого консольного приложения добавим wasm в dependencies (pubspec.yaml), выполним установку и настройку пакета и его зависимостей (на примере Ubuntu):

apt install rustc cargo dart pub get dart run wasm:setup

Теперь мы сможем получить доступ к wasm-модулю:

final brotliPath = Platform.script.resolve('test.wasm'); final moduleData = File(brotliPath.path).readAsBytesSync(); final module = WasmModule(moduleData); final instance = module.instantiate().enableWasi().build();

После получения объекта instance можно через него получать Function-объекты вокруг wasm-функций instance.lookupFunction('sum'),а также работать с динамической памятью instance.memory:

  • grow — увеличить резервирование памяти до заданного значения (должно быть кратно WasmMemory.kPageSizeInBytes)

  • view — получить объект для манипуляции памятью (например, заполнение байтовыми данными через setRange)

Указатель на свободное место в памяти может быть получен через memory.lengthInBytes, относительно него будут рассчитываться указатели для передаваемых и получаемых данных из функции. Для передачи дополнительных данных в вызываемую функцию память необходимо расширить через grow, и затем заполнить с помощью memory.view.setRange. Для вызова внешних функций можно использовать полученное значение из lookupFunction. Например, для нашего примера:

final sum = module.lookupFunction('sum'); println(sum(2,2));

Теперь попробуем сделать Dart for Web и сделаем вызов wasm-функции. Для этого будем использовать JS-связывания Dart. Для начала создадим новый проект Dart for Web:

dart create -t web wasmtest cd wasmtest dart pub global activate webdev webdev serve

Загрузка wasm-модуля выполняется через вызов статического метода instantiate от объекта WebAssembly, создадим обертку для его вызова из dart (используется package:js/js.dart), либо можем использовать JS-метод для загрузки wasm-объекта и в дальнейшем обращаться к нему через context (в dart:js).

@JS('WebAssembly.instantiate') external Object instantiate(Object bytesOrBuffer, Object import);

Также можно использовать пакет wasm_interop, который внутри себя выполняет все необходимые преобразования. Создадим класс-обертку для загрузки wasm из файлов, расположенных на том же сервере (в случае с Flutter также можно будет использовать возможности загрузки wasm-файлов из assets через rootBundle):

import 'dart:html'; import 'package:wasm_interop/wasm_interop.dart';  class WasmLoader {   WasmLoader({required this.path});    late Instance? _wasmInstance;   final String path;    Future<bool> initialized() async {     try {       final data = await HttpRequest.request(path,           method: 'GET', responseType: 'arraybuffer');       _wasmInstance =           await Instance.fromBufferAsync(data.response, importMap: {});       return isLoaded;     } catch (exc) {       print('Error on wasm initialization ${exc}');     }     return false;   }    bool get isLoaded => _wasmInstance != null;    Object? callfunction(String name, {List<Object>? arguments}) {     if (isLoaded) {       final func = _wasmInstance?.functions[name];       final arg = arguments ?? [];       switch (arg.length) {         case 0:           return func?.call();         case 1:           return func?.call(arg[0]);         case 2:           return func?.call(arg[0], arg[1]);         case 3:           return func?.call(arg[0], arg[1], arg[2]);         case 4:           return func?.call(arg[0], arg[1], arg[2], arg[3]);       }     }     return null;   } }  void main() {   Future(() async {     final wasm = WasmLoader(path: 'test.wasm');     await wasm.initialized();     print(wasm.callfunction('sum', arguments: const [2, 4]));   }); } 

Обратите внимание, что при компиляции C в wasm нужно явно перечислить экспортируемые символы (и добавить перед ними символ подчеркивания) и собрать standalone WASM (включается по умолчанию при выводе результата в файл с расширением wasm). Также, если в исходном файле нет функции main, нужно добавить опцию —no-entry. Оставим только функцию sum в исходном тексте на C и выполним компиляцию:

emcc test.c -o test.wasm --no-entry -s EXPORTED_FUNCTIONS=_sum

Теперь при обновлении страницы (http://localhost:8080) мы увидим в консоли значение 6 (результат обращения к wasm-функции sum).

Аналогичный подход может использоваться также для Flutter-приложений, в этом случае инициализация может выполняться в методе didChangeDependencies() async, а результат сохраняться как значение Future или отправляться в Stream (обратите внимание, что вызов внешней функции является синхронным). Например, мы можем сделать следующий виджет счетчика для использования wasm-функции, для загрузки .wasm будет использоваться (await rootBundle.load('assets/test.wasm')).buffer.

class WasmWidget extends StatefulWidget {   const WasmWidget({Key? key}) : super(key: key);    @override   State<WasmWidget> createState() => _WasmWidgetState(); }  class _WasmWidgetState extends State<WasmWidget> {   late final WasmLoader _loader;    StreamController<int> data = StreamController();    int currentValue = 0;    @override   void didChangeDependencies() async {     super.didChangeDependencies();     _loader = WasmLoader(path: "test.wasm");     await _loader.initialized();     data.add(0);   }    void increment() {     currentValue = _loader.callfunction("sum", arguments: [currentValue, 1]) as int;     data.add(currentValue);   }    @override   Widget build(BuildContext context) {     return Scaffold(       body: Center(         child: StreamBuilder(           stream: data.stream,           builder: (_, value) => Column(             children: [               Text("Current Value is ${value.data}"),               ElevatedButton(                 onPressed: increment,                 child: const Text('Increment'),               ),             ],           ),         ),       ),     );   } } 

Альтернативно для Android может использовать плагин flutter_wasm (по информации с pub.dev больше не поддерживается, но можно подсмотреть основные идеи). Он также основан на wasmer runtime и после установки требует настройки через flutter pub run flutter_wasm:setup), пример приложения можно посмотреть здесь. Для инициализации wasm-файлов из набора байтов также можно использовать assets.

Для компиляции библиотек, основанных на autoconf необходимо выполнить последовательность команд:

emconfigure ./configure emmake make emcc project.o -o project.wasm

При использовании cmake необходимо выполнить подготовку сборочных файлов (mkdir build && cmake -S . -B build) и затем в каталоге build выполнить emmake make и emcc project.c -o project.wasm

Важно отметить, что emscripten не только компилирует алгоритмическую часть кода, но и подменяет некоторые API через механизм портов, например поддерживается библиотека SDL с подменой запросов (для браузера) в соответствующие события и вызовы методов для canvas. При этом для рисования используется объект canvas, который был сохранен в WASM-модуль (в свойство canvas), хороший пример можно посмотреть здесь (также там выполняется интеграции main_loop для emscripten-кода с обновлением кадра). Сам объект Canvas во Flutter Web-приложение может быть добавлен через интеграцию платформенного view:

import 'dart:ui' as ui; import 'dart:html' as html;  //... ui.platformViewRegistry.registerFactory('canvas',    (id) => html.CanvasElement()..width=512..height=512);

Также можно подключить wasm-код, который использует библиотеку SDL (в этом случае в модуль будет необходимо записать свойство canvas и связать его с объектом для отрисовки графических изображений), при этом при компиляции emcc нужно дополнительно указать опции:

  • -s USE_SDL=2 — транслировать вызовы SDL на HTML Canvas

  • -s USE_SDL_IMAGE=2 — добавить поддержку вывода изображений

  • -s SDL2_IMAGE_FORMATS='["png"]' — список поддерживаемых типов изображений

  • -s USE_SDL_TTF=2 — поддержка шрифтов TrueType (и отображения текста)

Полный список поддерживаемых портов можно найти здесь.

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

А что насчет компиляции Dart в WebAssembly вместо JS? Пока все не очень хорошо, поддержка еще очень далеко от использование в production, но тем не менее, существует проект dart2wasm, который создает вариант компилятора для этой целевой архитектуры. Для компиляции Dart-исходного текста (для консольных приложений на Dart) можно использовать следующий сценарий:

git clone https://github.com/dart-lang/sdk cd sdk dart --enable-asserts pkg/dart2wasm/bin/dart2wasm.dart ../test.dart ../test.wasm

Для запуска скомпилированного файла должна быть включена экспериментальная поддержка GC, Stack Switching и возможности рефлексии в d8:

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git export PATH=`pwd`/depot_tools:$PATH fetch v8 cd v8 make native cd .. v8/out/native/d8 --experimental-wasm-gc --wasm-gc-js-interop \  --experimental-wasm-stack-switching \  --experimental-wasm-type-reflection \  pkg/dart2wasm/bin/run_wasm.js -- ../test.wasm

Во второй части статьи мы подключим к Flutter-приложению эмулятор GameBoy на SDL и обсудим, как обеспечить обработку жестов в canvas-виджете (с SDL) совместно с другими Flutter-виджетами, реализовать работу со звуком и видео во встраиваемом wasm-приложении, а также поговорим про использование других портов для emscripten.

И в заключение приглашаю всех желающих на бесплатный урок по теме: «Сферический Flutter в вакууме. Создаем свою систему координат для RenderObject».


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/693572/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *