Состояние Flutter на изолятах | OTUS >
Скидка 10% на Подписку до 31.03!
Цены на странице указаны без учета скидки
Смотреть

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

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

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

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

1214fd4f15ce9ea52b0d246d6862b3ed_1-1801-01269a.png

Введение

Изоляты предназначены для исполнения кода в не основном потоке вашего приложения. Когда основной поток начинает исполнять сетевые запросы, производить вычисления или делать какие угодно операции, отличные от его главного предназначения -- отрисовки интерфейса, рано или поздно вы столкнетесь с тем, что драгоценное время на отрисовку одного кадра начнет увеличиваться. В основном, время, доступное вам для выполнения любой операции в главном потоке, ограничено ~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).

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

b7e8cce76a57eae9b9afb1ab8a64a2bf_1-1801-3bc96f.png

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

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

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

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

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

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

1-1801-eef44c.png

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

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

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

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
0 комментариев
Для комментирования необходимо авторизоваться
Популярное
Сегодня тут пусто