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

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

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

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

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

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

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

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

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

Профилируем

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

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

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

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

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

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

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

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

Почти шесть секунд ожидания
Почти шесть секунд ожидания

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

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

Используем compute

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

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

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

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

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

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

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

Магия не работает вне Хогвартса
Магия не работает вне Хогвартса

Если что-то ведёт себя не так, как ты ожидаешь, то скорее всего ты чего-то не знаешь об этом самом чём-то. И действительно, если обратиться к статье об изолятах на сайте языка 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';

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

Угнать за 0,945 секунд
Угнать за 0,945 секунд

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

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

Заключение

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

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

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