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".