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