Состояние Flutter на изолятах
Во Flutter существует множество способов управления состоянием, но большинство из них строятся таким образом, что вся логика исполняется в главном изоляте вашего приложения. Исполнения сетевых запросов, работа с WebSocket, потенциально тяжелые синхронные операции (вроде локального поиска) все это, обычно, реализуют именно в главном изоляте. Эта статья покажет и другие двери.
Мне попадался всего один пакет, предназначенный для вынесения этих операций во внешние изоляты, но недавно появился и другой написанный мной). Предлагаю вам с ним ознакомиться.
В данной статье я буду оперировать двумя основными терминами -- изолят и главный поток. Они отличаются, чтобы текст не был слишком тавтологичен, но по существу, главный поток -- тоже изолят. Также тут вы найдете некоторые выражения, которые будут резать слух (или глаза) особенно чутким натурам, поэтому приношу заранее свои извинения -- извините. Также, называя в дальнейшем операции синхронными, я буду иметь в виду то, что результат вы будете получать в той же функции, в которой вызвали сторонний метод. А асинхронными -- такие функции, в которых на месте вы не получите результата, но получите его в другом.
Введение
Изоляты предназначены для исполнения кода в не основном потоке вашего приложения. Когда основной поток начинает исполнять сетевые запросы, производить вычисления или делать какие угодно операции, отличные от его главного предназначения -- отрисовки интерфейса, рано или поздно вы столкнетесь с тем, что драгоценное время на отрисовку одного кадра начнет увеличиваться. В основном, время, доступное вам для выполнения любой операции в главном потоке, ограничено ~16ms -- это окно, существующее между отрисовкой 2-х кадров при частоте 60FPS. Однако, в данный момент есть множество телефонов с большей частотой дисплея, и так, как у меня как раз такой -- тем интереснее будет сравнить производительность приложения при одних и тех же действиях с использованием разных подходов. В таком случае, окно равно уже ~11.11ms, а частота обновления дисплея 90FPS.
Исходные данные
Представим, что вам необходимо загрузить большой объем данных, вы можете сделать это несколькими способами:
- Просто осуществить запрос в главном потоке
- Использовать функцию compute для осуществления запроса
- Явно использовать изолят для запроса
Эксперименты проводились на смартфоне OnePlus 7 Pro, с процессором Snapdragon 855, и принудительно заданной частотой экрана в 90Hz. Приложение запускалось командой flutter run --profile. Проводилась эмуляция получения данных с сервера (5 одновременных запросов 10 раз подряд).
В одном запросе возвращается JSON -- массив из 2273 элементов, один из которых изображен на скриншоте. Размер ответа 1.12Mb. Таким образом, для 5 одновременных запросов получаем необходимость распарсить 5.6Mb JSON'а (но элементов в списке приложения будет 2273).
Параметры ответа сервера:
Давайте сравним все три способа по таким параметрам -- время отрисовки кадра, время операции, сложность организации/написания кода.
Пример первый: Пачка запросов из главного потока
Есть следующий код:
Future<void> loadItemsOnMainThread() async { _startFpsMeter(); isLoading = true; notifyListeners(); List<Item> mainThreadItems; for (int i = 0; i < 10; i++) { bench.startTimer('Load items in main thread'); mainThreadItems = await makeManyRequests(5); final double diff = bench.endTimer('Load items in main thread'); requestDurations.add(diff); } items.clear(); items.addAll(mainThreadItems); isLoading = false; notifyListeners(); _stopFpsMeter(); requestDurations.clear(); }
Данный метод находится в реактивном стейте, исполняемом в главном изоляте приложения.
При выполнении кода выше получаем следующие значения:
- Среднее время отрисовки одного кадра -- 14,036ms / 71.25FPS
- Медианное время кадра -- 11.148ms / 89.70FPS
- Максимальное время отрисовки одного кадра -- 100,332ms / 9.97FPS
- Среднее время для выполнения 5 одновременных запросов -- 226.894ms
Пример второй: Compute
Future<void> loadItemsWithComputed() async { _startFpsMeter(); isLoading = true; notifyListeners(); List<Item> computedItems; /// Реализовывались два варианта исполнения /// каждая пачка из 5 одновременных запросов, запускаемых последовательно, /// запускалась в функции compute if (true) { for (int i = 0; i < 10; i++) { bench.startTimer('Load items in computed'); computedItems = await compute<dynamic, List<Item>>(_loadItemsWithComputed, null); final double diff = bench.endTimer('Load items in computed'); requestDurations.add(diff); } /// Второй вариант -- все 10 запросов по 5 штук в одной функции compute } else { bench.startTimer('Load items in computed'); computedItems = await compute<dynamic, List<Item>>(_loadAllItemsWithComputed, null); final double diff = bench.endTimer('Load items in computed'); requestDurations.add(diff); } items.clear(); items.addAll(computedItems); isLoading = false; notifyListeners(); _stopFpsMeter(); requestDurations.clear(); } Future<List<Item>> _loadItemsWithComputed([dynamic _]) async { return makeManyRequests(5); } Future<List<Item>> _loadAllItemsWithComputed([dynamic _]) async { List<Item> items; for (int i = 0; i < 10; i++) { items = await makeManyRequests(5); } return items; }
В данном примере такие же запросы запускались в двух вариантах: каждые 5 одновременных запросов из 10 последовательных запускались каждый в своем compute:
- Среднее время кадра -- 11.254ms / 88.86FPS
- Медианное время кадра -- 11.152ms / 89.67FPS
- Максимальное время кадра -- 22.304ms / 44.84FPS
- Среднее время для 5 одновременных запросов -- 386.253ms
Второй вариант -- все 10 последовательных запросов по 5 одновременных запускались в одном compute:
- Среднее время кадра -- 11.252ms / 88.87FPS
- Медианное время кадра -- 11.152ms / 89.67FPS
- Максимальное время кадра -- 22.306ms / 44.83FPS
Среднее время для 5 одновременных запросов (считалось, как выполнение всех 10 по 5 запросов в compute, деленное на 10) - 231.747ms
Пример третий: Isolate
Тут стоит сделать отступление: в терминологии пакета существует две части общего стейта (состояния):
- 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
Промежуточные итоги
Проведя три эксперимента по загрузке в приложении одного и того же набора данных получаем такие показатели:
Судя по данным цифрам, можно сделать следующие выводы:
- Flutter способен обеспечивать стабильные ~90FPS
- Осуществление множества тяжелых сетевых запросов в главном потоке вашего приложения сказывается на его производительности -- появляются лаги
- Написание кода, исполняемого в главном потоке проще простого
- Compute позволяет уменьшить заметность лагов
- Написание кода с использованием Compute несет некоторые ограничения (чистые функции, нельзя передавать статические методы, нет замыкания и т.д.)
- Overhead при использовании compute по времени операции ~150-160ms
- Isolate позволяет полностью избавиться от лагов
- Написание кода с использованием изолятов сложнее, и также несет некоторые ограничения, о которых позднее
Давайте проведем еще один эксперимент, чтобы узнать наверняка, какой из способов оптимален по всем исследуемым параметрам. Его результат -- в следующей части нашей статьи.