Состояние Flutter на изолятах. Эксперимент № 2 | OTUS

Курсы

Программирование
Выбор профессии в IT
-99%
Python Developer. Basic Специализация Python Developer Python Developer. Professional Golang Developer. Professional Базы данных iOS Developer. Basic Computer Science Android Developer. Professional Team Lead Android Developer. Basic Специализация Android-разработчик Vue.js разработчик Groovy Developer JavaScript Developer. Basic Специализация Java-разработчик C++ Developer. Basic Специализация Fullstack developer Unity Game Developer. Basic PHP Developer. Professional Agile Project Manager PostgreSQL для администраторов баз данных и разработчиков MS SQL Server Developer Unreal Engine Game Developer. Professional Web-разработчик на Python Cloud Solution Architecture Flutter Mobile Developer PHP Developer. Basic Специализация PHP Developer Rust Developer Буткемп Java Unity VR/AR Developer
Специализации Курсы в разработке Подготовительные курсы Подписка
+7 499 938-92-02

Состояние Flutter на изолятах. Эксперимент № 2

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

Представим, что теперь нам необходимо найти в загруженных данных определенные элементы по вводимому в инпут-значению. Данный тест реализован следующим способом: имеется инпут, в который вводятся посимвольно 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

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

2-1801-6f16a3.png

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

  • Главный поток не следует использовать для операций > 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 были заданы методы-обработчики.

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

fa8cb4d343d4d72a24648469d52bd0d0_1-1801-34e85d.png

Желтая двусторонняя стрелка -- взаимодействие с какими-либо сервисами извне, например -- неким сервером. А фиолетовая, идущая от сервера к бэку -- это входящие сообщения от того же сервера, например -- 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,
  };
}

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

fded86fa740396ea94dd9845cd3fa0e1_1-1801-36a02d.png

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

Чтобы использовать такую связку -- необходимо эти бэкенды создавать. Для этого в 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
  • Вам не хочется разбираться с изолятами
  • Цикл жизни вашего приложения настолько короткий, что нет смысла его оптимизировать

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

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

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

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

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