Привет! Это вторая статья из цикла о разработке приложения на 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
, то и не всегда нужно пользоваться услугами транспортного изолята - можно передавать данные напрямую. Если это необходимо - использование данного параметра позволит отправлять сообщения без сторонней помощи.
Локальный поиск
Более подробно останавливаться на методе локального поиска останавливаться не буду, так как, кажется, статья уже стала лонгридом 🙂. Работает он как поиск по регулярному выражению. Могу добавить только то, что вы можете получить ответ на главный вопрос вселенной с его помощью и даже немного больше.
UI
После завершения данного этапа структура домена main станет такой:
|-- domain
| `-- main
| |-- logic
| | |-- main_backend.dart
| | `-- main_frontend.dart
| `-- ui
| |-- main_header.dart
| |-- main_view.dart
| `-- stock_item_tile.dart
|-- high_low_app.dart
`-- main.dart
Опишем содержимое папочки ui:
main_view.dart
содержит StatefulWidget
главного экрана
import 'package:flutter/material.dart';
import 'package:isolator/next/frontend/frontend_event_subscription.dart';
import 'package:provider/provider.dart';
import 'package:yalo_assets/lib.dart';
import 'package:yalo_locale/lib.dart';
import '../../../service/theme/app_theme.dart';
import '../../../service/tools/utils.dart';
import '../../crypto/dto/stock_item.dart';
import '../../notification/logic/notification_service.dart';
import '../logic/main_frontend.dart';
import 'main_header.dart';
import 'stock_item_tile.dart';
class MainView extends StatefulWidget {
const MainView({Key? key}) : super(key: key);
@override
_MainViewState createState() => _MainViewState();
}
class _MainViewState extends State<MainView> {
MainFrontend get _mainFrontend => Provider.of(context);
late final FrontendEventSubscription<MainEvent> _eventSubscription;
Widget _stockItemBuilder(BuildContext context, int index) {
final StockItem item = _mainFrontend.stocks[index];
final bool isFirst = index == 0;
final bool isLast = index == _mainFrontend.stocks.length - 1;
return Padding(
padding: EdgeInsets.only(
left: 8,
top: isFirst ? 8 : 0,
right: 8,
bottom: isLast ? MediaQuery.of(context).padding.bottom + 8 : 8,
),
child: StockItemTile(item: item),
);
}
void _onSearchEnd(MainEvent event) {
final MainFrontend mainFrontend = Provider.of<MainFrontend>(context, listen: false);
final LocalizationMessages loc = Messages.of(context);
final int stocksCount = mainFrontend.stocks.length;
final String content = loc.main.search.result(stocksCount);
Provider.of<NotificationService>(context, listen: false).showSnackBar(
content: content,
backgroundColor: AppTheme.of(context, listen: false).okColor,
);
}
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();
_eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(
listener: _onSearchEnd,
event: MainEvent.updateFilteredStocks,
onEveryEvent: true,
);
}
@override
void dispose() {
_eventSubscription.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Assets assets = Provider.of<Assets>(context, listen: false);
final AppTheme theme = AppTheme.of(context);
final MaterialStateProperty<Color> buttonColor = MaterialStateProperty.resolveWith((states) => theme.buttonColor);
final ButtonStyle buttonStyle = ButtonStyle(
foregroundColor: buttonColor,
overlayColor: MaterialStateProperty.resolveWith((states) => theme.splashColor),
shadowColor: buttonColor,
);
final List<String> notFoundImages = [
assets.notFound1,
assets.notFound2,
assets.notFound3,
assets.notFound4,
].map((e) => e.replaceFirst('assets/', '')).toList();
Widget body;
if (_mainFrontend.isLaunching) {
body = Center(
child: Text(Messages.of(context).main.loading),
);
} else if (_mainFrontend.errorOnLoadingStocks) {
body = Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Image.asset(notFoundImages[Utils.randomIntBetween(0, notFoundImages.length - 1)]),
),
TextButton(
onPressed: _mainFrontend.loadStocks,
style: buttonStyle,
child: Text(Messages.of(context).main.repeat),
),
],
),
),
);
} else {
body = CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
const MainHeader(),
SliverList(
delegate: SliverChildBuilderDelegate(
_stockItemBuilder,
childCount: _mainFrontend.stocks.length,
),
),
],
);
}
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: body,
),
);
}
}
Что есть интересного здесь? Инициализацию MainFrontend
уже обсудили, остался только подписчик на события. Кстати, вот он:
_eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(
listener: _onSearchEnd,
event: MainEvent.updateFilteredStocks,
onEveryEvent: true,
);
Вызов данного метода позволяет уведомляться в том, что наш MainFrontend
получил сообщение соответствующего типа от MainBackend
. Метод subscribeOnEvent
является частью Frontend
в принципе.
В результате мы получаем такие уведомления, каждый раз, когда нам прилетает порция данных после поиска:
И это - является подводкой к теме локализации приложений на Flutter.
Локализация интерфейса
Уже довольно давно я задавался вопросом - как можно быстро локализовать приложение на Flutter. Если взглянуть на официальный гайд - то первое впечатление “без бутылки не разберешься”. Второе, собственно - тоже. И тогда я подумал, что если избавиться от громоздкого .arb
, и вместо него использовать .yaml
? Так родился пакет assets_codegen (ссылку я не прикладываю, так как он deprecated). Его идея была в следующем - располагаем файлы локализации в ассетах, аннотируем какой-нибудь класс, чтобы к нему цеплялся код локализации, запускаем flutter pub run build_runner watch
и наслаждаемся. Решение было более чем работоспособным, но имелись и минусы - логика отслеживания изменений в файлах локализации была написана руками, а котогенерация Dart не позволяет отслеживать изменения не в Dart-файлах, и результат совмещения стандартного кодогенератора и рукописного вотчера иной раз удручал. В общем было много раздражающих багов. И вот однажды, уже имея некоторое понимание, как часто приходится добавлять новые строки локализации и сразу же после этого ожидать их появления в коде (спойлер - крайне редко), я решил написать полностью новый пакет, еще и название которого, родившееся в моей голове, очень мне понравилось.
Так появился пакет yalo. С предельно простой логикой (описанной в документации) - размещаем файлы локализации в ассетах, запускаем генератор командой
flutter pub run yalo:loc
, подключаем к проекту сгенерированный локальный пакет .yalo_locale
, используем пару переменных в корневой ...App
:
import 'package:flutter/material.dart';
import 'package:yalo_locale/lib.dart';
import 'service/di/di.dart';
class HighLowApp extends StatelessWidget {
const HighLowApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routeInformationParser: Di.get<RouteInformationParser<Object>>(),
routerDelegate: Di.get<RouterDelegate<Object>>(),
backButtonDispatcher: Di.get<BackButtonDispatcher>(),
theme: Theme.of(context).copyWith(brightness: Brightness.dark),
debugShowCheckedModeBanner: false,
localizationsDelegates: localizationsDelegates, // <-- 1
supportedLocales: supportedLocales, // <-- 2
onGenerateTitle: (BuildContext context) => Messages.of(context).common.appTitle,
);
}
}
И используем локализованный контент. С плюрализацией, префиксами, сколько угодно глубокой вложенностью и подстановкой (пока только для числовых данных в плюрализированных строках). Примеры использования вы уже могли заметить выше, но продемонстрирую их отдельно.
Генерация названия приложения:
(BuildContext context) => Messages.of(context).common.appTitle
Подсказка поля ввода поиска:
Messages.of(context).main.search.hint
Количество элементов после поиска в SnackBar
:
Messages.of(context).main.search.result(
Provider.of<MainFrontend>(context, listen: false).stocks.length)
Появляется это все из такого файлика:
main:
loading: Загрузка...
search:
hint: Поиск
result:
zero: Мы ничего не нашли
one: Мы нашли ${howMany} элемент
two: Мы нашли ${howMany} элемента
other: Мы нашли ${howMany} элементов
common:
currency: '\$'
percent: '%'
appTitle: High Low
Точнее, файликов, лежащих вот так:
|-- README.md
|-- analysis_options.yaml
|-- assets
| `-- i18
| |-- en_intl.yaml
| `-- ru_intl.yaml
`-- watch.sh
Но вместо префикса файла, можно раскладывать их по папкам - ../ru/intl.dart
Заключение
На этот раз статья поспела за кодом и все, что реализовано - тут описано. В третьей статье я сделаю полностью второй экран (учитывая графики и игровую механику, возможно третья часть выйдет во время новогодних праздников), покажу работу с ассетами здорового человека и implicit-анимацию любого текста.
И еще, приложу изменения, которые произошли со времени первой части. И, код текущего состояния проекта.
Особая секция
Как сцены после титров в Marvel - данная секция для особых зрителей читателей. Уже дописав данную статью я был практически готов её опубликовать. Но чувство перфекционизма старательно откусывало от меня кусочки - на момент “готовности” статьи isolator не был доработан настолько, чтобы было можно использовать его и в web. И ещё мне хотелось показать не только картинки приложения, но и дать возможность его “потыкать”. И вот я за пару вечеров добавил возможность работы в web (как и прежде - без многопоточности, но с сохранением полной работоспособности без изменений в вашем коде). Затем встал вопрос о публикации приложения. Публиковать в сторах я планирую в самом конце, а пока можно было бы сделать это на github.pages. Тут-то и начинается самое интересное.
Запустил web-версию локально, все отлично работает, за исключением одного NO! - API сервиса, который я начал использовать изначально, не позволяет осуществлять CORS-запросы, “чтобы не палить ваши токены авторизации”, видимо, про реверс API приложений они не слышали. Ну да ладно. Я начал искать способы, как можно обойти это ограничение без необходимости пилить свой собственный proxy, хостить его где-то и т.д. Нашел curl-online, сделал запрос через него (через интерфейс самого сервиса) - все заработало. Сразу начал делать web-имплементацию CryptoProvider
, который бы использовался в web-сборке и ходил за данными через web-curl. И снова:
У меня локально все работает
Деплой на github.pages → и снова CORS, но уже у самого курла (почему я не додумался выполнить этот запрос из консоли браузера со страницы приложения на pages - очень большой вопроc). Время - час ночи, и я неунывающими красными глазами начинаю пялить в код пишушейся прокси для этого всего. Еще пол часа и глаза говорят “пора спать”. Проснувшись на следующий день, рано утром, я снова начал искать способы не писать прокси и, видимо, правду говорят - утро вечера мудренее, я додумываюсь поискать альтернативу самому API. И первый же запрос в гугл предоставляет мне [прекрасную](https://www.coingecko.com/en/api/documentation?), полностью бесплатную, без авторизаций (и с очень небольшими ограничениями), апишку.
С одной стороны - я безмерно рад тому, что не придется пилить никакие прокси, и также рад тому, что смогу показать вам как оно работает в вебе без всяких “но”, но с другой - если бы я сначала подумал, поискал, а не бросился пилить код, сэкономил бы часов 8 жизни...
В общем результаты таковы, что isolator v2 теперь полностью готов к использованию. Ну и вы можете взглянуть на web-версию того, что уже реализовано. У API есть ограничение на 50 вызовов в минуту, так что если сработает хабраэффект - вы увидите Экран ошибки, на котором будет достаточно нажать одну кнопку.
Ассеты
Если бы не особая секция, и все страдания, которые там описаны, этот раздел действительно был бы должен оказаться в третьей статье. Изначально я хотел показать работу с ними в этой, но во время написания статьи понял, что нет особых мест, кроме как придуманных исскуственно, где они были бы к месту. Затем, во время реализации логики, связанной с возможностью исчерпания лимита моего токена авторизации на первом ресурсе появилось место, где ассеты будут к месту. Идея была такова - если ресурс моего токена заканчивается, то при получении ошибки во время запроса отобразится дополнительный экран, где будет висеть какая-нибудь прикольная картинка, а также инпут для ввода вашего собственного токена авторизации, с которым бы у вас лично все заработало. После перехода на новое API логика по использованию вашего токена отпала сама собой, но, потенциально, осталась возможность наткнуться на ошибку из-за лимитов API по RPS. Поэтому, если вы увидите данный экран - то хабраэффект сработал.
А теперь к самой работе с ассетами! Упомянутый выше пакет yalo, позволяет не только генерировать локализацию из .yaml
файлов, но также, он позволяет генерировать код с именами всех ассетов, лежащих в вашей папке assets
(или любой другой, если она корректно указана в pubspec.yaml
). Сейчас структура папки assets
данного проекта имеет следующий вид:
./assets
|-- i18
| |-- en_intl.yaml
| `-- ru_intl.yaml
`-- images
|-- notFound_1.png
|-- notFound_2.png
|-- notFound_3.png
`-- notFound_4.png
При условии, что у вас в проекте уже установлен данный пакет, вы можете запустить следующую команду:
flutter pub run yalo:asset
Результатом такой команды будет сгенерированный пакет .yalo_assets
в корне вашего проекта, который, по аналогии с .yalo_locale
нужно добавить в pubspec.yaml:
dependencies:
//...
yalo_locale:
path: ./.yalo_locale
yalo_assets:
path: ./.yalo_assets
После этих манипуляций вы получаете доступ к классу со статическими и обычными геттерами:
class Assets {
String get enIntl => enIntlS;
static const String enIntlS = 'assets/i18/en_intl.yaml';
String get ruIntl => ruIntlS;
static const String ruIntlS = 'assets/i18/ru_intl.yaml';
String get notFound1 => notFound1S;
static const String notFound1S = 'assets/images/notFound_1.png';
String get notFound2 => notFound2S;
static const String notFound2S = 'assets/images/notFound_2.png';
String get notFound3 => notFound3S;
static const String notFound3S = 'assets/images/notFound_3.png';
String get notFound4 => notFound4S;
static const String notFound4S = 'assets/images/notFound_4.png';
}
Я опустил некоторые дополнительные методы, имеющиеся в данном классе, так как особой востребованностью они не пользовались.
Чем это может быть полезно? Главный плюс - автодополнение. Дополнительный - у вас появляется возможность отслеживать ассеты на уровне кода. Если какой-либо файл будет удален или изменено его имя - код на это отреагирует и вы получите статическую ошибку, вместо отлова её в рантайме (если не уследили за этим). Разрешение коллизий имен ассетов (например два файла в одинаковым именем, лежащих в разных папках) тоже есть, и выглядит вот так:
class Assets {
String get enIntl => enIntlS;
static const String enIntlS = 'assets/i18/en_intl.yaml';
String get ruIntl => ruIntlS;
static const String ruIntlS = 'assets/i18/ru_intl.yaml';
String get notFound => notFoundS;
static const String notFoundS = 'assets/images/blabla/notFound.png';
String get notFound1 => notFound1S;
static const String notFound1S = 'assets/images/notFound_1.png';
String get notFound2 => notFound2S;
static const String notFound2S = 'assets/images/notFound_2.png';
String get notFound3 => notFound3S;
static const String notFound3S = 'assets/images/notFound_3.png';
String get notFound4 => notFound4S;
static const String notFound4S = 'assets/images/notFound_4.png';
String get notFoundCopy => notFoundCopyS;
static const String notFoundCopyS = 'assets/images/old_content/notFound.png';
String get notFoundCopyCopy => notFoundCopyCopyS;
static const String notFoundCopyCopyS = 'assets/images/something_else/notFound.png';
String get notFoundCopyCopyCopy => notFoundCopyCopyCopyS;
static const String notFoundCopyCopyCopyS = 'assets/images/very_important_content/notFound.png';
String get notFound3Copy => notFound3CopyS;
static const String notFound3CopyS = 'assets/images/very_important_content/notFound_3.png';
}
Окончательное заключение
Надо было что-то оставить на самый финал - на этом действительно все.