Как мы WASM в PWA на Flutter прикручивали

2023-07-20

— Парни, у нас PWA тормозит! — в голосе Димы чувствовались нотки интриги.

Вообще-то мы разрабатываем на Flutter кроссплатформенное приложение для мобильных устройств, но коль уж фреймворк позволяет, на сдачу запустили и веб-версию. Поначалу с PWA мы отхватили немало проблем, но со временем большую часть из них победили. Только вот производительность (из песни слов не выкинешь) так и осталась ахиллесовой пятой приложения — даже на достаточно мощных устройствах нет-нет, да проскакивали микрофризы.

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

Вдоволь насладившись нашими удивлёнными взорами, Дима продолжил:

— Нет, вы не поняли. У нас приложение очень тормозит — слово "очень" он произнёс с акцентом на "о", ещё и протянув его пару секунд, чтобы подчеркнуть, насколько всё плохо. — Пользователи жалуются, что после авторизации может зависнуть на несколько секунд, иногда до десяти.

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

Дима подождал, когда схлынут самые жаркие споры и уточнил:

— Что думаете делать?

— А что тут думать, профилировать надо — высказал витавшую в воздухе мысль Саша.

Профилируем

У Flutter есть довольно неплохие инструменты разработчика, которые включают в том числе и профилировщик. Однако Саша знал, что в мобильных приложениях проблема с лагом была выражена не так сильно, как в PWA, поэтому и профилировать решил с помощью инструментов разработчика Chrome.

Дело это нехитрое: запускаем веб версию приложения в режиме профилирования, открываем инструменты разработчика в браузере, переходим на вкладку "Perfomance" и в нужном месте включаем запись. После прохождения интересующего сценария в приложении останется остановить запись и подождать, когда Chrome подготовит данные.

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

Выглядела эта функция примерно так:

void _onPressed() {
  setState(() => _inProgress = true);

  // Вызов функции, которая долго вычисляется
  _result = fib(_value);

  setState(() => _inProgress = false);
}

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

Alt text

Хорошо, что во Flutter есть волшебная функция compute! С её помощью можно вынести долгие вычисления в отдельный изолят, а когда вычисления будут завершены — положить их в нужное место. Понятно дело, что магия в этом мире работает плохо и получить искомое значение быстрее не получится, но хотя бы можно не блокировать основной поток. В этом случае пользователь вместо унылого застывшего экрана будет видеть экран с весёлым и задорным лоадером.

Саша уже умел пользоваться функцией compute, поэтому приступил к переписыванию кода.

Используем compute

Функция compute принимает два аргумента: вычисляемую функцию и её аргумент, а возвращает объект Future. С помощью метода then этого объекта мы можем присвоить нужное значение нашим переменным, когда вычисления будут закончены.

После переработки наш метод принял следующий вид:

void _onPressed() {
  setState(() => _inProgress = true);

  // Вызов функции, которая долго вычисляется
  compute((n) => fib(n), _value).then((value) {
    _result = value;
    setState(() => _inProgress = false);
  });
}

Получилось неплохо! — подумал довольный собой Саша, после того как запустил приложение на десктопе (обычно при разработке он запускал приложение на своём горячо любимом линуксе — так было быстрее). А чего бы ему не быть довольным? — кнопки нажимаются, лоадеры задорно крутятся и в нужный момент меняются на задуманные виджеты. Никаких тебе фризов.

Мысль о фризах напомнила Саше о лежащем в холодильнике мороженом. "За хорошую работу не грех себя порадовать вкусняшкой — особенно в такую жару", подумал Саша и сходил за холодной сладостью.

Однако после запуска веб-версии приложения мороженое пришлось отставить в сторону: там лаги и фризы будто бы не знали, что им следовало исчезнуть. Профилировщик говорил то же, что и раньше: вы долго считаете какую-то дрянь.

Alt text

Если что-то ведёт себя не так, как ты ожидаешь, то скорее всего ты чего-то не знаешь об этом самом чём-то. И действительно, если обратиться к статье об изолятах на сайте языка Dart, то можно обнаружить такое сообщение:

All Dart apps can use async-await, Future, and Stream for non-blocking, interleaved computations. The Dart web platform, however, does not support isolates.

Перефразируя классиков, можно сказать, что товарищ compute в вебе нам совсем не товарищ.

Как оказалось, мы были не единственными, кто столкнулся с проблемой производительности при работе с данной библиотекой: в репозитории обнаружился issue двухгодичной давности, в котором обсуждалась эта проблема. Авторы библиотеки честно сказали, что для веба ничего сделать нельзя, разве что переписать её на WASM.

Мысль о реализации нужной функциональности для веба на технологии WebAssembly показалась интересной, ведь это стильно-модно-молодёжно, да и команда Flutter/Dart недавно рассказывала, что работает над этой технологией.

Прикручиваем WASM

Беглый поиск, однако, показал, что для Dart эта технология пока не готова к продакшену: поддерживают её только последние версии Chrome и Firefox, а Safari не поддерживает вовсе (и похоже не собирается).

С другой стороны, некоторые языки программирования уже умеют работать с данной технологией (например, Rust). Саша не был знатоком Rust, но этого и не требовалось: используемая функция наверняка уже был реализована для этого языка, нужно только было скомпилировать её в wasm и научить PWA использовать этот wasm-файл.

Шаг первый: собираем wasm модуль

Одна из приятных особенностей работы с экосистемой Rust — это хорошая документация. О том как подготовить Rust проект для компиляции в wasm очень подробно описано здесь. Важный момент, который там описан, это установка wasm-pack.

Создадим новый Rust проект:

cargo new --lib fib_wasm

Теперь нужно добавить в файл Cargo.toml новую зависимость:

[dependencies]
wasm-bindgen = "0.2"

Именно библиотека wasm-bindgen возьмёт на себя всю работу по подготовке Rust кода в wasm: для этого используется атрибут #[wasm_bindgen]:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fib(n: u32) -> u32 {
    if n < 2 {
        n
    } else {
        fib(n - 2) + fib(n - 1)
    }
}

Чтобы собрать проект в wasm, нужно выполнить команду:

wasm-pack build --target web

После этого в каталоге с проектом появится папка pkg, в которой нас интересуют файлы <project_name>.js и <project_name>_bg.wasm.

Осталось придумать, как научить PWA использовать получившийся wasm файл.

Шаг второй: прикручиваем wasm к html

Для начала давайте положим полученные на предыдущем шаге файлы куда-нибудь поближе к index.html (можно просто в каталог web) и добавим в этот файл следующие строки:

  <script type="module" defer="">
    import init, { fib } from "./fib_wasm.js";
    init().then(() => {
      window.fib = (n) => fib(n)
    });
  </script>

Здесь мы добавили к объекту window нашу функцию, чтобы иметь возможность достучаться до неё из js кода.

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

В нашем случае это будут файлы fib.dart:

/// Сторонняя функция, которая долго вычисляется
int fib(int n) => switch (n) {
      < 2 => n,
      _ => fib(n - 2) + fib(n - 1),
    };

И fib_web.dart:

// ignore: avoid_web_libraries_in_flutter
import 'dart:js';

/// Сторонняя функция, которая долго вычисляется
int fib(int n) {
  return context.callMethod('fib', [n]);
}

Здесь мы обращаемся к той самой функции, которую добавили к объекту window.

Осталось внести изменения в наш исходный проект:

import 'fib.dart' if (dart.library.html) 'fib_web.dart';

Теперь можно и посмотреть, насколько быстрее стал наш код:

Alt text

Ну что же, неплохо! В данном случае вместо 6 секунд результат был получен за 0,945 секунды. В зависимости от особенностей вычисляемой функции, результаты могут отличаться в большую или меньшую сторону: например, в одной нашей задаче время изменилось с 1,5-8 секунд до 0,05-0,09 секунды (sic!).

В веб этот код всё ещё выполняется в основном потоке, т.е. по-прежнему может вызывать фризы, но хотя бы на меньшее время.

Заключение

Flutter — хорошая технология, которая действительно позволяет создавать крутые кроссплатформенные приложения. Но как и у любой технологии, у неё есть ограничения (особенно на не мобильных платформах). Однако технологии не стоят на месте и иногда, если ваше приложение споткнулось, ему могут помочь коллеги по цеху. Такие как Rust и WebAssembly.

Исходники проекта можно посмотреть на GitHub.

P.S. Все события вымышлены, любые совпадения с реальными людьми случайны. За время написания статьи ни одно мороженое не пострадало.