[Open source, Разработка мобильных приложений, Flutter] Состояние Flutter на изолятах
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Во 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 ThreadCompute 1req in 1Compute 10req in 1IsolateСреднее время кадра14.036ms11.254ms11.252ms11.151msМедианное время кадра11.148ms11.152ms11.152ms11.151msМаксимальное время кадра100.332ms22.304ms22.306ms11.152msСреднее время пачки запросов226.894ms386.253ms231.747ms218.731msСубъективная сложность кода (больше - сложнее)1234Судя по данным цифрам, можно сделать следующие выводы:
- 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 ThreadComputeIsolateСреднее время кадра21.588ms12.682ms11.354msМаксимальное время кадра668.986ms111.544ms33.455msСреднее время кадра (схожесть)43.123ms12.515ms11.353msМаксимальное время кадра (схожесть)2 440.910ms111.527ms33.459msСубъективная сложность кода (больше - сложнее)123Из этой таблички и предыдущего исследования следует, что:
- Главный поток не следует использовать для операций > 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 стейтовЖелтая двусторонняя стрелка - взаимодействие с какими-либо сервисами из вне, например - неким сервером. А фиолетовая, идущая от сервера к бэку - это входящие сообщения от того же сервера, например - WebSocket.2 Синхронное общение через вызов функции бэкенда по ее ID2.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
- Вам не хочется разбираться с изолятами
- Цикл жизни вашего приложения настолько короткий, что нет смысла его оптимизировать
В противном случае - вы можете обратить свое внимание на данный подход и пакет, который упростит вашу работу с изолятами.Видео всех экспериментовИзвините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :( Извините, данный ресурс не поддреживается. :(
===========
Источник:
habr.com
===========
Похожие новости:
- [Open source] Опыт Embox как менторской организации в программе GSoC2020
- [Программирование, Разработка под iOS, Разработка мобильных приложений, Разработка под Android] Кошелёк Mobile Challenge: итоги конкурса и подробный разбор решений командой разработки
- [Информационная безопасность, Разработка мобильных приложений] Пользователи системы «Помощник Москвы» смогут сообщать о незаконной торговле и скоплениях людей
- [Программирование, Разработка мобильных приложений, Dart, Flutter] Flutter под капотом: Owners
- [Open source, Тестирование IT-систем, Программирование, Java] Вжух, и прогоны автотестов оптимизированы. Intellij IDEA плагины на службе QA Automation
- [Информационная безопасность, Open source, Git, GitHub, IT-компании] GitHub предупредил о переходе на токены и SSH-ключи при доступе к Git, пароли отменяются с 13 августа 2021 года
- [Open source, CAD/CAM, Веб-аналитика, История IT, IT-компании] Войны лоббистов и развитие BIM. Часть 2: open BIM VS closed BIM. Revit vs ArchiCAD и Европа против остального мира
- [Open source, Работа с видео, Nginx, Видеотехника, Видеоконференцсвязь] Видео трансляция с Oven Media Engine, до свидания nginx-rtmp-module,
- [Платежные системы, Разработка мобильных приложений, Разработка под Android, Смартфоны] Кошелёк в смартфоне и оплата без интернета: как работает система платежей в экосистеме Huawei
- [Настройка Linux, Open source, Разработка под Linux] Состоялся релиз ядра Linux 5.10
Теги для поиска: #_open_source, #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_flutter, #_flutter, #_isolate, #_state, #_sostojanie (состояние), #_upravlenie_sostojaniem (управление состоянием), #_mnogopotochnost (многопоточность), #_dart, #_open_source, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
), #_flutter
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 19:08
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Во Flutter существует множество способов управления состоянием, но большинство из них строятся таким образом, что вся логика исполняется в главном изоляте вашего приложения. Исполнения сетевых запросов, работа с WebSocket, потенциально тяжелые синхронные операции (вроде локального поиска) все это, обычно, реализуют именно в главном изоляте. Мне попадался всего один пакет, предназначенный для вынесения этих операций во внешние изоляты, но недавно появился и другой (написанный мной). Предлагаю вам с ним ознакомиться.В данной статье я буду оперировать двумя основными терминами - изолят и главный поток. Они отличаются, чтобы текст не был слишком тавтологичен, но по существу, главный поток - тоже изолят. Также тут вы найдете некоторые выражения, которые будут резать слух (или глаза) особенно чутким натурам, поэтому приношу заранее свои извинения - извините. Все сомнительные слова я буду помечать курсивом. Также, называя в дальнейшем операции синхронными - я буду иметь в виду то, что результат вы будете получать в той же функции, в которой вызвали сторонний метод. А асинхронными - такие функции, в которых на месте вы не получите результата, но получите его в другом.ВведениеИзоляты предназначены для исполнения кода в не основном потоке вашего приложения. Когда основной поток начинает исполнять сетевые запросы, производить вычисления или делать какие угодно операции, отличные от его главного предназначения - отрисовки интерфейса, рано или поздно вы столкнетесь с тем, что драгоценное время на отрисовку одного кадра начнет увеличиваться. В основном, время, доступное вам для выполнения любой операции в главном потоке ограничено ~16ms, это окно, существующее между отрисовкой 2х кадров при частоте 60FPS. Однако, в данный момент есть множество телефонов с большей частотой дисплея, и так, как у меня как раз такой - тем интереснее будет сравнить производительность приложения при одних и тех же действиях с использованием разных подходов. В таком случае, окно равно уже ~11.11ms, а частота обновления дисплея 90FPS.Исходные данныеПредставим, что вам необходимо загрузить большой объем данных, вы можете сделать это несколькими способами:
Параметры ответа сервераДавайте сравним все три способа по таким параметрам - время отрисовки кадра, время операции, сложность организации / написания кода. Пример первый: Пачка запросов из главного потокаЕсть следующий код: 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(); }
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; }
/// Данный метод является точкой входа в операцию
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); } } }
/// Функция для создания копии элементов
/// используемых как исходные при фильтрации 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(); }
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; }
/// Запускаем операцию в изоляте
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); }
class Item {
const Item( this.id, this.createdAt, this.profile, this.imageUrl, ); final int id; final DateTime createdAt; final String profile; final String imageUrl; } /// 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; } /// 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, }; 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, }; } 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, }; } Схема взаимодействия Frontend - Backend стейтовЖелтая двусторонняя стрелка - взаимодействие с какими-либо сервисами из вне, например - неким сервером. А фиолетовая, идущая от сервера к бэку - это входящие сообщения от того же сервера, например - WebSocket.2 Синхронное общение через вызов функции бэкенда по ее ID2.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(); } } 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, }; } 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 { /// ... }
=========== Источник: habr.com =========== Похожие новости:
Разработка мобильных приложений ), #_flutter |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 19:08
Часовой пояс: UTC + 5