Состояние Flutter на изолятах

Во Flutter существует множество способов управления состоянием, но большинство из них строятся таким образом, что вся логика исполняется в главном изоляте вашего приложения. Исполнения сетевых запросов, работа с WebSocket, потенциально тяжелые синхронные операции (вроде локального поиска) все это, обычно, реализуют именно в главном изоляте. Эта статья покажет и другие двери.

Мне попадался всего один пакет, предназначенный для вынесения этих операций во внешние изоляты, но недавно появился и другой написанный мной). Предлагаю вам с ним ознакомиться.

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

Введение

Изоляты предназначены для исполнения кода в не основном потоке вашего приложения. Когда основной поток начинает исполнять сетевые запросы, производить вычисления или делать какие угодно операции, отличные от его главного предназначения -- отрисовки интерфейса, рано или поздно вы столкнетесь с тем, что драгоценное время на отрисовку одного кадра начнет увеличиваться. В основном, время, доступное вам для выполнения любой операции в главном потоке, ограничено ~16ms -- это окно, существующее между отрисовкой 2-х кадров при частоте 60FPS. Однако, в данный момент есть множество телефонов с большей частотой дисплея, и так, как у меня как раз такой -- тем интереснее будет сравнить производительность приложения при одних и тех же действиях с использованием разных подходов. В таком случае, окно равно уже ~11.11ms, а частота обновления дисплея 90FPS.

Исходные данные

Представим, что вам необходимо загрузить большой объем данных, вы можете сделать это несколькими способами:

  • Просто осуществить запрос в главном потоке
  • Использовать функцию compute для осуществления запроса
  • Явно использовать изолят для запроса

Эксперименты проводились на смартфоне OnePlus 7 Pro, с процессором Snapdragon 855, и принудительно заданной частотой экрана в 90Hz. Приложение запускалось командой flutter run --profile. Проводилась эмуляция получения данных с сервера (5 одновременных запросов 10 раз подряд).

В одном запросе возвращается JSON -- массив из 2273 элементов, один из которых изображен на скриншоте. Размер ответа 1.12Mb. Таким образом, для 5 одновременных запросов получаем необходимость распарсить 5.6Mb JSON'а (но элементов в списке приложения будет 2273).

Параметры ответа сервера:

Давайте сравним все три способа по таким параметрам -- время отрисовки кадра, время операции, сложность организации/написания кода.

Пример первый: Пачка запросов из главного потока

Есть следующий код:

Future<void> loadItemsOnMainThread() async {
  _startFpsMeter();
  isLoading = true;
  notifyListeners();
  List<Item> mainThreadItems;
  for (int i = 0; i < 10; i++) {
    bench.startTimer('Load items in main thread');
    mainThreadItems = await makeManyRequests(5);
    final double diff = bench.endTimer('Load items in main thread');
    requestDurations.add(diff);
  }
  items.clear();
  items.addAll(mainThreadItems);
  isLoading = false;
  notifyListeners();
  _stopFpsMeter();
  requestDurations.clear();
} 

Данный метод находится в реактивном стейте, исполняемом в главном изоляте приложения.

При выполнении кода выше получаем следующие значения:

  • Среднее время отрисовки одного кадра -- 14,036ms / 71.25FPS
  • Медианное время кадра -- 11.148ms / 89.70FPS
  • Максимальное время отрисовки одного кадра -- 100,332ms / 9.97FPS
  • Среднее время для выполнения 5 одновременных запросов -- 226.894ms

Пример второй: Compute

Future<void> loadItemsWithComputed() async {
  _startFpsMeter();
  isLoading = true;
  notifyListeners();
  List<Item> computedItems;
  /// Реализовывались два варианта исполнения
  /// каждая пачка из 5 одновременных запросов, запускаемых последовательно,
  /// запускалась в функции compute
  if (true) {
    for (int i = 0; i < 10; i++) {
      bench.startTimer('Load items in computed');
      computedItems = await compute<dynamic, List<Item>>(_loadItemsWithComputed, null);
      final double diff = bench.endTimer('Load items in computed');
      requestDurations.add(diff);
    }
    /// Второй вариант -- все 10 запросов по 5 штук в одной функции compute
  } else {
    bench.startTimer('Load items in computed');
    computedItems = await compute<dynamic, List<Item>>(_loadAllItemsWithComputed, null);
    final double diff = bench.endTimer('Load items in computed');
    requestDurations.add(diff);
  }
  items.clear();
  items.addAll(computedItems);
  isLoading = false;
  notifyListeners();
  _stopFpsMeter();
  requestDurations.clear();
}

Future<List<Item>> _loadItemsWithComputed([dynamic _]) async {
  return makeManyRequests(5);
}

Future<List<Item>> _loadAllItemsWithComputed([dynamic _]) async {
  List<Item> items;
  for (int i = 0; i < 10; i++) {
    items = await makeManyRequests(5);
  }
  return items;
}

В данном примере такие же запросы запускались в двух вариантах: каждые 5 одновременных запросов из 10 последовательных запускались каждый в своем compute:

  • Среднее время кадра -- 11.254ms / 88.86FPS
  • Медианное время кадра -- 11.152ms / 89.67FPS
  • Максимальное время кадра -- 22.304ms / 44.84FPS
  • Среднее время для 5 одновременных запросов -- 386.253ms

Второй вариант -- все 10 последовательных запросов по 5 одновременных запускались в одном compute:

  • Среднее время кадра -- 11.252ms / 88.87FPS
  • Медианное время кадра -- 11.152ms / 89.67FPS
  • Максимальное время кадра -- 22.306ms / 44.83FPS

Среднее время для 5 одновременных запросов (считалось, как выполнение всех 10 по 5 запросов в compute, деленное на 10) - 231.747ms

Пример третий: Isolate

Тут стоит сделать отступление: в терминологии пакета существует две части общего стейта (состояния):

  1. Frontend-стейт -- некий реактивный стейт, который отправляет сообщения в Backend, обрабатывает его ответы, а также хранит данные, после обновления которых обновляется и UI, а также он хранит легкие методы, которые вызываются из UI. Данный стейт работает в главном потоке приложения.
  2. Backend-стейт -- тяжелый стейт, получающий сообщения от фронта, выполняющий тяжелые операции, возвращающий ответы фронту и работающий в отдельном изоляте. Данный стейт также может хранить данные (тут, как вам захочется).

Код из третьего варианта разбит на несколько методов, по причине наличия необходимости общения с изолятом. Методы фронта показаны ниже:

/// Данный метод является точкой входа в операцию
Future<void> loadItemsWithIsolate() async {
  /// Запускаем счетчик кадров перед всей операцией
  _startFpsMeter();
  isLoading = true;
  notifyListeners();
  /// Начинаем считать время запросов
  bench.startTimer('Load items in separate isolate');
  /// Отправляем событие в "тяжеловесную" часть стейта, запускаемую на изоляте
  send(Events.startLoadingItems);
}

/// Обработчик события [Events.loadingItems] по обновлению времени запросов из изолята
void _middleLoadingEvent() {
  final double time = bench.endTimer('Load items in separate isolate');
  requestDurations.add(time);
  bench.startTimer('Load items in separate isolate');
}

/// Обработчик завершающего события [Events.endLoadingItems] из изолята
Future<void> _endLoadingEvents(List<Item> items) async {
  this.items.clear();
  /// Обновляем данные в реактивном стейте
  this.items.addAll(items);
  /// Заканчиваем считать время запросов
  final double time = bench.endTimer('Load items in separate isolate');
  requestDurations.add(time);
  isLoading = false;
  notifyListeners();
  /// Останавливаем счетчик кадров
  _stopFpsMeter();
  requestDurations.clear();
}

А тут вы можете увидеть метод бэка с нужной нам логикой:

/// Обработчик события [Events.startLoadingItems]
Future<void> _loadingItems() async {
  _items.clear();
  for (int i = 0; i < 10; i++) {
    _items.addAll(await makeManyRequests(5));
    if (i < (10 - 1)) {
      /// Для всех запросов, кроме последнего - отсылаем только одно событие
      send(Events.loadingItems);
    } else {
      /// Для последнего из 10ти запросов - отсылаем сообщение с данными
      send(Events.endLoadingItems, _items);
    }
  }
}

Результаты:

  • Среднее время кадра -- 11.151ms / 89.68FPS
  • Медианное время кадра -- 11.151ms / 89.68FPS
  • Максимальное время кадра -- 11.152ms / 89.67FPS

Промежуточные итоги

Проведя три эксперимента по загрузке в приложении одного и того же набора данных получаем такие показатели:

Судя по данным цифрам, можно сделать следующие выводы:

  • Flutter способен обеспечивать стабильные ~90FPS
  • Осуществление множества тяжелых сетевых запросов в главном потоке вашего приложения сказывается на его производительности -- появляются лаги
  • Написание кода, исполняемого в главном потоке проще простого
  • Compute позволяет уменьшить заметность лагов
  • Написание кода с использованием Compute несет некоторые ограничения (чистые функции, нельзя передавать статические методы, нет замыкания и т.д.)
  • Overhead при использовании compute по времени операции ~150-160ms
  • Isolate позволяет полностью избавиться от лагов
  • Написание кода с использованием изолятов сложнее, и также несет некоторые ограничения, о которых позднее

Давайте проведем еще один эксперимент, чтобы узнать наверняка, какой из способов оптимален по всем исследуемым параметрам. Его результат -- в следующей части нашей статьи.