Привет! Это вторая статья из цикла о разработке приложения на Flutter. В этом "номере" я опишу создание сетевого слоя, работу с локализацией, удобный способ работы с ассетами, локальный поиск и создание UI для одного из двух экранов приложения. Также я выведу интересные метрики, например - сколько данных сможет распарсить ваше приложение за одну милисекунду и начиная с какого размера JSON’а, прилетевшего с бэка UI начнет тормозить. Как говорится - с места...

Ссылки на статьи цикла
Часть 1: Идея + Базовая инфраструктура
Часть 2: Сеть, локализация, локальный поиск, главный экран
Сеть
Для отрисовки первого экрана необходимы следующие данные:

image
title
subtitle
price
diffИсходя из этого получаем следующую сущность, описывающую каждый из токенов:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import '../../../service/types/types.dart';
import 'item_prices.dart';
part 'stock_item.g.dart';
// BTC, ETH etc.
typedef CryptoSymbol = String;
/* Example of data:
{
"id": 1,
"name": "Bitcoin",
"symbol": "BTC",
"max_supply": 21000000,
"circulating_supply": 18897568,
"total_supply": 18897568,
"platform": null,
"cmc_rank": 1,
"last_updated": "2021-12-11T03:44:02.000Z",
"quote": {
"USD": {
"price": 48394.083464545605,
"volume_24h": 32477191827.784477,
"volume_change_24h": 7.5353,
"percent_change_1h": 0.3400355,
"percent_change_24h": 0.05623531,
"percent_change_7d": -7.88809336,
"percent_change_30d": -25.12367453,
"percent_change_60d": -14.67776793,
"percent_change_90d": 6.86740691,
"market_cap": 914530483068.9261,
"market_cap_dominance": 40.8876,
"fully_diluted_market_cap": 1016275752755.46,
"last_updated": "2021-12-11T03:44:02.000Z"
}
}
}
*/
@immutable
@JsonSerializable()
class StockItem {
const StockItem({
required this.id,
required this.name,
required this.symbol,
required this.prices,
});
factory StockItem.fromJson(Json json) => _$StockItemFromJson(json);
final int id;
final String name;
final CryptoSymbol symbol;
@JsonKey(name: 'quote')
final Map<CryptoSymbol, ItemPrices> prices;
ItemPrices get usdPrices => prices['USD']!;
String imageUrl(int size) {
assert(size > 128 && size <= 250);
return '<https://s2.coinmarketcap.com/static/img/coins/${size}x$size/$id.png>';
}
Json toJson() => _$StockItemToJson(this);
}Поле id появилось как необходимость для отображения логотипов валют. Так как исходный ресурс предоставляет их как раз по id.
И еще одна сущность, описывающая цены криптовалюты в валюте обычной:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import '../../../service/types/types.dart';
part 'item_prices.g.dart';
@immutable
@JsonSerializable()
class ItemPrices {
const ItemPrices({
required this.price,
required this.diff1h,
required this.diff24h,
});
factory ItemPrices.fromJson(Json json) => _$ItemPricesFromJson(json);
final double price;
@JsonKey(name: 'percent_change_1h')
final double diff1h;
@JsonKey(name: 'percent_change_24h')
final double diff24h;
Json toJson() => _$ItemPricesToJson(this);
}Для сериализации / десериализации моделей я использовал json_serializable. Осталось только загрузить данные. Тут нам на помощь приходит кодогенерация в лице retrofit. Благодаря данному решению мы можем избавиться от необходимости написания хоть какой-то части бойлерплейта (но не всей). Сетевую логику, связанную с получением списка крипты разместим в классе CryptoProvider.
import 'package:dio/dio.dart';
import 'package:high_low/domain/crypto/dto/stock_response.dart';
import 'package:retrofit/http.dart';
part 'crypto_provider.g.dart';
@RestApi(baseUrl: '<https://pro-api.coinmarketcap.com/v1/>')
abstract class CryptoProvider {
factory CryptoProvider(Dio dio, {String? baseUrl}) = _CryptoProvider;
@GET('cryptocurrency/listings/latest')
Future<StockResponse> fetchLatestData({
@Header('X-CMC_PRO_API_KEY') required String token,
@Query('limit') int limit = 1000,
});
}
Конечно же, в DI-регистратор была добавлена фабрика CryptoProvider и Dio:
import 'package:dio/dio.dart';
import 'package:flutter/widgets.dart';
import '../../domain/crypto/logic/crypto_provider.dart';
import '../routing/default_router_information_parser.dart';
import '../routing/page_builder.dart';
import '../routing/root_router_delegate.dart';
import 'di.dart';
void initDependencies() {
Di.reg<BackButtonDispatcher>(() => RootBackButtonDispatcher());
Di.reg<RouteInformationParser<Object>>(() => DefaultRouterInformationParser());
Di.reg<RouterDelegate<Object>>(() => RootRouterDelegate());
Di.reg(() => PageBuilder());
Di.reg(() => Dio(), asBuilder: true); // <--
Di.reg(() => CryptoProvider(Di.get()), asBuilder: true); // <--
}На данном этапе у нас получается следующая структура проекта (внутренности service пока опускаю):
|-- domain
| `-- crypto
| |-- dto
| | |-- item_prices.dart
| | |-- stock_item.dart
| | |-- stock_item_example.json
| | `-- stock_response.dart
| `-- logic
| `-- crypto_provider.dart
|-- high_low_app.dart
|-- main.dart
`-- service
|-- config
|-- di
|-- logs
|-- routing
|-- theme
|-- tools
|-- typesЕсли вы задались вопросом, как получить такую картинку директории, вот ответ. Ну и на данном этапе работа с сетью завершена, все что нужно для отображения главного экрана у нас уже есть.
State
Вот мы и подбираемся к UI с логикой. Давайте начнем с последней, так как иначе она все равно заспойлерится в интерфейсе.
Но, прежде чем начать описывать состояние нашего приложения, нужно сделать большое лирическое отступление. Для тех, кто занимается разработкой приложений на Flutter не секрет, что Dart - однопоточный язык с возможностью запуска нескольких, так называемых Isolate - изолированных потоков со своим собственным Event Loop и памятью. И обычно, большинство разработчиков пишет весь код “просто в одном потоке”. То есть не заморачивается с тем, чтобы выносить тяжелые операции, потенциально блокирующие UI в отдельные изоляты (но я никого не виню, стандартное API весьма громоздкое, compute() не то, чтобы спасал, а различные сторонние библиотеки...ну кому они нужны?, изоляты - сложно ведь). Со временем могут происходить неприятные изменения в приложении или данных, прилетающих с бэка, становится все больше и все начинает лагать. Из-за чего? Давайте проведем небольшое исследование.
Исследование
Я провел 3 эксперимента по 5 раз для двух окружений. Первое окружение: profile-сборка на флагманском устройстве (Samsung Galaxy Note 20 Ultra), находящемся в режиме “обычное использование” - то есть я не перезагружал телефон перед каждым прогоном, но каждый раз выгружал из памяти приложение, а других активно запущенных приложений не было. Второе окружение: определенного рода симуляция слабого устройства, которое у пользователя вашего приложения тоже может оказаться - это эмулятор со следующими настройками:
2048Mb RAM
256Mb VM Heap
4 Cores CPU
Сам эмулятор был запущен на ноутбуке с Ryzen 7 5800H, никаких фоновых задач нет (только открытая IDEA).
Теперь к сути испытаний - для главного экрана необходимо загрузить данные о криптовалютах. Я загружал их по 100, 1000 и 5000 штук за один запрос. По окончанию запроса измерял время, требуемое на преобразование ответа сервера (массив байт) в сырую JSON-строку, которая, затем, десереализуется в Map<String, dynamic>, все это - подкапотная логика Dio, в которую я добавил только логирование времени. Вторая операция, подвергнутая анализу - уже преобразование мапки в бизнес-классы, с которыми в реальном приложении мы и работаем.
Для того, чтобы внедрить логирование в Dio пришлось изрядно покопаться в его внутренних органах: все указанные преобразования происходят посредством класса Transformer. Данный класс можно написать самому и скормить Dio, а можно ничего и не делать - тогда будет использоваться DefaultTransformer. Приведу тот кусок стандартного трансформера, который отвечает за то, чтобы вы смогли получить мапку на выходе (справа от каждой добавленной строки есть комментарий с префиксом <--, в котором описано, что тут происходит):
Future transformResponse(
RequestOptions options, ResponseBody response) async {
if (options.responseType == ResponseType.stream) {
return response;
}
var length = 0;
var received = 0;
var showDownloadProgress = options.onReceiveProgress != null;
if (showDownloadProgress) {
length = int.parse(
response.headers[Headers.contentLengthHeader]?.first ?? '-1');
}
var completer = Completer();
var stream =
response.stream.transform<Uint8List>(StreamTransformer.fromHandlers(
handleData: (data, sink) {
sink.add(data);
if (showDownloadProgress) {
received += data.length;
options.onReceiveProgress?.call(received, length);
}
},
));
// let's keep references to the data chunks and concatenate them later
final chunks = <Uint8List>[];
var finalSize = 0;
int totalDuration = 0; // <-- Total computation time in microseconds
int networkTime = 0; // <-- Time (microseconds), which will spend to accumulate parts of network response
StreamSubscription subscription = stream.listen(
(chunk) {
final start = DateTime.now().microsecondsSinceEpoch; // <-- Before saving each part of the data we start tracking the current time
finalSize += chunk.length;
chunks.add(chunk);
final now = DateTime.now().microsecondsSinceEpoch; // <--
totalDuration += now - start; // <-- After the chunk of data was saved, we check spent time
networkTime += now - start; // <--
},
onError: (Object error, StackTrace stackTrace) {
completer.completeError(error, stackTrace);
},
onDone: () => completer.complete(),
cancelOnError: true,
);
// ignore: unawaited_futures
options.cancelToken?.whenCancel.then((_) {
return subscription.cancel();
});
if (options.receiveTimeout > 0) {
try {
await completer.future
.timeout(Duration(milliseconds: options.receiveTimeout));
} on TimeoutException {
await subscription.cancel();
throw DioError(
requestOptions: options,
error: 'Receiving data timeout[${options.receiveTimeout}ms]',
type: DioErrorType.receiveTimeout,
);
}
} else {
await completer.future;
}
final start = DateTime.now().microsecondsSinceEpoch; // <-- Here we start tracking time before all chunks will be joined into the one Uint8List
final responseBytes = Uint8List(finalSize);
var chunkOffset = 0;
for (var chunk in chunks) {
responseBytes.setAll(chunkOffset, chunk);
chunkOffset += chunk.length;
}
totalDuration += DateTime.now().microsecondsSinceEpoch - start; // <-- And adding the new portion of time
if (options.responseType == ResponseType.bytes) return responseBytes;
String? responseBody;
if (options.responseDecoder != null) {
responseBody = options.responseDecoder!(
responseBytes,
options,
response..stream = Stream.empty(),
);
} else {
final start = DateTime.now().microsecondsSinceEpoch; // <-- We also tracked the decoding of the bytes into the string (raw JSON)
responseBody = utf8.decode(responseBytes, allowMalformed: true);
totalDuration += DateTime.now().microsecondsSinceEpoch - start; // <--
}
if (responseBody.isNotEmpty &&
options.responseType == ResponseType.json &&
_isJsonMime(response.headers[Headers.contentTypeHeader]?.first)) {
final callback = jsonDecodeCallback;
if (callback != null) {
return callback(responseBody);
} else {
final start = DateTime.now().microsecondsSinceEpoch; // <-- And finally - we track the decoding of the raw JSON string into the Map<String, dynamic>
final result = json.decode(responseBody);
totalDuration += DateTime.now().microsecondsSinceEpoch - start; // <--
print('TOTAL PARSING TIME: ${totalDuration / 1000}ms; NETWORK TIME: ${networkTime / 1000}ms'); // <--
return result;
}
}
return responseBody;
}
Ну и второй герой нашего времени - операция преобразования мапки в бизнес-сущности (для этого мы вклиниваем логирование в сгенерированный retrofit класс, в котором и описана вся логика получения данных):
Future<StockResponse> fetchLatestData({required token, limit = 1000}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{r'limit': limit};
final _headers = <String, dynamic>{r'X-CMC_PRO_API_KEY': token};
_headers.removeWhere((k, v) => v == null);
final _data = <String, dynamic>{};
final _result = await _dio.fetch<Map<String, dynamic>>(_setStreamType<StockResponse>(Options(method: 'GET', headers: _headers, extra: _extra)
.compose(_dio.options, 'cryptocurrency/listings/latest', queryParameters: queryParameters, data: _data)
.copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));
bench.start('STOCK RESPONSE DESERIALIZING'); // <-- At here we used the simple performance-tracker
final value = StockResponse.fromJson(_result.data!);
bench.end('STOCK RESPONSE DESERIALIZING'); // <--
return value;
}
Также стоит показать и код самого performance-tracker, используемого выше:
class _Benchmark {
final Map<String, int> _starts = <String, int>{};
void start(dynamic id) {
final String benchId = id.toString();
if (_starts.containsKey(benchId)) {
Logs.warn('Benchmark already have comparing with id=$benchId in time');
} else {
_starts[benchId] = DateTime.now().microsecondsSinceEpoch;
}
}
double end(dynamic id) {
final String benchId = id.toString();
if (!_starts.containsKey(benchId)) {
throw Exception('In Benchmark not placed comparing with id=$benchId');
}
final double diff = (DateTime.now().microsecondsSinceEpoch - _starts[benchId]!) / 1000;
final String info = '$benchId need ${diff}ms';
print(info);
_starts.remove(benchId);
return diff;
}
}
final _Benchmark bench = _Benchmark();
Как говорил кто-то там:
Лучше показать таблицу с данными, чем ходить вокруг да около
Поэтому, вот таблица, с дополнительной аннотацией полей:
Count - количество элементов криптовалют, загружаемых за один запрос (да, да, в мире есть, как минимум, 5000 видов крипты)
Rows - количество строк в JSON (если сделать Beautify в Postman)
Size - размер данных в килобайтах
[P] / [D] - префикс окружения, Profile / Debug (описано выше)
JSON - время в милисекундах, потраченное непосредственно на то, чтобы Dio вернул нам мапку
Entity - время в милисекундах, потраченное на то, чтобы преобразовать мапку в бизнес-сущности
Total - сумма JSON + Entity
kB / ms - метрика, означающая, “сколько килобайт можно преобразовать за одну милисекунду”

А вот мои выводы из этой таблицы:
В лучшем случае, если у пользователя устройство верхнего ценового сегмента - мы можем рассчитывать на то, что оно будет способно обработать до ~18kB/ms (возможно, самые новые флагманы будут способны и на большее)
Ремарка про худший случай - так как [D] окружение было запущено на эмуляторе с JIT-компиляцией, то мы имеем некоторые негативные экстремумы, связанные с тем, что код еще не разогрелся. Это отчетливо видно на объеме данных в 100 единиц - было потрачено чрезвычайно много времени, выбивающееся из статистики. Поэтому я не буду брать значение в 2.629kB/ms как минимальное, а возьму 8.603kB/ms, как более близкое к реальности. Делаем вывод - мы можем рассчитывать на то, что устройство пользователя сможет обработать хотя бы ~9kB/ms
Будем исходить из того, что все большее количество девайсов обладает экранами с частотой обновления 120FPS, это значит, что у нас есть всего 8ms для отрисовки одного кадра, из этих 8ms какое-то время занимает сам процесс рендеринга, примерно, в среднем, это будет 2ms. Итого - у нас осталось 6ms, чтобы сделать что-то и не потерять кадр. А это значит, что мы можем рассчитывать на то, что пользовательское устройство сможет обработать запрос с размером ответа в (18 + 9) / 2 * (8 - 2) = 81kB, чтобы не потерять ни одного кадра (это в идеале, если нет других негативных факторов). Если дисплей с 60FPS, то (18 + 9) / 2 * (16 - 2) = 189kB
Что с этой информацией делать? Ну, например, мы можем сделать вывод, что если попытаться разобрать JSON в 1mb в главном потоке приложения, то мы гарантированно получим лаг в 80-160ms, и это уже будет бросаться в глаза пользователю. Если у вас много запросов с жирными ответами - интерфейс будет лагать намного чаще. Как с этим можно бороться, я уже однажды рассказывал. И пора продолжить этот старый рассказ.
Isolate
С недавним релизом Dart 2.15 произошли позитивные изменения в возможностях использования изолятов. Главным новшеством стал новый метод Isolate.exit(), который позволяет завершить текущий сторонний изолят, передавая в SendPort данные, которые прилетят в соответствующий ReceivePort за константное время. При этом, глубокого копирования, которое происходило раньше, до появления данного метода - не происходит, а значит - мы не заблочим наш UI-поток, когда он будет получать большую порцию данных одномоментно из стороннего изолята. Все это доступно “из коробки” посредством старой доброй функции compute(). С её помощью можно выносить вычисления, произодимые в отдельных функциях в сторонний изолят и быстро получать результаты обратно.
Относительно простым решением будет создание своего Transformer, который будет парсить ответы в стороннем изоляте и возвращать результат.
Но, как говорилось в первой статье - я хочу показать еще и использование своих библиотек, а не только этапы создания приложения и так уж вышло, что у меня есть библиотека isolator, созданная для упрощения работы с изолятами и позволяющая вынести вообще всю логику в сторонние Stateful изоляты. Эти сторонние изоляты, в контексте библиотеки, носят название Backend. И к ним в нагрузку идут легковесные реактивные компаньоны, называемые Frontend - это может быть любой класс из любого менеджера управления состоянием - Bloc, Mobx, ChangeNotifier и тд. К этому классу добавляется mixin Frontend и вы получаете возможность общения с соответсвующим Backend. До выхода Dart 2.15 эта библиотека решала одну узкую, но фундаментальную проблему (чтобы её не пришлось решать самостоятельно) - возможность передачи данных неограниченного объема из стороннего изолята в главный без блокировки последнего. С появлением метода Isolate.exit() эта проблема, кажется, ушла сама собой, поэтому теперь данная библиотека просто позволяет не нагружать основной поток ничем, кроме отрисовки UI (впрочем, как и раньше).
В данный момент на pub.dev доступна первая версия, но при этом все основные работы по написанию v2 завершены, но пока не опубликованы, поэтому если вы захотите попробовать - можно установить из git:
isolator:
git:
url: <https://github.com/alphamikle/isolator.git>
ref: next
Среди прочих нововведений второй версии присутствует возможность прозрачного использования этого же кода в вебе (но пока еще в разработке). Isolate API не имеет поддержки в вебе, как таковой, однако, при использовании isolator весь код будет работать как и обычно, но в главном потоке.
Frontend
Для начала приложу весь код, а затем буду разбирать каждый из его блоков по отдельности:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:isolator/isolator.dart';
import 'package:isolator/next/maybe.dart';
import '../../../service/di/di.dart';
import '../../../service/di/registrations.dart';
import '../../../service/tools/localization_wrapper.dart';
import '../../crypto/dto/stock_item.dart';
import '../../notification/logic/notification_service.dart';
import 'main_backend.dart';
enum MainEvent {
init,
loadStocks,
startLoadingStocks,
endLoadingStocks,
filterStocks,
updateFilteredStocks,
}
class MainFrontend with Frontend, ChangeNotifier {
late final NotificationService _notificationService;
late final LocalizationWrapper _localizationWrapper;
final List<StockItem> stocks = [];
bool isLaunching = true;
bool isStocksLoading = false;
bool errorOnLoadingStocks = false;
TextEditingController searchController = TextEditingController();
TextEditingController tokenController = TextEditingController();
bool _isInLaunchProcess = false;
bool _isLaunched = false;
String _prevSearch = '';
Future<void> loadStocks() async {
errorOnLoadingStocks = false;
final Maybe<StockItem> stocks = await run(event: MainEvent.loadStocks);
if (stocks.hasList) {
_update(() {
this.stocks.clear();
this.stocks.addAll(stocks.list);
});
}
if (stocks.hasError) {
_update(() {
errorOnLoadingStocks = true;
});
await _notificationService.showSnackBar(content: _localizationWrapper.loc.main.errors.loadingError);
}
}
Future<void> launch({
required NotificationService notificationService,
required LocalizationWrapper localizationWrapper,
}) async {
if (!isLaunching || _isLaunched || _isInLaunchProcess) {
return;
}
_notificationService = notificationService;
_localizationWrapper = localizationWrapper;
_isInLaunchProcess = true;
searchController.addListener(_filterStocks);
await initBackend(initializer: _launch);
_isInLaunchProcess = false;
_isLaunched = true;
_update(() => isLaunching = false);
}
void _filterStocks() {
if (_prevSearch != searchController.text) {
_prevSearch = searchController.text;
run(event: MainEvent.filterStocks, data: searchController.text);
}
}
void _setFilteredStocks({required MainEvent event, required List<StockItem> data}) {
_update(() {
stocks.clear();
stocks.addAll(data);
});
}
void _startLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = true;
});
}
void _endLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = false;
});
}
void _update(VoidCallback dataChanger) {
dataChanger();
notifyListeners();
}
static MainBackend _launch(BackendArgument<void> argument) {
initDependencies();
return MainBackend(argument: argument, cryptoProvider: Di.get());
}
@override
void initActions() {
whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);
whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);
whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);
}
}
Логика работы библиотеки, отчасти, похожа на Bloc - необходимо зарегистрировать обработчики сообщений, прилетающих с Backend. Регистрируются они в методе initActions:
@override
void initActions() {
whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);
whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);
whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);
}
В качестве идентификатора события выступает любая сущность, но важно то, что проверка на соответствие будет происходить через обычное равенство ==. Также, можно зарегистрировать обработчик на определенный тип идентификаторов, в этом случае он будет обрабатывать все события, идентифицируемые конкретно этим типом:
class SpecificMessageId {
const SpecificMessageId(this.someValue);
final int someValue;
}
void initActions() {
whenEventCome<SpecificMessageId>().run(_specificHandler);
}
Стоит добавить несколько слов и о самих обработчиках. Все обработчики должны соответствовать следующему типу (не соответствующие не получится зарегистрировать):
typedef FrontendAction<Event, Req, Res> =
FutureOr<Res> Function({required Event event, required Req data});Но, при этом, значение data не обязательно должно прилетать. Идентификатор-событие event будет прилетать всегда. То есть, следующие обработчики зарегистрируются и будут корректными:
void _startLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = true;
});
}
void _endLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = false;
});
}
Смысл обработчиков заключается в том, что если вы ходите только реагировать на события, инициированные Backend - нужен обработчик. Если же вы хотите вызвать какой-то метод Backend - можно обойтись и без обработчиков вовсе.
При вызове любого Backend-метода из Frontend вы всегда получите какой-нибудь ответ “на месте”, завернутый в своеобразный union-type Maybe<T>. Union-типов в Dart на данный момент нет, кроме одного встроенного FutureOr<T>, поэтому, для корректной типизации данных методов пришлось создавать Maybe<T>, он может включать в себя просто T, List<T> или ошибку, ну или вообще все три - null, если метод Backend не возвращает ничего (но, на самом деле, Backend-методы всегда должны возвращать кое-что, что вы увидите немного ниже).
Следующий код демонстрирует вызов MainBackend метода по event = MainEvent.loadStocks и получение результата сразу в месте вызова:
Future<void> loadStocks() async {
errorOnLoadingStocks = false;
final Maybe<StockItem> stocks = await run(event: MainEvent.loadStocks);
if (stocks.hasList) {
_update(() {
this.stocks.clear();
this.stocks.addAll(stocks.list);
});
}
if (stocks.hasError) {
_update(() {
errorOnLoadingStocks = true;
});
await _notificationService.showSnackBar(content:
_localizationWrapper.loc.main.errors.loadingError);
}
}
Немного забегая наперед покажу и соответствующий этому event метод MainBackend, который и будет исполнен в стороннем изоляте:
Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {
await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
try {
final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
_stocks.clear();
_stocks.addAll(stockItems);
} catch (error) {
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
rethrow;
}
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
return ActionResponse.list(_stocks);
}
Пока не буду описывать его содержимое, об этом будет ниже.
Следующий метод launch нужен для инициализации MainFrontend и MainBackend. В нем вызывается метод initBackend миксина Frontend, в который необходимо передать, как минимум, один аргумент: функцию-инициализатор, которая запустится уже в стороннем изоляте, и эта функция должна возвращать инстанс соответствующего Backend.
Future<void> launch({
required NotificationService notificationService,
required LocalizationWrapper localizationWrapper,
}) async {
if (!isLaunching || _isLaunched || _isInLaunchProcess) {
return;
}
_notificationService = notificationService;
_localizationWrapper = localizationWrapper;
_isInLaunchProcess = true;
searchController.addListener(_filterStocks);
await initBackend(initializer: _launch);
_isInLaunchProcess = false;
_isLaunched = true;
_update(() => isLaunching = false);
}
Давайте взглянем на нее поближе:
static MainBackend _launch(BackendArgument<void> argument) {
initDependencies();
return MainBackend(argument: argument, CryptoProvider: Di.get());
}
В этой функции нам необходимо повторно инициализировать Di-контейнер, так как сторонний изолят не знает ничего о том, что происходило в главном и все фабрики в стороннем изоляте не зарегистрированы. Требования к функции-инициализатору аналогичны требованиям к оригинальной функции entryPoint, используемой в Isolate API. А вот её интерфейс:
typedef BackendInitializer<T, B extends Backend> =
B Function(BackendArgument<T> argument);
Также, Frontend позволяет регистрировать хуки, вызываемые на каждое сообщение от Backend, только на сообщения, которые должны принудительно заставить Frontend уведомить UI об изменении данных; можно подписаться (например одному Frontend на другой), посредством метода subscribeOnEvent. Об этом будет сказано немного подробнее в блоке про UI.
Backend
Я начну с метода Frontend, который вызывается для получения данных о крипте. При первичной отрисовке главного экрана в хуке initState виджета MainView происходит инициализация MainFrontend (см. метод MainFrontend.launch). По завершению которой вызывается метод loadStocks (который был разобран выше):
// main_view.dart
Future<void> _launchMainFrontend() async {
final MainFrontend mainFrontend = Provider.of(context, listen: false);
await mainFrontend.launch(notificationService: Provider.of(context, listen: false), localizationWrapper: Provider.of(context, listen: false));
await mainFrontend.loadStocks();
}
@override
void initState() {
super.initState();
_launchMainFrontend();
// ...
}
Выше уже отсветил один из методов MainBackend, что-же, вот теперь пора представить и сам класс, который будет существовать в отдельном изоляте на протяжении жизни всего приложения:
import 'dart:async';
import '../../crypto/logic/crypto_provider.dart';
import 'package:isolator/isolator.dart';
import '../../crypto/dto/stock_item.dart';
import 'main_frontend.dart';
typedef StockItemFilter = bool Function(StockItem);
class MainBackend extends Backend {
MainBackend({
required BackendArgument<void> argument,
required CryptoProvider cryptoProvider,
}) : _cryptoProvider = cryptoProvider,
super(argument: argument);
final CryptoProvider _cryptoProvider;
final List<StockItem> _stocks = [];
Timer? _searchTimer;
Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {
await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
try {
final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
_stocks.clear();
_stocks.addAll(stockItems);
} catch (error) {
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
rethrow;
}
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
return ActionResponse.list(_stocks);
}
ActionResponse<StockItem> _filterStocks({required MainEvent event, required String data}) {
final String searchSubString = data;
send(event: MainEvent.startLoadingStocks);
_searchTimer?.cancel();
_searchTimer = Timer(const Duration(milliseconds: 500), () async {
_searchTimer = null;
final List<StockItem> filteredStocks = _stocks.where(_stockFilterPredicate(searchSubString)).toList();
await send(
event: MainEvent.updateFilteredStocks,
data: ActionResponse.list(filteredStocks),
);
await send(event: MainEvent.endLoadingStocks);
});
return ActionResponse.empty();
}
StockItemFilter _stockFilterPredicate(String searchSubString) {
final RegExp filterRegExp = RegExp(searchSubString, caseSensitive: false, unicode: true);
return (StockItem item) {
if (searchSubString.isEmpty) {
return true;
}
return filterRegExp.hasMatch(item.symbol) || filterRegExp.hasMatch(item.name);
};
}
@override
void initActions() {
whenEventCome(MainEvent.loadStocks).run(_loadStocks);
whenEventCome(MainEvent.filterStocks).run(_filterStocks);
}
}
По аналогии с Frontend в любом Backend есть возможность регистрации обработчиков событий с тем же самым API, но небольшим отличием в типе обработчика:
typedef BackendAction<Event, Req, Res> = FutureOr<ActionResponse<Res>> Function({required Event event, required Req data});
Отличие заключается в том, что если Frontend обработчик может не возвращать ничего, то Backend обработчик обязан возвращать результат вида ActionResponse<T>, либо падать с ошибкой. Это является следствием определенных ограничений при работе с типами в Dart.
Также, обработчик является выходной точкой любого Backend, каждый из которых может вызывать обработчики любого другого Backend, делается это посредством специальных сущностей Interactor. Вот и вот небольшой пример.
Теперь разберем подробнее метод получения криптовалют. Перед началом загрузки мы посылаем сообщение в MainFrontend, чтобы отобразить в интерфейсе, что идет процесс загрузки.
await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
Затем, происходит сама загрузка данных и их сохранение в MainBackend для возможности локального поиска.
final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
_stocks.clear();
_stocks.addAll(stockItems);
Теперь начинается кое-что интересное, что стало возможным с выходом Dart 2.15. Упомянутая выше возможность библиотеки передавать любой объем данных без просадки кадров достигается (раньше достигалась) посредством разбиения массива данных на чанки и передачей этих чанков во Frontend по очереди. Логика тут была простая, если данных много - их можно так или иначе представить в виде массива, а его можно без проблем разбить на маленькие куски и передать без проблем с производительностью. Собственно, эта старая логика отображена передачей данных, завернутых в специальный wrapper Chunks:
await send(
event: MainEvent.loadStocks,
data: ActionResponse.chunks(
Chunks(
data: _stocks,
updateAfterFirstChunk: true,
size: 100,
delay: const Duration(milliseconds: 8),
),
),
);
При этом сборка чанков во Frontend происходила “магически-автоматически”, и обработчик, который ожидал получения большой пачки данных - просто получал свой готовый огромный массив. Все эти возможности придется выпилить, так как особого смысла от них теперь нет.
С приходом новой версии Dart стало возможным передавать любой объем данных любого типа за константное время и без ограничений по типу передаваемых данных - теперь можно без проблем передавать не только массивы, но и любую другую структуру, если это необходимо. Сейчас достаточно использовать обычный метод отправки сообщений, который будет использовать под капотом пресловутый Isolate.exit:
await send(
event: MainEvent.loadStocks,
data: ActionResponse.list(_stocks),
);
При этом, как говорит документация, возможность быстрой передачи данных доступна только при уничтожении отправляющего изолята. А так как наш MainBackend (да и любой другой Backend) - стремится жить на протяжении существования всего приложения (по крайней мере такова их задумка, но их и без проблем можно закрывать, но, всё-таки, не таким способом), то использовать Isolate.exit напрямую в этом изоляте нельзя - он, по большому счету, завершится аварийно. Чтобы обойти это недоразумение наш Backend создает дополнительный транспортный изолят, в который классическим способом (глубоким копированием средствами Dart VM) передается любое количество данных, никак не влияющее на UI-изолят, а затем этот одноразовый транспортный изолят уничтожается, передавая при этом, данные в наш UI-изолят.
Вернемся к разбору нашего метода загрузки крипты. Так как мы организуем “синхронный” вызов Backend-метода из Frontend, то наш Backend-метод должен вернуть этот результат:
return ActionResponse.list(_stocks);Также, при отправке события начала загрузки данных был указан дополнительный параметр sendDirectly, думаю, самое время описать и его - так как мы не всегда передаём большое количество данных из Backend во Frontend, то и не всегда нужно пользоваться услугами транспортного изолята - можно передавать данные напрямую. Если это необходимо - использование данного параметра позволит отправлять сообщения без сторонней помощи.
Локальный поиск
Более подробно останавливаться на методе локального поиска останавливаться не буду, так как, кажется, статья уже стала лонгридом
