Во 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

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

  • Frontend-стейт - некий реактивный стейт, который отправляет сообщения в Backend, обрабатывает его ответы, а также хранит данные, после обновления которых обновляется и UI, а также он хранит легкие методы, которые вызываются из UI. Данный стейт работает в главном потоке приложения.

  • 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

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

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

Main Thread

Compute 1req in 1

Compute 10req in 1

Isolate

Среднее время кадра

14.036ms

11.254ms

11.252ms

11.151ms

Медианное время кадра

11.148ms

11.152ms

11.152ms

11.151ms

Максимальное время кадра

100.332ms

22.304ms

22.306ms

11.152ms

Среднее время пачки запросов

226.894ms

386.253ms

231.747ms

218.731ms

Субъективная сложность кода (больше - сложнее)

1

2

3

4

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

  • Flutter способен обеспечивать стабильные ~90FPS

  • Осуществление множества тяжелых сетевых запросов в главном потоке вашего приложения сказывается на его производительности - появляются лаги

  • Написание кода, исполняемого в главном потоке проще простого

  • Compute позволяет уменьшить заметность лагов

  • Написание кода с использованием Compute несет некоторые ограничения (чистые функции, нельзя передавать статические методы, нет замыкания и т.д.)

  • Overhead при использовании compute по времени операции ~150-160ms

  • Isolate позволяет полностью избавиться от лагов

  • Написание кода с использованием изолятов сложнее, и также несет некоторые ограничения, о которых позднее

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

Эксперимент номер два: Локальный поиск

Представим, что теперь нам необходимо найти в загруженных данных определенные элементы по вводимому в инпут значению. Данный тест реализован следующим способом: имеется инпут, в который вводятся посимвольно 3 подстроки в 3 символа из числа подстрок, имеющихся в элементах списка. Количество элементов в массиве при поиске увеличено в 10 раз и составляет 22730 штук.

Поиск осуществлялся в 2х режимах - примитивное наличие введенной строки в элементе из списка, а также с использованием алгоритма схожести строк.

Также, асинхронные варианты поиска - compute / isolate не начинаются, пока не завершится предыдущий поиск. Т.е. схема такая - введя первый символ в инпут, начинаем поиск, пока он не завершится - данные не вернутся в основной поток и не перерисуется UI, второй символ в инпут не вводится. Когда все действия завершены, вводится второй символ и также наоборот. Это аналогично алгоритму, когда мы "копим" введенные пользователем символы, а затем отправляем всего один запрос, вместо отправки запроса на абсолютно каждый введенный символ, вне зависимости от того, с какой скоростью они вводились.

Замеры времени отрисовки производились только во время ввода символов в поиск, т.е. операции подготовки данных и что-то другое, не влияли на собранные данные.

Для начала, вспомогательные функции, функция поиска и другой общий код:

/// Функция для создания копии элементов
/// используемых как исходные при фильтрации
void cacheItems() {
  _notFilteredItems.clear();
  final List<Item> multipliedItems = [];
  for (int i = 0; i < 10; i++) {
    multipliedItems.addAll(items);
  }
  _notFilteredItems.addAll(multipliedItems);
}
/// Функция, запускающая тестовый сценарий
/// по вводу символов в текстовый инпут
Future<void> _testSearch() async {
  List<String> words = items.map((Item item) => item.profile.replaceAll('https://opencollective.com/', '')).toSet().toList();
  words = words
    .map((String word) {
      final String newWord = word.substring(0, min(word.length, 3));
      return newWord;
    })
    .toSet()
    .take(3)
    .toList();

  /// Стартуем счетчик кадров
  _startFpsMeter();
  for (String word in words) {
    final List<String> letters = word.split('');
    String search = '';
    for (String letter in letters) {
      search += letter;
      await _setWord(search);
    }
    while (search.isNotEmpty) {
      search = search.substring(0, search.length - 1);
      await _setWord(search);
    }
  }
  /// Останавливаем счетчик
  _stopFpsMeter();
}
/// Вводим символы с задержкой
/// в 800мс, но если данные из асинхронного
/// фильтра (computed / isolate) еще не пришли,
/// то ждем их
Future<void> _setWord(String word) async {
  if (!canPlaceNextLetter) {
    await wait(800);
    await _setWord(word);
  } else {
    searchController.value = TextEditingValue(text: word);
    await wait(800);
  }
}
/// В зависимости от установленного флага [USE_SIMILARITY]
/// используется или нет поиск со схожестью строк
List<Item> filterItems(Packet2<List<Item>, String> itemsAndInputValue) {
  return itemsAndInputValue.value.where((Item item) {
    return item.profile.contains(itemsAndInputValue.value2) || (USE_SIMILARITY && isStringsSimilar(item.profile, itemsAndInputValue.value2));
  }).toList();
}

bool isStringsSimilar(String first, String second) {
  return max(StringSimilarity.compareTwoStrings(first, second), StringSimilarity.compareTwoStrings(second, first)) >= 0.3);
}

Поиск в главном потоке

Future<void> runSearchOnMainThread() async {
  cacheItems();
  isLoading = true;
  notifyListeners();
  searchController.addListener(_searchOnMainThread);
  await _testSearch();
  searchController.removeListener(_searchOnMainThread);
  isLoading = false;
  notifyListeners();
}

void _searchOnMainThread() {
  final String searchValue = searchController.text;
  if (searchValue.isEmpty && items.length != _notFilteredItems.length) {
    items.clear();
    items.addAll(_notFilteredItems);
    notifyListeners();
    return;
  }
  items.clear();
  /// Packet2 - обертка для двух значений
  items.addAll(filterItems(Packet2(_notFilteredItems, searchValue)));
  notifyListeners();
}

Простой поиск:

  • Среднее время кадра - 21.588ms / 46.32FPS

  • Медианное время кадра - 11.154ms / 89.65FPS

  • Максимальное время кадра - 668,986ms / 1.50FPS

Поиск со схожестью:

  • Среднее время кадра - 43,123ms / 23.19FPS

  • Медианное время кадра - 11,152ms / 89.67FPS

  • Максимальное время кадра - 2 440,910ms / 0.41FPS

Поиск через Compute

Future<void> runSearchWithCompute() async {
  cacheItems();
  isLoading = true;
  notifyListeners();
  searchController.addListener(_searchWithCompute);
  await _testSearch();
  searchController.removeListener(_searchWithCompute);
  isLoading = false;
  notifyListeners();
}

Future<void> _searchWithCompute() async {
  canPlaceNextLetter = false;
  /// Перед началом фильтрации
  /// устанавливаем флаг, который будет сигнализировать
  /// о том, что происходит асинхронная фильтрация
  isSearching = true;
  notifyListeners();
  final String searchValue = searchController.text;
  if (searchValue.isEmpty && items.length != _notFilteredItems.length) {
    items.clear();
    items.addAll(_notFilteredItems);
    isSearching = false;
    notifyListeners();
    await wait(800);
    canPlaceNextLetter = true;
    return;
  }
  final List<Item> filteredItems = await compute(filterItems, Packet2(_notFilteredItems, searchValue));
  /// После окончания фильтрации убираем сигнал
  isSearching = false;
  notifyListeners();
  await wait(800);
  items.clear();
  items.addAll(filteredItems);
  notifyListeners();
  canPlaceNextLetter = true;
}

Простой поиск:

  • Среднее время кадра - 12,682ms / 78.85FPS

  • Медианное время кадра - 11,154ms / 89.65FPS

  • Максимальное время кадра - 111,544ms / 8.97FPS

Поиск со схожестью:

  • Среднее время кадра - 12,515ms / 79.90FPS

  • Медианное время кадра - 11,153ms / 89.66FPS

  • Максимальное время кадра - 111,527ms / 8.97FPS

Поиск с помощью Isolate

Немного кода:

/// Запускаем операцию в изоляте
Future<void> runSearchInIsolate() async {
  send(Events.cacheItems);
}

void _middleLoadingEvent() {
  final double time = bench.endTimer('Load items in separate isolate');
  requestDurations.add(time);
  bench.startTimer('Load items in separate isolate');
}

/// Этот метод запускается на событие [Events.cacheItems],
/// отправленное из изолята
Future<void> _startSearchOnIsolate() async {
  isLoading = true;
  notifyListeners();
  searchController.addListener(_searchInIsolate);
  await _testSearch();
  searchController.removeListener(_searchInIsolate);
  isLoading = false;
  notifyListeners();
}

/// На каждое изменение инпута отсылается сообщение в изолят
void _searchInIsolate() {
  canPlaceNextLetter = false;
  isSearching = true;
  notifyListeners();
  send(Events.startSearch, searchController.text);
}

/// Запись в реактивный стейт данных из изолята
Future<void> _setFilteredItems(List<Item> filteredItems) async {
  isSearching = false;
  notifyListeners();
  await wait(800);
  items.clear();
  items.addAll(filteredItems);
  notifyListeners();
  canPlaceNextLetter = true;
}

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);
  await wait(800);
  isLoading = false;
  notifyListeners();
  _stopFpsMeter();
  print('Load items in isolate ->' + requestDurations.join(' ').replaceAll('.', ','));
  requestDurations.clear();
}

А это методы, находящиеся в бэкенде, который работает в стороннем изоляте:

/// Обработчик события [Events.cacheItems]
void _cacheItems() {
  _notFilteredItems.clear();
  final List<Item> multipliedItems = [];
  for (int i = 0; i < 10; i++) {
    multipliedItems.addAll(_items);
  }
  _notFilteredItems.addAll(multipliedItems);
  send(Events.cacheItems);
}

/// На каждое событие [Events.startSearch] вызывается данный метод
/// фильтрующий элементы и отсылающий отфильтрованное в легкий стейт
void _filterItems(String searchValue) {
  if (searchValue.isEmpty) {
    _items.clear();
    _items.addAll(_notFilteredItems);
    send(ThirdEvents.setFilteredItems, _items);
    return;
  }
  final List<Item> filteredItems = filterItems(Packet2(_notFilteredItems, searchValue));
  _items.clear();
  _items.addAll(filteredItems);
  send(Events.setFilteredItems, _items);
}

Простой поиск:

  • Среднее время кадра - 11,354ms / 88.08FPS

  • Медианное время кадра - 11,153ms / 89.66FPS

  • Максимальное время кадра - 33,455ms / 29.89FPS

Поиск со схожестью:

  • Среднее время кадра - 11,353ms / 88.08FPS

  • Медианное время кадра - 11,153ms / 89.66FPS

  • Максимальное время кадра - 33,459ms / 29.89FPS

Еще одни выводы

Main Thread

Compute

Isolate

Среднее время кадра

21.588ms

12.682ms

11.354ms

Максимальное время кадра

668.986ms

111.544ms

33.455ms

Среднее время кадра (схожесть)

43.123ms

12.515ms

11.353ms

Максимальное время кадра (схожесть)

2 440.910ms

111.527ms

33.459ms

Субъективная сложность кода (больше - сложнее)

1

2

3

Из этой таблички и предыдущего исследования следует, что:

  • Главный поток не следует использовать для операций > 16ms (чтобы обеспечить, хотя бы, 60FPS)

  • Compute технически подходит для частых и тяжелых операций, но накладывает overhead в те же 150ms, а также имеет более нестабильную производительность, по сравнению с постоянным изолятом (вероятно, это связано с тем, что каждый раз открывается, и, после завершения операции - закрывается изолят, что также требует ресурсов)

  • Isolate - самый сложный в написании кода способ достижения максимальной производительности приложения на Flutter

Что же, кажется, что изоляты - это идеальный способ достижения результата, и даже Google советует использовать именно их для всех тяжелых операций (это для красного словца, пруфов я не нашел ?). Но нужно писать много кода. На самом деле, все что написано выше - это результат, достигнутый с использованием представленной в самом начале библиотеки, без нее - придется написать намного, намнооого больше. К тому же, данный алгоритм поиска можно оптимизировать - после фильтрации всех элементов отправлять фронту только маленькую порцию данных - это отнимет меньше ресурсов, а уже после ее передачи отправлять все остальное.

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

class Item {
  const Item(
    this.id,
    this.createdAt,
    this.profile,
    this.imageUrl,
  );

  final int id;
  final DateTime createdAt;
  final String profile;
  final String imageUrl;
}

И получилось следующее - при одновременной передаче 5000 элементов, время, которое уходит на копирование данных, не влияет на UI, т.е. частота отрисовки не уменьшается. Было передано 1 000 000 таких элементов пачками по 5 000 штук за раз с принудительной паузой между передачей пачек в 8ms, через Future<void>.delayed , при этом частота кадров не опускалась ниже 80FPS. К сожалению, делал я этот эксперимент задолго до написания данной статьи и сухих цифр нет (если будет запрос - то появятся).

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

Вот пример:

/// Frontend part
Future<void> decrement([int diff = 1]) async {
  counter = await runBackendMethod<int, int>(Events.decrement, diff);
}

/// -----

/// Backend part
Future<int> _decrement(int diff) async {
  counter -= diff;
  return counter;
}

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

/// Frontend part
/// Данный блок отвечает за обработку событий из изолята
@override
Map<Events, Function> get tasks => {
  Events.increment: _setCounter,
  Events.decrement: _setCounter,
  Events.error: _setCounter,
};

/// -----

/// Backend part
/// А данный - за обработку событий из главного потока
@override
Map<Events, Function> get operations => {
  Events.increment: _increment,
  Events.decrement: _decrement,
};

Таким образом мы получаем два способа взаимодействия:

1 Асинхронное общение через явную передачу сообщений

1.1 Frontend-стейт (тот, что крутится в главном потоке, замиксованный с BackendMixin<EventType> ) отправляет событие в Backend-стейт используя метод send, передавая в сообщении ID события и необязательный аргумент.

enum Events {
  increment,
}

class FirstState with BackendMixin<Events> {
  int counter = 0;

  void increment([int diff = 1]) {
    send(Events.increment, diff);
  }

  void _setCounter(int value) {
    counter = value;
    notifyListeners();
  }

  @override
  Map<Events, Function> get tasks => {
    Events.increment: _setCounter,
  };
}

1.2 Это сообщение передается в бэкенд и обрабатывается там

class FirstBackend extends Backend<Events> {
  FirstBackend(SendPort toFrontend) : super(toFrontend);

  int counter = 0;

  void _increment(int diff) {
    counter += diff;
    send(Events.increment, counter);
  }

  @override
  Map<Events, Function> get operations => {
    Events.increment: _increment,
  };
}

1.3 Backend-стейт возвращает результат в реактивный стейт главного потока и готово! Есть два способа вернуть результат - возврат ответа методом бэкенда (return) (тогда ответ будет отправлен с тем же ID сообщения, что и был получен), а второй - явно вызвать метод send. При этом можно отправлять в реактивный стейт какие угодно сообщения с любыми, заданными вами ID. Главное - чтобы этим ID были заданы методы-обработчики.

Схематично, первый способ выглядит так:

Схема взаимодействия Frontend - Backend стейтов
Схема взаимодействия Frontend - Backend стейтов

Желтая двусторонняя стрелка - взаимодействие с какими-либо сервисами из вне, например - неким сервером. А фиолетовая, идущая от сервера к бэку - это входящие сообщения от того же сервера, например - WebSocket.

2 Синхронное общение через вызов функции бэкенда по ее ID

2.1 Frontend использует метод runBackendMethod , указывая ID, чтобы вызвать метод бэка, ему соответствующий, получая ответ тут же. В таком способе не обязательно даже указывать что-либо в списке задач (tasks) вашего фронта. При этом, как показано в коде ниже, вы можете переопределить метод onBackendResponse в вашем фронте, который вызывается после каждого получения вашим фронт-стейтом сообщений от бэка.

enum Events {
  decrement,
}

class FirstState with ChangeNotifier, BackendMixin<Events> {
  int counter = 0;

  Future<void> decrement([int diff = 1]) async {
    counter = await runBackendMethod<int, int>(Events.decrement, diff);
  }

  /// Automatically notification after any event from backend
  @override
  void onBackendResponse() {
    notifyListeners();
  }
}

2.2 Backend-метод обрабатывает пришедшее событие, и просто возвращает результат. В данном случае есть одно ограничение - методы бэка, вызываемые "синхронно", не должны вызывать метод send, с тем же ID, которому они соответствуют. В данном примере метод _decrement не должен вызывать метод send(Events.decrement). При этом любые другие сообщения он отправлять может.

class FirstBackend extends Backend<Events> {
  FirstBackend(SendPort toFrontend) : super(toFrontend);

  int counter = 0;

  /// Or, you can simply return a value
  Future<int> _decrement(int diff) async {
    counter -= diff;
    return counter;
  }

  @override
  Map<Events, Function> get operations => {
    Events.decrement: _decrement,
  };
}

Схема второго способа похожа на первый, за тем исключением, что во фронте вам не нужно писать обработчики событий, прилетающих с бэка.

Что бы еще добавить...

Чтобы использовать такую связку - необходимо эти бэкенды создавать. Для этого в BackendMixin<EventType> заложен механизм создания бэка - метод initBackend. В данный метод необходимо передать функцию-фабрику по созданию бэкенда. Это должна быть чистая функция высшего уровня (top-level, как гласит документация Flutter), либо статический метод класса. Время создания одного изолята ~200ms.

enum Events {
  increment,
  decrement,
}

class FirstState with ChangeNotifier, BackendMixin<Events> {
  int counter = 0;

  void increment([int diff = 1]) {
    send(Events.increment, diff);
  }

  Future<void> decrement([int diff = 1]) async {
    counter = await runBackendMethod<int, int>(Events.decrement, diff);
  }

  void _setCounter(int value) {
    counter = value;
  }

  Future<void> initState() async {
    await initBackend(createFirstBackend);
  }

  /// Automatically notification after any event from backend
  @override
  void onBackendResponse() {
    notifyListeners();
  }

  @override
  Map<Events, Function> get tasks => {
    Events.increment: _setCounter,
  };
}

Пример функции-создателя Backend-части:

typedef Creator<TDataType> = void Function(BackendArgument<TDataType> argument);

void createFirstBackend(BackendArgument<void> argument) {
  FirstBackend(argument.toFrontend);
}

@protected
Future<void> initBackend<TDataType extends Object>(Creator<TDataType> creator, {TDataType data, ErrorHandler errorHandler}) async {
	/// ...
}

Ограничения

  • Все тоже самое, что есть у обычного изолята

  • Для каждого создающегося "бэкенда" в данный момент создается свой изолят и при слишком большом количестве бэкендов - время их создания становится ощутимым, особенно, если инициализировать все их, скажем, при загрузке приложения. Я проводил эксперименты, запуская одновременно 30 бэкендов - время загрузки на указанном выше телефоне в --release режиме заняло 6 с небольшим секунд.

  • Есть некоторые сложности с обработкой ошибок, возникающих в изолятах (бэкендах). Тут, если вас заинтересует данный пакет, следует подробнее ознакомиться с методом initBackend из BackendMixin.

  • Сложность написания кода выше, по сравнению с хранением логики только в главном потоке

Чек-лист для использования

Тут все просто, вам не нужно использовать изоляты (как отдельно, так и с помощью данного пакета), если:

  • Производительность вашего приложения не падает при различных операциях

  • Для узких мест вам достаточно compute

  • Вам не хочется разбираться с изолятами

  • Цикл жизни вашего приложения настолько короткий, что нет смысла его оптимизировать

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

Видео всех экспериментов