HTML Diff
0 added 0 removed
Original 2026-01-01
Modified 2026-03-10
1 <p>Это вторая статья из цикла о разработке приложения на Flutter (предыдущая находится<a>здесь</a>). В этом "номере" я опишу создание сетевого слоя, работу с локализацией, удобный способ работы с ассетами, локальный поиск и создание UI для одного из двух экранов приложения. Также я выведу интересные метрики, например -- сколько данных сможет распарсить ваше приложение за одну милисекунду и начиная с какого размера JSON’а, прилетевшего с бэка UI начнет тормозить. Как говорится -- с места... В карьер!</p>
1 <p>Это вторая статья из цикла о разработке приложения на Flutter (предыдущая находится<a>здесь</a>). В этом "номере" я опишу создание сетевого слоя, работу с локализацией, удобный способ работы с ассетами, локальный поиск и создание UI для одного из двух экранов приложения. Также я выведу интересные метрики, например -- сколько данных сможет распарсить ваше приложение за одну милисекунду и начиная с какого размера JSON’а, прилетевшего с бэка UI начнет тормозить. Как говорится -- с места... В карьер!</p>
2 <h2>Сеть</h2>
2 <h2>Сеть</h2>
3 <p>Для отрисовки первого экрана необходимы следующие данные:</p>
3 <p>Для отрисовки первого экрана необходимы следующие данные:</p>
4 image title subtitle price diff<p>Исходя из этого получаем следующую сущность, описывающую каждый из токенов:</p>
4 image title subtitle price diff<p>Исходя из этого получаем следующую сущность, описывающую каждый из токенов:</p>
5 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) =&gt; _$StockItemFromJson(json); final int id; final String name; final CryptoSymbol symbol; @JsonKey(name: 'quote') final Map&lt;CryptoSymbol, ItemPrices&gt; prices; ItemPrices get usdPrices =&gt; prices['USD']!; String imageUrl(int size) { assert(size &gt; 128 &amp;&amp; size &lt;= 250); return '&lt;https://s2.coinmarketcap.com/static/img/coins/${size}x$size/$id.png&gt;'; } Json toJson() =&gt; _$StockItemToJson(this); }<p>Поле id появилось как необходимость для отображения логотипов валют. Так как исходный ресурс предоставляет их как раз по id.</p>
5 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) =&gt; _$StockItemFromJson(json); final int id; final String name; final CryptoSymbol symbol; @JsonKey(name: 'quote') final Map&lt;CryptoSymbol, ItemPrices&gt; prices; ItemPrices get usdPrices =&gt; prices['USD']!; String imageUrl(int size) { assert(size &gt; 128 &amp;&amp; size &lt;= 250); return '&lt;https://s2.coinmarketcap.com/static/img/coins/${size}x$size/$id.png&gt;'; } Json toJson() =&gt; _$StockItemToJson(this); }<p>Поле id появилось как необходимость для отображения логотипов валют. Так как исходный ресурс предоставляет их как раз по id.</p>
6 <p>И еще одна сущность, описывающая цены криптовалюты в валюте обычной:</p>
6 <p>И еще одна сущность, описывающая цены криптовалюты в валюте обычной:</p>
7 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) =&gt; _$ItemPricesFromJson(json); final double price; @JsonKey(name: 'percent_change_1h') final double diff1h; @JsonKey(name: 'percent_change_24h') final double diff24h; Json toJson() =&gt; _$ItemPricesToJson(this); }<p>Для сериализации / десериализации моделей я использовал<strong>json_serializable</strong>. Осталось только загрузить данные. Тут нам на помощь приходит кодогенерация в лице<strong>retrofit</strong>. Благодаря данному решению мы можем избавиться от необходимости написания хоть какой-то части бойлерплейта (но не всей). Сетевую логику, связанную с получением списка крипты, разместим в классе CryptoProvider.</p>
7 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) =&gt; _$ItemPricesFromJson(json); final double price; @JsonKey(name: 'percent_change_1h') final double diff1h; @JsonKey(name: 'percent_change_24h') final double diff24h; Json toJson() =&gt; _$ItemPricesToJson(this); }<p>Для сериализации / десериализации моделей я использовал<strong>json_serializable</strong>. Осталось только загрузить данные. Тут нам на помощь приходит кодогенерация в лице<strong>retrofit</strong>. Благодаря данному решению мы можем избавиться от необходимости написания хоть какой-то части бойлерплейта (но не всей). Сетевую логику, связанную с получением списка крипты, разместим в классе CryptoProvider.</p>
8 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: '&lt;https://pro-api.coinmarketcap.com/v1/&gt;') abstract class CryptoProvider { factory CryptoProvider(Dio dio, {String? baseUrl}) = _CryptoProvider; @GET('cryptocurrency/listings/latest') Future&lt;StockResponse&gt; fetchLatestData({ @Header('X-CMC_PRO_API_KEY') required String token, @Query('limit') int limit = 1000, }); }<p>Конечно же, в DI-регистратор была добавлена фабрика CryptoProvider и Dio:</p>
8 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: '&lt;https://pro-api.coinmarketcap.com/v1/&gt;') abstract class CryptoProvider { factory CryptoProvider(Dio dio, {String? baseUrl}) = _CryptoProvider; @GET('cryptocurrency/listings/latest') Future&lt;StockResponse&gt; fetchLatestData({ @Header('X-CMC_PRO_API_KEY') required String token, @Query('limit') int limit = 1000, }); }<p>Конечно же, в DI-регистратор была добавлена фабрика CryptoProvider и Dio:</p>
9 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&lt;BackButtonDispatcher&gt;(() =&gt; RootBackButtonDispatcher()); Di.reg&lt;RouteInformationParser&lt;Object&gt;&gt;(() =&gt; DefaultRouterInformationParser()); Di.reg&lt;RouterDelegate&lt;Object&gt;&gt;(() =&gt; RootRouterDelegate()); Di.reg(() =&gt; PageBuilder()); Di.reg(() =&gt; Dio(), asBuilder: true); // &lt;-- Di.reg(() =&gt; CryptoProvider(Di.get()), asBuilder: true); // &lt;-- }<p>На данном этапе у нас получается следующая структура проекта (внутренности service пока опускаю):</p>
9 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&lt;BackButtonDispatcher&gt;(() =&gt; RootBackButtonDispatcher()); Di.reg&lt;RouteInformationParser&lt;Object&gt;&gt;(() =&gt; DefaultRouterInformationParser()); Di.reg&lt;RouterDelegate&lt;Object&gt;&gt;(() =&gt; RootRouterDelegate()); Di.reg(() =&gt; PageBuilder()); Di.reg(() =&gt; Dio(), asBuilder: true); // &lt;-- Di.reg(() =&gt; CryptoProvider(Di.get()), asBuilder: true); // &lt;-- }<p>На данном этапе у нас получается следующая структура проекта (внутренности service пока опускаю):</p>
10 |-- 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<p>Если вы задались вопросом, как получить такую картинку директории, вот ответ. Ну и на данном этапе работа с сетью завершена, все что нужно для отображения главного экрана у нас уже есть.</p>
10 |-- 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<p>Если вы задались вопросом, как получить такую картинку директории, вот ответ. Ну и на данном этапе работа с сетью завершена, все что нужно для отображения главного экрана у нас уже есть.</p>
11 <h2>State</h2>
11 <h2>State</h2>
12 <p>Вот мы и подбираемся к UI с логикой. Давайте начнем с последней, так как иначе она все равно заспойлерится в интерфейсе.</p>
12 <p>Вот мы и подбираемся к UI с логикой. Давайте начнем с последней, так как иначе она все равно заспойлерится в интерфейсе.</p>
13 <p>Но, прежде чем начать описывать состояние нашего приложения, нужно сделать большое лирическое отступление. Для тех, кто занимается разработкой приложений на Flutter не секрет, что Dart -- однопоточный язык с возможностью запуска нескольких, так называемых Isolate -- изолированных потоков со своим собственным Event Loop и памятью. И обычно, большинство разработчиков пишет весь код “просто в одном потоке”. То есть не заморачивается с тем, чтобы выносить тяжелые операции, потенциально блокирующие UI в отдельные изоляты (но я никого не виню, стандартное API весьма громоздкое, compute() не то, чтобы спасал, а различные сторонние библиотеки...ну кому они нужны?, изоляты -- сложно ведь). Со временем могут происходить неприятные изменения в приложении или данных, прилетающих с бэка, становится все больше и все начинает лагать. Из-за чего? Давайте проведем небольшое исследование.</p>
13 <p>Но, прежде чем начать описывать состояние нашего приложения, нужно сделать большое лирическое отступление. Для тех, кто занимается разработкой приложений на Flutter не секрет, что Dart -- однопоточный язык с возможностью запуска нескольких, так называемых Isolate -- изолированных потоков со своим собственным Event Loop и памятью. И обычно, большинство разработчиков пишет весь код “просто в одном потоке”. То есть не заморачивается с тем, чтобы выносить тяжелые операции, потенциально блокирующие UI в отдельные изоляты (но я никого не виню, стандартное API весьма громоздкое, compute() не то, чтобы спасал, а различные сторонние библиотеки...ну кому они нужны?, изоляты -- сложно ведь). Со временем могут происходить неприятные изменения в приложении или данных, прилетающих с бэка, становится все больше и все начинает лагать. Из-за чего? Давайте проведем небольшое исследование.</p>
14 <h3>Исследование</h3>
14 <h3>Исследование</h3>
15 <p>Я провел 3 эксперимента по 5 раз для двух окружений. Первое окружение: profile-сборка на флагманском устройстве (Samsung Galaxy Note 20 Ultra), находящемся в режиме “обычное использование” -- то есть я не перезагружал телефон перед каждым прогоном, но каждый раз выгружал из памяти приложение, а других активно запущенных приложений не было. Второе окружение: определенного рода симуляция слабого устройства, которое у пользователя вашего приложения тоже может оказаться - это эмулятор со следующими настройками:</p>
15 <p>Я провел 3 эксперимента по 5 раз для двух окружений. Первое окружение: profile-сборка на флагманском устройстве (Samsung Galaxy Note 20 Ultra), находящемся в режиме “обычное использование” -- то есть я не перезагружал телефон перед каждым прогоном, но каждый раз выгружал из памяти приложение, а других активно запущенных приложений не было. Второе окружение: определенного рода симуляция слабого устройства, которое у пользователя вашего приложения тоже может оказаться - это эмулятор со следующими настройками:</p>
16 2048Mb RAM 256Mb VM Heap 4 Cores CPU<p>Сам эмулятор был запущен на ноутбуке с Ryzen 7 5800H, никаких фоновых задач нет (только открытая IDEA).</p>
16 2048Mb RAM 256Mb VM Heap 4 Cores CPU<p>Сам эмулятор был запущен на ноутбуке с Ryzen 7 5800H, никаких фоновых задач нет (только открытая IDEA).</p>
17 <p>Теперь к сути испытаний -- для главного экрана необходимо загрузить данные о криптовалютах. Я загружал их по 100, 1000 и 5000 штук за один запрос. По окончанию запроса измерял время, требуемое на преобразование ответа сервера (массив байт) в сырую JSON-строку, которая, затем, десереализуется в Map&lt;String, dynamic&gt;, все это - подкапотная логика Dio, в которую я добавил только логирование времени. Вторая операция, подвергнутая анализу - уже преобразование мапки в бизнес-классы, с которыми в реальном приложении мы и работаем.</p>
17 <p>Теперь к сути испытаний -- для главного экрана необходимо загрузить данные о криптовалютах. Я загружал их по 100, 1000 и 5000 штук за один запрос. По окончанию запроса измерял время, требуемое на преобразование ответа сервера (массив байт) в сырую JSON-строку, которая, затем, десереализуется в Map&lt;String, dynamic&gt;, все это - подкапотная логика Dio, в которую я добавил только логирование времени. Вторая операция, подвергнутая анализу - уже преобразование мапки в бизнес-классы, с которыми в реальном приложении мы и работаем.</p>
18 <p>Для того, чтобы внедрить логирование в Dio пришлось изрядно покопаться в его внутренних органах: все указанные преобразования происходят посредством класса Transformer. Данный класс можно написать самому и скормить Dio, а можно ничего и не делать -- тогда будет использоваться<strong>DefaultTransformer</strong>. Приведу тот кусок стандартного трансформера, который отвечает за то, чтобы вы смогли получить мапку на выходе (справа от каждой добавленной строки есть комментарий с префиксом &lt;--, в котором описано, что тут происходит):</p>
18 <p>Для того, чтобы внедрить логирование в Dio пришлось изрядно покопаться в его внутренних органах: все указанные преобразования происходят посредством класса Transformer. Данный класс можно написать самому и скормить Dio, а можно ничего и не делать -- тогда будет использоваться<strong>DefaultTransformer</strong>. Приведу тот кусок стандартного трансформера, который отвечает за то, чтобы вы смогли получить мапку на выходе (справа от каждой добавленной строки есть комментарий с префиксом &lt;--, в котором описано, что тут происходит):</p>
19 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&lt;Uint8List&gt;(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 = &lt;Uint8List&gt;[]; var finalSize = 0; int totalDuration = 0; // &lt;-- Total computation time in microseconds int networkTime = 0; // &lt;-- Time (microseconds), which will spend to accumulate parts of network response StreamSubscription subscription = stream.listen( (chunk) { final start = DateTime.now().microsecondsSinceEpoch; // &lt;-- Before saving each part of the data we start tracking the current time finalSize += chunk.length; chunks.add(chunk); final now = DateTime.now().microsecondsSinceEpoch; // &lt;-- totalDuration += now - start; // &lt;-- After the chunk of data was saved, we check spent time networkTime += now - start; // &lt;-- }, onError: (Object error, StackTrace stackTrace) { completer.completeError(error, stackTrace); }, onDone: () =&amp;gt; completer.complete(), cancelOnError: true, ); // ignore: unawaited_futures options.cancelToken?.whenCancel.then((_) { return subscription.cancel(); }); if (options.receiveTimeout &amp;gt; 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; // &lt;-- 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; // &lt;-- 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; // &lt;-- We also tracked the decoding of the bytes into the string (raw JSON) responseBody = utf8.decode(responseBytes, allowMalformed: true); totalDuration += DateTime.now().microsecondsSinceEpoch - start; // &lt;-- } if (responseBody.isNotEmpty &amp;amp;&amp;amp; options.responseType == ResponseType.json &amp;amp;&amp;amp; _isJsonMime(response.headers[Headers.contentTypeHeader]?.first)) { final callback = jsonDecodeCallback; if (callback != null) { return callback(responseBody); } else { final start = DateTime.now().microsecondsSinceEpoch; // &lt;-- And finally - we track the decoding of the raw JSON string into the Map&lt;String, dynamic&gt; final result = json.decode(responseBody); totalDuration += DateTime.now().microsecondsSinceEpoch - start; // &lt;-- print('TOTAL PARSING TIME: ${totalDuration / 1000}ms; NETWORK TIME: ${networkTime / 1000}ms'); // &lt;-- return result; } } return responseBody; }<p>Ну и второй герой нашего времени - операция преобразования мапки в бизнес-сущности (для этого мы вклиниваем логирование в сгенерированный retrofit класс, в котором и описана вся логика получения данных):</p>
19 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&lt;Uint8List&gt;(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 = &lt;Uint8List&gt;[]; var finalSize = 0; int totalDuration = 0; // &lt;-- Total computation time in microseconds int networkTime = 0; // &lt;-- Time (microseconds), which will spend to accumulate parts of network response StreamSubscription subscription = stream.listen( (chunk) { final start = DateTime.now().microsecondsSinceEpoch; // &lt;-- Before saving each part of the data we start tracking the current time finalSize += chunk.length; chunks.add(chunk); final now = DateTime.now().microsecondsSinceEpoch; // &lt;-- totalDuration += now - start; // &lt;-- After the chunk of data was saved, we check spent time networkTime += now - start; // &lt;-- }, onError: (Object error, StackTrace stackTrace) { completer.completeError(error, stackTrace); }, onDone: () =&amp;gt; completer.complete(), cancelOnError: true, ); // ignore: unawaited_futures options.cancelToken?.whenCancel.then((_) { return subscription.cancel(); }); if (options.receiveTimeout &amp;gt; 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; // &lt;-- 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; // &lt;-- 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; // &lt;-- We also tracked the decoding of the bytes into the string (raw JSON) responseBody = utf8.decode(responseBytes, allowMalformed: true); totalDuration += DateTime.now().microsecondsSinceEpoch - start; // &lt;-- } if (responseBody.isNotEmpty &amp;amp;&amp;amp; options.responseType == ResponseType.json &amp;amp;&amp;amp; _isJsonMime(response.headers[Headers.contentTypeHeader]?.first)) { final callback = jsonDecodeCallback; if (callback != null) { return callback(responseBody); } else { final start = DateTime.now().microsecondsSinceEpoch; // &lt;-- And finally - we track the decoding of the raw JSON string into the Map&lt;String, dynamic&gt; final result = json.decode(responseBody); totalDuration += DateTime.now().microsecondsSinceEpoch - start; // &lt;-- print('TOTAL PARSING TIME: ${totalDuration / 1000}ms; NETWORK TIME: ${networkTime / 1000}ms'); // &lt;-- return result; } } return responseBody; }<p>Ну и второй герой нашего времени - операция преобразования мапки в бизнес-сущности (для этого мы вклиниваем логирование в сгенерированный retrofit класс, в котором и описана вся логика получения данных):</p>
20 Future&lt;StockResponse&gt; fetchLatestData({required token, limit = 1000}) async { const _extra = &lt;String, dynamic&gt;{}; final queryParameters = &lt;String, dynamic&gt;{r'limit': limit}; final _headers = &lt;String, dynamic&gt;{r'X-CMC_PRO_API_KEY': token}; _headers.removeWhere((k, v) =&gt; v == null); final _data = &lt;String, dynamic&gt;{}; final _result = await _dio.fetch&lt;Map&lt;String, dynamic&gt;&gt;(_setStreamType&lt;StockResponse&gt;(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'); // &lt;-- At here we used the simple performance-tracker final value = StockResponse.fromJson(_result.data!); bench.end('STOCK RESPONSE DESERIALIZING'); // &lt;-- return value; }<p>Также стоит показать и код самого performance-tracker, используемого выше:</p>
20 Future&lt;StockResponse&gt; fetchLatestData({required token, limit = 1000}) async { const _extra = &lt;String, dynamic&gt;{}; final queryParameters = &lt;String, dynamic&gt;{r'limit': limit}; final _headers = &lt;String, dynamic&gt;{r'X-CMC_PRO_API_KEY': token}; _headers.removeWhere((k, v) =&gt; v == null); final _data = &lt;String, dynamic&gt;{}; final _result = await _dio.fetch&lt;Map&lt;String, dynamic&gt;&gt;(_setStreamType&lt;StockResponse&gt;(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'); // &lt;-- At here we used the simple performance-tracker final value = StockResponse.fromJson(_result.data!); bench.end('STOCK RESPONSE DESERIALIZING'); // &lt;-- return value; }<p>Также стоит показать и код самого performance-tracker, используемого выше:</p>
21 class _Benchmark { final Map&lt;String, int&gt; _starts = &lt;String, int&gt;{}; 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();<p>Как говорил кто-то там:</p>
21 class _Benchmark { final Map&lt;String, int&gt; _starts = &lt;String, int&gt;{}; 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();<p>Как говорил кто-то там:</p>
22 <p><strong><em>"Лучше показать таблицу с данными, чем ходить вокруг да около"</em></strong></p>
22 <p><strong><em>"Лучше показать таблицу с данными, чем ходить вокруг да около"</em></strong></p>
23 <p>Поэтому, вот таблица, с дополнительной аннотацией полей:</p>
23 <p>Поэтому, вот таблица, с дополнительной аннотацией полей:</p>
24 <ul><li><strong>Count</strong>-- количество элементов криптовалют, загружаемых за один запрос (да, да, в мире есть, как минимум, 5000 видов крипты)</li>
24 <ul><li><strong>Count</strong>-- количество элементов криптовалют, загружаемых за один запрос (да, да, в мире есть, как минимум, 5000 видов крипты)</li>
25 <li><strong>Rows</strong>-- количество строк в JSON (если сделать Beautify в Postman)</li>
25 <li><strong>Rows</strong>-- количество строк в JSON (если сделать Beautify в Postman)</li>
26 <li><strong>Size</strong>-- размер данных в килобайтах</li>
26 <li><strong>Size</strong>-- размер данных в килобайтах</li>
27 <li><strong>[P] / [D]</strong>-- префикс окружения, Profile / Debug (описано выше)</li>
27 <li><strong>[P] / [D]</strong>-- префикс окружения, Profile / Debug (описано выше)</li>
28 <li><strong>JSON</strong>-- время в милисекундах, потраченное непосредственно на то, чтобы Dio вернул нам мапку</li>
28 <li><strong>JSON</strong>-- время в милисекундах, потраченное непосредственно на то, чтобы Dio вернул нам мапку</li>
29 <li><strong>Entity</strong>-- время в милисекундах, потраченное на то, чтобы преобразовать мапку в бизнес-сущности</li>
29 <li><strong>Entity</strong>-- время в милисекундах, потраченное на то, чтобы преобразовать мапку в бизнес-сущности</li>
30 <li><strong>Total</strong>-- сумма JSON + Entity</li>
30 <li><strong>Total</strong>-- сумма JSON + Entity</li>
31 <li><strong>kB / ms</strong>- метрика, означающая, “сколько килобайт можно преобразовать за одну милисекунду”.</li>
31 <li><strong>kB / ms</strong>- метрика, означающая, “сколько килобайт можно преобразовать за одну милисекунду”.</li>
32 </ul><p>А вот мои выводы из этой таблицы:</p>
32 </ul><p>А вот мои выводы из этой таблицы:</p>
33 <ol><li>В лучшем случае, если у пользователя устройство верхнего ценового сегмента -- мы можем рассчитывать на то, что оно будет способно обработать до<strong>~18kB/ms</strong>(возможно, самые новые флагманы будут способны и на большее).</li>
33 <ol><li>В лучшем случае, если у пользователя устройство верхнего ценового сегмента -- мы можем рассчитывать на то, что оно будет способно обработать до<strong>~18kB/ms</strong>(возможно, самые новые флагманы будут способны и на большее).</li>
34 <li>Ремарка про худший случай -- так как<strong>[D]</strong>окружение было запущено на эмуляторе с JIT-компиляцией, то мы имеем некоторые негативные экстремумы, связанные с тем, что код еще не разогрелся. Это отчетливо видно на объеме данных в 100 единиц - было потрачено чрезвычайно много времени, выбивающееся из статистики. Поэтому я не буду брать значение в 2.629kB/ms как минимальное, а возьму 8.603kB/ms, как более близкое к реальности. Делаем вывод -- мы можем рассчитывать на то, что устройство пользователя сможет обработать хотя бы<strong>~9kB/ms</strong>.</li>
34 <li>Ремарка про худший случай -- так как<strong>[D]</strong>окружение было запущено на эмуляторе с JIT-компиляцией, то мы имеем некоторые негативные экстремумы, связанные с тем, что код еще не разогрелся. Это отчетливо видно на объеме данных в 100 единиц - было потрачено чрезвычайно много времени, выбивающееся из статистики. Поэтому я не буду брать значение в 2.629kB/ms как минимальное, а возьму 8.603kB/ms, как более близкое к реальности. Делаем вывод -- мы можем рассчитывать на то, что устройство пользователя сможет обработать хотя бы<strong>~9kB/ms</strong>.</li>
35 <li>Будем исходить из того, что все большее количество девайсов обладает экранами с частотой обновления 120FPS, это значит, что у нас есть всего 8ms для отрисовки одного кадра, из этих 8ms какое-то время занимает сам процесс рендеринга, примерно, в среднем, это будет 2ms. Итого -- у нас осталось 6ms, чтобы сделать что-то и не потерять кадр. А это значит, что мы можем рассчитывать на то, что пользовательское устройство сможет обработать запрос с размером ответа в (18 + 9) / 2 * (8 - 2) =<strong>81kB</strong>, чтобы не потерять ни одного кадра (это в идеале, если нет других негативных факторов). Если дисплей с 60FPS, то (18 + 9) / 2 * (16 - 2) =<strong>189kB</strong>.</li>
35 <li>Будем исходить из того, что все большее количество девайсов обладает экранами с частотой обновления 120FPS, это значит, что у нас есть всего 8ms для отрисовки одного кадра, из этих 8ms какое-то время занимает сам процесс рендеринга, примерно, в среднем, это будет 2ms. Итого -- у нас осталось 6ms, чтобы сделать что-то и не потерять кадр. А это значит, что мы можем рассчитывать на то, что пользовательское устройство сможет обработать запрос с размером ответа в (18 + 9) / 2 * (8 - 2) =<strong>81kB</strong>, чтобы не потерять ни одного кадра (это в идеале, если нет других негативных факторов). Если дисплей с 60FPS, то (18 + 9) / 2 * (16 - 2) =<strong>189kB</strong>.</li>
36 </ol><p>Что с этой информацией делать? Ну, например, мы можем сделать вывод, что если попытаться разобрать JSON в 1mb в главном потоке приложения, то мы гарантированно получим лаг в 80-160ms, и это уже будет бросаться в глаза пользователю. Если у вас много запросов с жирными ответами -- интерфейс будет лагать намного чаще. Как с этим можно бороться, я уже однажды<a>рассказывал</a>. И пора продолжить этот старый рассказ.</p>
36 </ol><p>Что с этой информацией делать? Ну, например, мы можем сделать вывод, что если попытаться разобрать JSON в 1mb в главном потоке приложения, то мы гарантированно получим лаг в 80-160ms, и это уже будет бросаться в глаза пользователю. Если у вас много запросов с жирными ответами -- интерфейс будет лагать намного чаще. Как с этим можно бороться, я уже однажды<a>рассказывал</a>. И пора продолжить этот старый рассказ.</p>
37 <h3>Isolate</h3>
37 <h3>Isolate</h3>
38 <p>С недавним релизом Dart 2.15 произошли позитивные изменения в возможностях использования изолятов. Главным новшеством стал новый метод Isolate.exit(), который позволяет завершить текущий сторонний изолят, передавая в SendPort данные, которые прилетят в соответствующий ReceivePort за константное время. При этом, глубокого копирования, которое происходило раньше, до появления данного метода -- не происходит, а значит -- мы не заблочим наш UI-поток, когда он будет получать большую порцию данных одномоментно из стороннего изолята. Все это доступно “из коробки” посредством старой доброй функции compute(). С её помощью можно выносить вычисления, произодимые в отдельных функциях в сторонний изолят и быстро получать результаты обратно.</p>
38 <p>С недавним релизом Dart 2.15 произошли позитивные изменения в возможностях использования изолятов. Главным новшеством стал новый метод Isolate.exit(), который позволяет завершить текущий сторонний изолят, передавая в SendPort данные, которые прилетят в соответствующий ReceivePort за константное время. При этом, глубокого копирования, которое происходило раньше, до появления данного метода -- не происходит, а значит -- мы не заблочим наш UI-поток, когда он будет получать большую порцию данных одномоментно из стороннего изолята. Все это доступно “из коробки” посредством старой доброй функции compute(). С её помощью можно выносить вычисления, произодимые в отдельных функциях в сторонний изолят и быстро получать результаты обратно.</p>
39 <p>Относительно простым решением будет создание своего Transformer, который будет парсить ответы в стороннем изоляте и возвращать результат.</p>
39 <p>Относительно простым решением будет создание своего Transformer, который будет парсить ответы в стороннем изоляте и возвращать результат.</p>
40 <p>Но, как говорилось в первой статье -- я хочу показать еще и использование своих библиотек, а не только этапы создания приложения и так уж вышло, что у меня есть библиотека isolator, созданная для упрощения работы с изолятами и позволяющая вынести вообще всю логику в сторонние Stateful изоляты. Эти сторонние изоляты, в контексте библиотеки, носят название Backend. И к ним в нагрузку идут легковесные реактивные компаньоны, называемые Frontend -- это может быть любой класс из любого менеджера управления состоянием -- Bloc, Mobx, ChangeNotifier и т. д. К этому классу добавляется mixin Frontend и вы получаете возможность общения с соответствующим Backend. До выхода Dart 2.15 эта библиотека решала одну узкую, но фундаментальную проблему (чтобы её не пришлось решать самостоятельно) -- возможность передачи данных неограниченного объема из стороннего изолята в главный без блокировки последнего. С появлением метода Isolate.exit() эта проблема, кажется, ушла сама собой, поэтому теперь данная библиотека просто позволяет не нагружать основной поток ничем, кроме отрисовки UI (впрочем, как и раньше).</p>
40 <p>Но, как говорилось в первой статье -- я хочу показать еще и использование своих библиотек, а не только этапы создания приложения и так уж вышло, что у меня есть библиотека isolator, созданная для упрощения работы с изолятами и позволяющая вынести вообще всю логику в сторонние Stateful изоляты. Эти сторонние изоляты, в контексте библиотеки, носят название Backend. И к ним в нагрузку идут легковесные реактивные компаньоны, называемые Frontend -- это может быть любой класс из любого менеджера управления состоянием -- Bloc, Mobx, ChangeNotifier и т. д. К этому классу добавляется mixin Frontend и вы получаете возможность общения с соответствующим Backend. До выхода Dart 2.15 эта библиотека решала одну узкую, но фундаментальную проблему (чтобы её не пришлось решать самостоятельно) -- возможность передачи данных неограниченного объема из стороннего изолята в главный без блокировки последнего. С появлением метода Isolate.exit() эта проблема, кажется, ушла сама собой, поэтому теперь данная библиотека просто позволяет не нагружать основной поток ничем, кроме отрисовки UI (впрочем, как и раньше).</p>
41 <p>В данный момент на pub.dev доступна первая версия, но при этом все основные работы по написанию v2 завершены, но пока не опубликованы, поэтому если вы захотите попробовать -- можно установить из git:</p>
41 <p>В данный момент на pub.dev доступна первая версия, но при этом все основные работы по написанию v2 завершены, но пока не опубликованы, поэтому если вы захотите попробовать -- можно установить из git:</p>
42 isolator: git: url: &lt;https://github.com/alphamikle/isolator.git&gt; ref: next<p>Среди прочих нововведений второй версии присутствует возможность прозрачного использования этого же кода в вебе (но пока еще в разработке). Isolate API не имеет поддержки в вебе, как таковой, однако, при использовании isolator весь код будет работать как и обычно, но в главном потоке.</p>
42 isolator: git: url: &lt;https://github.com/alphamikle/isolator.git&gt; ref: next<p>Среди прочих нововведений второй версии присутствует возможность прозрачного использования этого же кода в вебе (но пока еще в разработке). Isolate API не имеет поддержки в вебе, как таковой, однако, при использовании isolator весь код будет работать как и обычно, но в главном потоке.</p>
43 <h3>Frontend</h3>
43 <h3>Frontend</h3>
44 <p>Для начала приложу весь код, а затем буду разбирать каждый из его блоков по отдельности:</p>
44 <p>Для начала приложу весь код, а затем буду разбирать каждый из его блоков по отдельности:</p>
45 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&lt;StockItem&gt; 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&lt;void&gt; loadStocks() async { errorOnLoadingStocks = false; final Maybe&lt;StockItem&gt; 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&lt;void&gt; 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(() =&amp;gt; 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&lt;StockItem&gt; 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&lt;void&gt; 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); } }<p>Логика работы библиотеки, отчасти, похожа на Bloc -- необходимо зарегистрировать обработчики сообщений, прилетающих с Backend. Регистрируются они в методе initActions:</p>
45 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&lt;StockItem&gt; 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&lt;void&gt; loadStocks() async { errorOnLoadingStocks = false; final Maybe&lt;StockItem&gt; 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&lt;void&gt; 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(() =&amp;gt; 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&lt;StockItem&gt; 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&lt;void&gt; 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); } }<p>Логика работы библиотеки, отчасти, похожа на Bloc -- необходимо зарегистрировать обработчики сообщений, прилетающих с Backend. Регистрируются они в методе initActions:</p>
46 @override void initActions() { whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks); whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks); whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks); }<p>В качестве идентификатора события выступает любая сущность, но важно то, что проверка на соответствие будет происходить через обычное равенство ==. Также, можно зарегистрировать обработчик на определенный тип идентификаторов, в этом случае он будет обрабатывать все события, идентифицируемые конкретно этим типом:</p>
46 @override void initActions() { whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks); whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks); whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks); }<p>В качестве идентификатора события выступает любая сущность, но важно то, что проверка на соответствие будет происходить через обычное равенство ==. Также, можно зарегистрировать обработчик на определенный тип идентификаторов, в этом случае он будет обрабатывать все события, идентифицируемые конкретно этим типом:</p>
47 class SpecificMessageId { const SpecificMessageId(this.someValue); final int someValue; } void initActions() { whenEventCome&lt;SpecificMessageId&gt;().run(_specificHandler); }<p>Стоит добавить несколько слов и о самих обработчиках. Все обработчики должны соответствовать следующему типу (не соответствующие не получится зарегистрировать):</p>
47 class SpecificMessageId { const SpecificMessageId(this.someValue); final int someValue; } void initActions() { whenEventCome&lt;SpecificMessageId&gt;().run(_specificHandler); }<p>Стоит добавить несколько слов и о самих обработчиках. Все обработчики должны соответствовать следующему типу (не соответствующие не получится зарегистрировать):</p>
48 typedef FrontendAction&lt;Event, Req, Res&gt; = FutureOr&lt;Res&gt; Function({required Event event, required Req data});<p>Но, при этом, значение data не обязательно должно прилетать. Идентификатор-событие event будет прилетать всегда. То есть, следующие обработчики зарегистрируются и будут корректными:</p>
48 typedef FrontendAction&lt;Event, Req, Res&gt; = FutureOr&lt;Res&gt; Function({required Event event, required Req data});<p>Но, при этом, значение data не обязательно должно прилетать. Идентификатор-событие event будет прилетать всегда. То есть, следующие обработчики зарегистрируются и будут корректными:</p>
49 void _startLoadingStocks({required MainEvent event, void data}) { _update(() { isStocksLoading = true; }); } void _endLoadingStocks({required MainEvent event, void data}) { _update(() { isStocksLoading = false; }); }<p>Смысл обработчиков заключается в том, что если вы ходите только реагировать на события, инициированные Backend -- нужен обработчик. Если же вы хотите вызвать какой-то метод Backend -- можно обойтись и без обработчиков вовсе.</p>
49 void _startLoadingStocks({required MainEvent event, void data}) { _update(() { isStocksLoading = true; }); } void _endLoadingStocks({required MainEvent event, void data}) { _update(() { isStocksLoading = false; }); }<p>Смысл обработчиков заключается в том, что если вы ходите только реагировать на события, инициированные Backend -- нужен обработчик. Если же вы хотите вызвать какой-то метод Backend -- можно обойтись и без обработчиков вовсе.</p>
50 <p>При вызове любого Backend-метода из Frontend вы всегда получите какой-нибудь ответ “на месте”, завернутый в своеобразный union-type Maybe&lt;T&gt;. Union-типов в Dart на данный момент нет, кроме одного встроенного FutureOr&lt;T&gt;, поэтому, для корректной типизации данных методов пришлось создавать Maybe&lt;T&gt;, он может включать в себя просто T, List&lt;T&gt; или ошибку, ну или вообще все три -- null, если метод Backend не возвращает ничего (но, на самом деле, Backend-методы всегда должны возвращать кое-что, что вы увидите немного ниже).</p>
50 <p>При вызове любого Backend-метода из Frontend вы всегда получите какой-нибудь ответ “на месте”, завернутый в своеобразный union-type Maybe&lt;T&gt;. Union-типов в Dart на данный момент нет, кроме одного встроенного FutureOr&lt;T&gt;, поэтому, для корректной типизации данных методов пришлось создавать Maybe&lt;T&gt;, он может включать в себя просто T, List&lt;T&gt; или ошибку, ну или вообще все три -- null, если метод Backend не возвращает ничего (но, на самом деле, Backend-методы всегда должны возвращать кое-что, что вы увидите немного ниже).</p>
51 <p>Следующий код демонстрирует вызов MainBackend метода по event = MainEvent.loadStocks и получение результата сразу в месте вызова:</p>
51 <p>Следующий код демонстрирует вызов MainBackend метода по event = MainEvent.loadStocks и получение результата сразу в месте вызова:</p>
52 Future&lt;void&gt; loadStocks() async { errorOnLoadingStocks = false; final Maybe&lt;StockItem&gt; 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); } }<p>Немного забегая наперед, покажу и соответствующий этому event метод MainBackend, который и будет исполнен в стороннем изоляте:</p>
52 Future&lt;void&gt; loadStocks() async { errorOnLoadingStocks = false; final Maybe&lt;StockItem&gt; 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); } }<p>Немного забегая наперед, покажу и соответствующий этому event метод MainBackend, который и будет исполнен в стороннем изоляте:</p>
53 Future&lt;ActionResponse&lt;StockItem&gt;&gt; _loadStocks({required MainEvent event, void data}) async { await send(event: MainEvent.startLoadingStocks, sendDirectly: true); try { final List&lt;StockItem&gt; 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); }<p>Пока не буду описывать его содержимое, об этом будет ниже.</p>
53 Future&lt;ActionResponse&lt;StockItem&gt;&gt; _loadStocks({required MainEvent event, void data}) async { await send(event: MainEvent.startLoadingStocks, sendDirectly: true); try { final List&lt;StockItem&gt; 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); }<p>Пока не буду описывать его содержимое, об этом будет ниже.</p>
54 <p>Следующий метод launch нужен для инициализации MainFrontend и MainBackend. В нем вызывается метод initBackend миксина Frontend, в который необходимо передать, как минимум, один аргумент: функцию-инициализатор, которая запустится уже в стороннем изоляте, и эта функция должна возвращать инстанс соответствующего Backend.</p>
54 <p>Следующий метод launch нужен для инициализации MainFrontend и MainBackend. В нем вызывается метод initBackend миксина Frontend, в который необходимо передать, как минимум, один аргумент: функцию-инициализатор, которая запустится уже в стороннем изоляте, и эта функция должна возвращать инстанс соответствующего Backend.</p>
55 Future&lt;void&gt; 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(() =&gt; isLaunching = false); }<p>Давайте взглянем на нее поближе:</p>
55 Future&lt;void&gt; 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(() =&gt; isLaunching = false); }<p>Давайте взглянем на нее поближе:</p>
56 static MainBackend _launch(BackendArgument&lt;void&gt; argument) { initDependencies(); return MainBackend(argument: argument, CryptoProvider: Di.get()); }<p>В этой функции нам необходимо повторно инициализировать Di-контейнер, так как сторонний изолят не знает ничего о том, что происходило в главном и все фабрики в стороннем изоляте не зарегистрированы. Требования к функции-инициализатору аналогичны требованиям к оригинальной функции entryPoint, используемой в Isolate API. А вот её интерфейс:</p>
56 static MainBackend _launch(BackendArgument&lt;void&gt; argument) { initDependencies(); return MainBackend(argument: argument, CryptoProvider: Di.get()); }<p>В этой функции нам необходимо повторно инициализировать Di-контейнер, так как сторонний изолят не знает ничего о том, что происходило в главном и все фабрики в стороннем изоляте не зарегистрированы. Требования к функции-инициализатору аналогичны требованиям к оригинальной функции entryPoint, используемой в Isolate API. А вот её интерфейс:</p>
57 typedef BackendInitializer&lt;T, B extends Backend&gt; = B Function(BackendArgument&lt;T&gt; argument);<p>Также, Frontend позволяет регистрировать хуки, вызываемые на каждое сообщение от Backend, только на сообщения, которые должны принудительно заставить Frontend уведомить UI об изменении данных; можно подписаться (например одному Frontend на другой), посредством метода subscribeOnEvent. Об этом будет сказано немного подробнее в блоке про UI.</p>
57 typedef BackendInitializer&lt;T, B extends Backend&gt; = B Function(BackendArgument&lt;T&gt; argument);<p>Также, Frontend позволяет регистрировать хуки, вызываемые на каждое сообщение от Backend, только на сообщения, которые должны принудительно заставить Frontend уведомить UI об изменении данных; можно подписаться (например одному Frontend на другой), посредством метода subscribeOnEvent. Об этом будет сказано немного подробнее в блоке про UI.</p>
58 <h3>Backend</h3>
58 <h3>Backend</h3>
59 <p>Я начну с метода Frontend, который вызывается для получения данных о крипте. При первичной отрисовке главного экрана в хуке initState виджета MainView происходит инициализация MainFrontend (см. метод MainFrontend.launch). По завершению которой вызывается метод loadStocks (который был разобран выше):</p>
59 <p>Я начну с метода Frontend, который вызывается для получения данных о крипте. При первичной отрисовке главного экрана в хуке initState виджета MainView происходит инициализация MainFrontend (см. метод MainFrontend.launch). По завершению которой вызывается метод loadStocks (который был разобран выше):</p>
60 // main_view.dart Future&lt;void&gt; _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(); // ... }<p>Выше уже отсветил один из методов MainBackend, что-же, вот теперь пора представить и сам класс, который будет существовать в отдельном изоляте на протяжении жизни всего приложения:</p>
60 // main_view.dart Future&lt;void&gt; _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(); // ... }<p>Выше уже отсветил один из методов MainBackend, что-же, вот теперь пора представить и сам класс, который будет существовать в отдельном изоляте на протяжении жизни всего приложения:</p>
61 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&lt;void&gt; argument, required CryptoProvider cryptoProvider, }) : _cryptoProvider = cryptoProvider, super(argument: argument); final CryptoProvider _cryptoProvider; final List&lt;StockItem&gt; _stocks = []; Timer? _searchTimer; Future&lt;ActionResponse&lt;StockItem&gt;&gt; _loadStocks({required MainEvent event, void data}) async { await send(event: MainEvent.startLoadingStocks, sendDirectly: true); try { final List&lt;StockItem&gt; 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&lt;StockItem&gt; _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&lt;StockItem&gt; 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); } }<p>По аналогии с Frontend в любом Backend есть возможность регистрации обработчиков событий с тем же самым API, но небольшим отличием в типе обработчика:</p>
61 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&lt;void&gt; argument, required CryptoProvider cryptoProvider, }) : _cryptoProvider = cryptoProvider, super(argument: argument); final CryptoProvider _cryptoProvider; final List&lt;StockItem&gt; _stocks = []; Timer? _searchTimer; Future&lt;ActionResponse&lt;StockItem&gt;&gt; _loadStocks({required MainEvent event, void data}) async { await send(event: MainEvent.startLoadingStocks, sendDirectly: true); try { final List&lt;StockItem&gt; 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&lt;StockItem&gt; _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&lt;StockItem&gt; 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); } }<p>По аналогии с Frontend в любом Backend есть возможность регистрации обработчиков событий с тем же самым API, но небольшим отличием в типе обработчика:</p>
62 typedef BackendAction&lt;Event, Req, Res&gt; = FutureOr&lt;ActionResponse&lt;Res&gt;&gt; Function({required Event event, required Req data});<p>Отличие заключается в том, что если Frontend обработчик может не возвращать ничего, то Backend обработчик обязан возвращать результат вида ActionResponse&lt;T&gt;, либо падать с ошибкой. Это является следствием определенных ограничений при работе с типами в Dart.</p>
62 typedef BackendAction&lt;Event, Req, Res&gt; = FutureOr&lt;ActionResponse&lt;Res&gt;&gt; Function({required Event event, required Req data});<p>Отличие заключается в том, что если Frontend обработчик может не возвращать ничего, то Backend обработчик обязан возвращать результат вида ActionResponse&lt;T&gt;, либо падать с ошибкой. Это является следствием определенных ограничений при работе с типами в Dart.</p>
63 <p>Также, обработчик является выходной точкой любого Backend, каждый из которых может вызывать обработчики любого другого Backend, делается это посредством специальных сущностей Interactor.</p>
63 <p>Также, обработчик является выходной точкой любого Backend, каждый из которых может вызывать обработчики любого другого Backend, делается это посредством специальных сущностей Interactor.</p>
64 <p>Теперь разберем подробнее метод получения криптовалют. Перед началом загрузки мы посылаем сообщение в MainFrontend, чтобы отобразить в интерфейсе, что идет процесс загрузки.</p>
64 <p>Теперь разберем подробнее метод получения криптовалют. Перед началом загрузки мы посылаем сообщение в MainFrontend, чтобы отобразить в интерфейсе, что идет процесс загрузки.</p>
65 <p>await send(event: MainEvent.startLoadingStocks, sendDirectly: true); Затем, происходит сама загрузка данных и их сохранение в MainBackend для возможности локального поиска.</p>
65 <p>await send(event: MainEvent.startLoadingStocks, sendDirectly: true); Затем, происходит сама загрузка данных и их сохранение в MainBackend для возможности локального поиска.</p>
66 final List&lt;StockItem&gt; stockItems = await _cryptoProvider.fetchLatestData(); _stocks.clear(); _stocks.addAll(stockItems);<p>Теперь начинается кое-что интересное, что стало возможным с выходом Dart 2.15. Упомянутая выше возможность библиотеки передавать любой объем данных без просадки кадров достигается (раньше достигалась) посредством разбиения массива данных на чанки и передачей этих чанков во Frontend по очереди. Логика тут была простая, если данных много -- их можно так или иначе представить в виде массива, а его можно без проблем разбить на маленькие куски и передать без проблем с производительностью. Собственно, эта старая логика отображена передачей данных, завернутых в специальный wrapper Chunks:</p>
66 final List&lt;StockItem&gt; stockItems = await _cryptoProvider.fetchLatestData(); _stocks.clear(); _stocks.addAll(stockItems);<p>Теперь начинается кое-что интересное, что стало возможным с выходом Dart 2.15. Упомянутая выше возможность библиотеки передавать любой объем данных без просадки кадров достигается (раньше достигалась) посредством разбиения массива данных на чанки и передачей этих чанков во Frontend по очереди. Логика тут была простая, если данных много -- их можно так или иначе представить в виде массива, а его можно без проблем разбить на маленькие куски и передать без проблем с производительностью. Собственно, эта старая логика отображена передачей данных, завернутых в специальный wrapper Chunks:</p>
67 await send( event: MainEvent.loadStocks, data: ActionResponse.chunks( Chunks( data: _stocks, updateAfterFirstChunk: true, size: 100, delay: const Duration(milliseconds: 8), ), ), );<p>При этом сборка чанков во Frontend происходила “магически-автоматически”, и обработчик, который ожидал получения большой пачки данных -- просто получал свой готовый огромный массив. Все эти возможности придется выпилить, так как особого смысла от них теперь нет.</p>
67 await send( event: MainEvent.loadStocks, data: ActionResponse.chunks( Chunks( data: _stocks, updateAfterFirstChunk: true, size: 100, delay: const Duration(milliseconds: 8), ), ), );<p>При этом сборка чанков во Frontend происходила “магически-автоматически”, и обработчик, который ожидал получения большой пачки данных -- просто получал свой готовый огромный массив. Все эти возможности придется выпилить, так как особого смысла от них теперь нет.</p>
68 <p>С приходом новой версии Dart стало возможным передавать любой объем данных любого типа за константное время и без ограничений по типу передаваемых данных -- теперь можно без проблем передавать не только массивы, но и любую другую структуру, если это необходимо. Сейчас достаточно использовать обычный метод отправки сообщений, который будет использовать под капотом пресловутый Isolate.exit:</p>
68 <p>С приходом новой версии Dart стало возможным передавать любой объем данных любого типа за константное время и без ограничений по типу передаваемых данных -- теперь можно без проблем передавать не только массивы, но и любую другую структуру, если это необходимо. Сейчас достаточно использовать обычный метод отправки сообщений, который будет использовать под капотом пресловутый Isolate.exit:</p>
69 await send( event: MainEvent.loadStocks, data: ActionResponse.list(_stocks), );<p>При этом, как говорит документация, возможность быстрой передачи данных доступна только при уничтожении отправляющего изолята. А так как наш MainBackend (да и любой другой Backend) -- стремится жить на протяжении существования всего приложения (по крайней мере такова их задумка, но их и без проблем можно закрывать, но, всё-таки, не таким способом), то использовать Isolate.exit напрямую в этом изоляте нельзя -- он, по большому счету, завершится аварийно. Чтобы обойти это недоразумение наш Backend создает дополнительный транспортный изолят, в который классическим способом (глубоким копированием средствами Dart VM) передается любое количество данных, никак не влияющее на UI-изолят, а затем этот одноразовый транспортный изолят уничтожается, передавая при этом, данные в наш UI-изолят.</p>
69 await send( event: MainEvent.loadStocks, data: ActionResponse.list(_stocks), );<p>При этом, как говорит документация, возможность быстрой передачи данных доступна только при уничтожении отправляющего изолята. А так как наш MainBackend (да и любой другой Backend) -- стремится жить на протяжении существования всего приложения (по крайней мере такова их задумка, но их и без проблем можно закрывать, но, всё-таки, не таким способом), то использовать Isolate.exit напрямую в этом изоляте нельзя -- он, по большому счету, завершится аварийно. Чтобы обойти это недоразумение наш Backend создает дополнительный транспортный изолят, в который классическим способом (глубоким копированием средствами Dart VM) передается любое количество данных, никак не влияющее на UI-изолят, а затем этот одноразовый транспортный изолят уничтожается, передавая при этом, данные в наш UI-изолят.</p>
70 <p>Вернемся к разбору нашего метода загрузки крипты. Так как мы организуем “синхронный” вызов Backend-метода из Frontend, то наш Backend-метод должен вернуть этот результат:</p>
70 <p>Вернемся к разбору нашего метода загрузки крипты. Так как мы организуем “синхронный” вызов Backend-метода из Frontend, то наш Backend-метод должен вернуть этот результат:</p>
71 return ActionResponse.list(_stocks);<p>Также, при отправке события начала загрузки данных был указан дополнительный параметр sendDirectly, думаю, самое время описать и его -- так как мы не всегда передаём большое количество данных из Backend во Frontend, то и не всегда нужно пользоваться услугами транспортного изолята -- можно передавать данные напрямую. Если это необходимо -- использование данного параметра позволит отправлять сообщения без сторонней помощи.</p>
71 return ActionResponse.list(_stocks);<p>Также, при отправке события начала загрузки данных был указан дополнительный параметр sendDirectly, думаю, самое время описать и его -- так как мы не всегда передаём большое количество данных из Backend во Frontend, то и не всегда нужно пользоваться услугами транспортного изолята -- можно передавать данные напрямую. Если это необходимо -- использование данного параметра позволит отправлять сообщения без сторонней помощи.</p>
72 <h3>Локальный поиск</h3>
72 <h3>Локальный поиск</h3>
73 <p>Более подробно останавливаться на методе локального поиска останавливаться не буду, так как, кажется, статья уже стала лонгридом 🙂. Работает он как поиск по регулярному выражению. Могу добавить только то, что вы можете получить ответ на главный вопрос вселенной с его помощью и даже немного больше.</p>
73 <p>Более подробно останавливаться на методе локального поиска останавливаться не буду, так как, кажется, статья уже стала лонгридом 🙂. Работает он как поиск по регулярному выражению. Могу добавить только то, что вы можете получить ответ на главный вопрос вселенной с его помощью и даже немного больше.</p>
74 <h3>UI</h3>
74 <h3>UI</h3>
75 <p>После завершения данного этапа структура домена main станет такой:</p>
75 <p>После завершения данного этапа структура домена main станет такой:</p>
76 |-- 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<p>Опишем содержимое папочки ui:</p>
76 |-- 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<p>Опишем содержимое папочки ui:</p>
77 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() =&gt; _MainViewState(); } class _MainViewState extends State&lt;MainView&gt; { MainFrontend get _mainFrontend =&gt; Provider.of(context); late final FrontendEventSubscription&lt;MainEvent&gt; _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&lt;MainFrontend&gt;(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&lt;NotificationService&gt;(context, listen: false).showSnackBar( content: content, backgroundColor: AppTheme.of(context, listen: false).okColor, ); } Future&lt;void&gt; _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&lt;MainFrontend&gt;(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&lt;Assets&gt;(context, listen: false); final AppTheme theme = AppTheme.of(context); final MaterialStateProperty&lt;Color&gt; buttonColor = MaterialStateProperty.resolveWith((states) =&gt; theme.buttonColor); final ButtonStyle buttonStyle = ButtonStyle( foregroundColor: buttonColor, overlayColor: MaterialStateProperty.resolveWith((states) =&gt; theme.splashColor), shadowColor: buttonColor, ); final List&lt;String&gt; notFoundImages = [ assets.notFound1, assets.notFound2, assets.notFound3, assets.notFound4, ].map((e) =&gt; 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, ), ); } }<p>Что есть интересного здесь? Инициализацию MainFrontend уже обсудили, остался только подписчик на события. Кстати, вот он:</p>
77 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() =&gt; _MainViewState(); } class _MainViewState extends State&lt;MainView&gt; { MainFrontend get _mainFrontend =&gt; Provider.of(context); late final FrontendEventSubscription&lt;MainEvent&gt; _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&lt;MainFrontend&gt;(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&lt;NotificationService&gt;(context, listen: false).showSnackBar( content: content, backgroundColor: AppTheme.of(context, listen: false).okColor, ); } Future&lt;void&gt; _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&lt;MainFrontend&gt;(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&lt;Assets&gt;(context, listen: false); final AppTheme theme = AppTheme.of(context); final MaterialStateProperty&lt;Color&gt; buttonColor = MaterialStateProperty.resolveWith((states) =&gt; theme.buttonColor); final ButtonStyle buttonStyle = ButtonStyle( foregroundColor: buttonColor, overlayColor: MaterialStateProperty.resolveWith((states) =&gt; theme.splashColor), shadowColor: buttonColor, ); final List&lt;String&gt; notFoundImages = [ assets.notFound1, assets.notFound2, assets.notFound3, assets.notFound4, ].map((e) =&gt; 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, ), ); } }<p>Что есть интересного здесь? Инициализацию MainFrontend уже обсудили, остался только подписчик на события. Кстати, вот он:</p>
78 _eventSubscription = Provider.of&lt;MainFrontend&gt;(context, listen: false).subscribeOnEvent( listener: _onSearchEnd, event: MainEvent.updateFilteredStocks, onEveryEvent: true, );<p>Вызов данного метода позволяет уведомляться в том, что наш MainFrontend получил сообщение соответствующего типа от MainBackend. Метод subscribeOnEvent является частью Frontend в принципе.</p>
78 _eventSubscription = Provider.of&lt;MainFrontend&gt;(context, listen: false).subscribeOnEvent( listener: _onSearchEnd, event: MainEvent.updateFilteredStocks, onEveryEvent: true, );<p>Вызов данного метода позволяет уведомляться в том, что наш MainFrontend получил сообщение соответствующего типа от MainBackend. Метод subscribeOnEvent является частью Frontend в принципе.</p>
79 <p>В результате мы получаем такие уведомления, каждый раз, когда нам прилетает порция данных после поиска:</p>
79 <p>В результате мы получаем такие уведомления, каждый раз, когда нам прилетает порция данных после поиска:</p>
80 <p>И это -- является подводкой к теме локализации приложений на Flutter.</p>
80 <p>И это -- является подводкой к теме локализации приложений на Flutter.</p>
81 <h3>Локализация интерфейса</h3>
81 <h3>Локализация интерфейса</h3>
82 <p>Уже довольно давно я задавался вопросом -- как можно быстро локализовать приложение на Flutter. Если взглянуть на официальный гайд -- то первое впечатление “без бутылки не разберешься”. Второе, собственно -- тоже. И тогда я подумал, что если избавиться от громоздкого .arb, и вместо него использовать .yaml? Так родился пакет assets_codegen (ссылку я не прикладываю, так как он deprecated). Его идея была в следующем -- располагаем файлы локализации в ассетах, аннотируем какой-нибудь класс, чтобы к нему цеплялся код локализации, запускаем flutter pub run build_runner watch и наслаждаемся. Решение было более чем работоспособным, но имелись и минусы -- логика отслеживания изменений в файлах локализации была написана руками, а котогенерация Dart не позволяет отслеживать изменения не в Dart-файлах, и результат совмещения стандартного кодогенератора и рукописного вотчера иной раз удручал. В общем было много раздражающих багов. И вот однажды, уже имея некоторое понимание, как часто приходится добавлять новые строки локализации и сразу же после этого ожидать их появления в коде (спойлер -- крайне редко), я решил написать полностью новый пакет, еще и название которого, родившееся в моей голове, очень мне понравилось.</p>
82 <p>Уже довольно давно я задавался вопросом -- как можно быстро локализовать приложение на Flutter. Если взглянуть на официальный гайд -- то первое впечатление “без бутылки не разберешься”. Второе, собственно -- тоже. И тогда я подумал, что если избавиться от громоздкого .arb, и вместо него использовать .yaml? Так родился пакет assets_codegen (ссылку я не прикладываю, так как он deprecated). Его идея была в следующем -- располагаем файлы локализации в ассетах, аннотируем какой-нибудь класс, чтобы к нему цеплялся код локализации, запускаем flutter pub run build_runner watch и наслаждаемся. Решение было более чем работоспособным, но имелись и минусы -- логика отслеживания изменений в файлах локализации была написана руками, а котогенерация Dart не позволяет отслеживать изменения не в Dart-файлах, и результат совмещения стандартного кодогенератора и рукописного вотчера иной раз удручал. В общем было много раздражающих багов. И вот однажды, уже имея некоторое понимание, как часто приходится добавлять новые строки локализации и сразу же после этого ожидать их появления в коде (спойлер -- крайне редко), я решил написать полностью новый пакет, еще и название которого, родившееся в моей голове, очень мне понравилось.</p>
83 <p>Так появился пакет yalo. С предельно простой логикой (описанной в документации) -- размещаем файлы локализации в ассетах, запускаем генератор командой</p>
83 <p>Так появился пакет yalo. С предельно простой логикой (описанной в документации) -- размещаем файлы локализации в ассетах, запускаем генератор командой</p>
84 <p>flutter pub run yalo:loc, подключаем к проекту сгенерированный локальный пакет .yalo_locale, используем пару переменных в корневой ...App:</p>
84 <p>flutter pub run yalo:loc, подключаем к проекту сгенерированный локальный пакет .yalo_locale, используем пару переменных в корневой ...App:</p>
85 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&lt;RouteInformationParser&lt;Object&gt;&gt;(), routerDelegate: Di.get&lt;RouterDelegate&lt;Object&gt;&gt;(), backButtonDispatcher: Di.get&lt;BackButtonDispatcher&gt;(), theme: Theme.of(context).copyWith(brightness: Brightness.dark), debugShowCheckedModeBanner: false, localizationsDelegates: localizationsDelegates, // &lt;-- 1 supportedLocales: supportedLocales, // &lt;-- 2 onGenerateTitle: (BuildContext context) =&gt; Messages.of(context).common.appTitle, ); } }<p>И используем локализованный контент. С плюрализацией, префиксами, сколько угодно глубокой вложенностью и подстановкой (пока только для числовых данных в плюрализированных строках). Примеры использования вы уже могли заметить выше, но продемонстрирую их отдельно.</p>
85 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&lt;RouteInformationParser&lt;Object&gt;&gt;(), routerDelegate: Di.get&lt;RouterDelegate&lt;Object&gt;&gt;(), backButtonDispatcher: Di.get&lt;BackButtonDispatcher&gt;(), theme: Theme.of(context).copyWith(brightness: Brightness.dark), debugShowCheckedModeBanner: false, localizationsDelegates: localizationsDelegates, // &lt;-- 1 supportedLocales: supportedLocales, // &lt;-- 2 onGenerateTitle: (BuildContext context) =&gt; Messages.of(context).common.appTitle, ); } }<p>И используем локализованный контент. С плюрализацией, префиксами, сколько угодно глубокой вложенностью и подстановкой (пока только для числовых данных в плюрализированных строках). Примеры использования вы уже могли заметить выше, но продемонстрирую их отдельно.</p>
86 <p>Генерация названия приложения:</p>
86 <p>Генерация названия приложения:</p>
87 (BuildContext context) =&gt; Messages.of(context).common.appTitle<p>Подсказка поля ввода поиска:</p>
87 (BuildContext context) =&gt; Messages.of(context).common.appTitle<p>Подсказка поля ввода поиска:</p>
88 Messages.of(context).main.search.hint<p>Количество элементов после поиска в SnackBar:</p>
88 Messages.of(context).main.search.hint<p>Количество элементов после поиска в SnackBar:</p>
89 Messages.of(context).main.search.result( Provider.of&lt;MainFrontend&gt;(context, listen: false).stocks.length)<p>Появляется это все из такого файлика:</p>
89 Messages.of(context).main.search.result( Provider.of&lt;MainFrontend&gt;(context, listen: false).stocks.length)<p>Появляется это все из такого файлика:</p>
90 main: loading: Загрузка... search: hint: Поиск result: zero: Мы ничего не нашли one: Мы нашли ${howMany} элемент two: Мы нашли ${howMany} элемента other: Мы нашли ${howMany} элементов common: currency: '\$' percent: '%' appTitle: High Low<p>Точнее, файликов, лежащих вот так:</p>
90 main: loading: Загрузка... search: hint: Поиск result: zero: Мы ничего не нашли one: Мы нашли ${howMany} элемент two: Мы нашли ${howMany} элемента other: Мы нашли ${howMany} элементов common: currency: '\$' percent: '%' appTitle: High Low<p>Точнее, файликов, лежащих вот так:</p>
91 |-- README.md |-- analysis_options.yaml |-- assets | `-- i18 | |-- en_intl.yaml | `-- ru_intl.yaml `-- watch.sh<p>Но вместо префикса файла, можно раскладывать их по папкам -- ../ru/intl.dart</p>
91 |-- README.md |-- analysis_options.yaml |-- assets | `-- i18 | |-- en_intl.yaml | `-- ru_intl.yaml `-- watch.sh<p>Но вместо префикса файла, можно раскладывать их по папкам -- ../ru/intl.dart</p>
92 <h2>Заключение</h2>
92 <h2>Заключение</h2>
93 <p>На этот раз статья поспела за кодом и все, что реализовано -- тут описано. В третьей статье я сделаю полностью второй экран (учитывая графики и игровую механику, возможно третья часть выйдет во время новогодних праздников), покажу работу с ассетами здорового человека и implicit-анимацию любого текста.</p>
93 <p>На этот раз статья поспела за кодом и все, что реализовано -- тут описано. В третьей статье я сделаю полностью второй экран (учитывая графики и игровую механику, возможно третья часть выйдет во время новогодних праздников), покажу работу с ассетами здорового человека и implicit-анимацию любого текста.</p>
94 <p>И еще, приложу изменения, которые произошли со времени первой части. И, код текущего состояния проекта.</p>
94 <p>И еще, приложу изменения, которые произошли со времени первой части. И, код текущего состояния проекта.</p>
95 <h3>Особая секция</h3>
95 <h3>Особая секция</h3>
96 <p>Как сцены после титров в Marvel -- данная секция для особых зрителей читателей. Уже дописав данную статью я был практически готов её опубликовать. Но чувство перфекционизма старательно откусывало от меня кусочки -- на момент “готовности” статьи isolator не был доработан настолько, чтобы было можно использовать его и в web. И ещё мне хотелось показать не только картинки приложения, но и дать возможность его “потыкать”. И вот я за пару вечеров добавил возможность работы в web (как и прежде -- без многопоточности, но с сохранением полной работоспособности без изменений в вашем коде). Затем встал вопрос о публикации приложения. Публиковать в сторах я планирую в самом конце, а пока можно было бы сделать это на github.pages. Тут-то и начинается самое интересное.</p>
96 <p>Как сцены после титров в Marvel -- данная секция для особых зрителей читателей. Уже дописав данную статью я был практически готов её опубликовать. Но чувство перфекционизма старательно откусывало от меня кусочки -- на момент “готовности” статьи isolator не был доработан настолько, чтобы было можно использовать его и в web. И ещё мне хотелось показать не только картинки приложения, но и дать возможность его “потыкать”. И вот я за пару вечеров добавил возможность работы в web (как и прежде -- без многопоточности, но с сохранением полной работоспособности без изменений в вашем коде). Затем встал вопрос о публикации приложения. Публиковать в сторах я планирую в самом конце, а пока можно было бы сделать это на github.pages. Тут-то и начинается самое интересное.</p>
97 <p>Запустил web-версию локально, все отлично работает, за исключением одного NO! -- API сервиса, который я начал использовать изначально, не позволяет осуществлять CORS-запросы, “чтобы не палить ваши токены авторизации”, видимо, про реверс API приложений они не слышали. Ну да ладно. Я начал искать способы, как можно обойти это ограничение без необходимости пилить свой собственный proxy, хостить его где-то и т. д. Нашел curl-online, сделал запрос через него (через интерфейс самого сервиса) -- все заработало. Сразу начал делать web-имплементацию CryptoProvider, который бы использовался в web-сборке и ходил за данными через web-curl. И снова:</p>
97 <p>Запустил web-версию локально, все отлично работает, за исключением одного NO! -- API сервиса, который я начал использовать изначально, не позволяет осуществлять CORS-запросы, “чтобы не палить ваши токены авторизации”, видимо, про реверс API приложений они не слышали. Ну да ладно. Я начал искать способы, как можно обойти это ограничение без необходимости пилить свой собственный proxy, хостить его где-то и т. д. Нашел curl-online, сделал запрос через него (через интерфейс самого сервиса) -- все заработало. Сразу начал делать web-имплементацию CryptoProvider, который бы использовался в web-сборке и ходил за данными через web-curl. И снова:</p>
98 <p>У меня локально все работает</p>
98 <p>У меня локально все работает</p>
99 <p>Деплой на github.pages → и снова CORS, но уже у самого курла (почему я не додумался выполнить этот запрос из консоли браузера со страницы приложения на pages - очень большой вопроc). Время - час ночи, и я неунывающими красными глазами начинаю пялить в код пишушейся прокси для этого всего. Еще пол часа и глаза говорят “пора спать”. Проснувшись на следующий день, рано утром, я снова начал искать способы не писать прокси и, видимо, правду говорят - утро вечера мудренее, я додумываюсь поискать альтернативу самому API. И первый же запрос в гугл предоставляет мне<a>прекрасную</a>, полностью бесплатную, без авторизаций (и с очень небольшими ограничениями), апишку.</p>
99 <p>Деплой на github.pages → и снова CORS, но уже у самого курла (почему я не додумался выполнить этот запрос из консоли браузера со страницы приложения на pages - очень большой вопроc). Время - час ночи, и я неунывающими красными глазами начинаю пялить в код пишушейся прокси для этого всего. Еще пол часа и глаза говорят “пора спать”. Проснувшись на следующий день, рано утром, я снова начал искать способы не писать прокси и, видимо, правду говорят - утро вечера мудренее, я додумываюсь поискать альтернативу самому API. И первый же запрос в гугл предоставляет мне<a>прекрасную</a>, полностью бесплатную, без авторизаций (и с очень небольшими ограничениями), апишку.</p>
100 <p>С одной стороны -- я безмерно рад тому, что не придется пилить никакие прокси, и также рад тому, что смогу показать вам как оно работает в вебе без всяких “но”, но с другой -- если бы я сначала подумал, поискал, а не бросился пилить код, сэкономил бы часов 8 жизни...</p>
100 <p>С одной стороны -- я безмерно рад тому, что не придется пилить никакие прокси, и также рад тому, что смогу показать вам как оно работает в вебе без всяких “но”, но с другой -- если бы я сначала подумал, поискал, а не бросился пилить код, сэкономил бы часов 8 жизни...</p>
101 <p>В общем результаты таковы, что isolator v2 теперь полностью готов к использованию. Ну и вы можете взглянуть на web-версию того, что уже реализовано. У API есть ограничение на 50 вызовов в минуту, так что если сработает хабраэффект -- вы увидите Экран ошибки, на котором будет достаточно нажать одну кнопку.</p>
101 <p>В общем результаты таковы, что isolator v2 теперь полностью готов к использованию. Ну и вы можете взглянуть на web-версию того, что уже реализовано. У API есть ограничение на 50 вызовов в минуту, так что если сработает хабраэффект -- вы увидите Экран ошибки, на котором будет достаточно нажать одну кнопку.</p>
102 <h2>Ассеты</h2>
102 <h2>Ассеты</h2>
103 <p>Если бы не особая секция, и все страдания, которые там описаны, этот раздел действительно был бы должен оказаться в третьей статье. Изначально я хотел показать работу с ними в этой, но во время написания статьи понял, что нет особых мест, кроме как придуманных исскуственно, где они были бы к месту. Затем, во время реализации логики, связанной с возможностью исчерпания лимита моего токена авторизации на первом ресурсе появилось место, где ассеты будут к месту. Идея была такова -- если ресурс моего токена заканчивается, то при получении ошибки во время запроса отобразится дополнительный экран, где будет висеть какая-нибудь прикольная картинка, а также инпут для ввода вашего собственного токена авторизации, с которым бы у вас лично все заработало. После перехода на новое API логика по использованию вашего токена отпала сама собой, но, потенциально, осталась возможность наткнуться на ошибку из-за лимитов API по RPS. Поэтому, если вы увидите данный экран -- то хабраэффект сработал.</p>
103 <p>Если бы не особая секция, и все страдания, которые там описаны, этот раздел действительно был бы должен оказаться в третьей статье. Изначально я хотел показать работу с ними в этой, но во время написания статьи понял, что нет особых мест, кроме как придуманных исскуственно, где они были бы к месту. Затем, во время реализации логики, связанной с возможностью исчерпания лимита моего токена авторизации на первом ресурсе появилось место, где ассеты будут к месту. Идея была такова -- если ресурс моего токена заканчивается, то при получении ошибки во время запроса отобразится дополнительный экран, где будет висеть какая-нибудь прикольная картинка, а также инпут для ввода вашего собственного токена авторизации, с которым бы у вас лично все заработало. После перехода на новое API логика по использованию вашего токена отпала сама собой, но, потенциально, осталась возможность наткнуться на ошибку из-за лимитов API по RPS. Поэтому, если вы увидите данный экран -- то хабраэффект сработал.</p>
104 <p>А теперь к самой работе с ассетами! Упомянутый выше пакет yalo, позволяет не только генерировать локализацию из .yaml файлов, но также, он позволяет генерировать код с именами всех ассетов, лежащих в вашей папке assets (или любой другой, если она корректно указана в pubspec.yaml). Сейчас структура папки assets данного проекта имеет следующий вид:</p>
104 <p>А теперь к самой работе с ассетами! Упомянутый выше пакет yalo, позволяет не только генерировать локализацию из .yaml файлов, но также, он позволяет генерировать код с именами всех ассетов, лежащих в вашей папке assets (или любой другой, если она корректно указана в pubspec.yaml). Сейчас структура папки assets данного проекта имеет следующий вид:</p>
105 ./assets |-- i18 | |-- en_intl.yaml | `-- ru_intl.yaml `-- images |-- notFound_1.png |-- notFound_2.png |-- notFound_3.png `-- notFound_4.png<p>При условии, что у вас в проекте уже установлен данный пакет, вы можете запустить следующую команду:</p>
105 ./assets |-- i18 | |-- en_intl.yaml | `-- ru_intl.yaml `-- images |-- notFound_1.png |-- notFound_2.png |-- notFound_3.png `-- notFound_4.png<p>При условии, что у вас в проекте уже установлен данный пакет, вы можете запустить следующую команду:</p>
106 flutter pub run yalo:asset<p>Результатом такой команды будет сгенерированный пакет .yalo_assets в корне вашего проекта, который, по аналогии с .yalo_locale нужно добавить в pubspec.yaml:</p>
106 flutter pub run yalo:asset<p>Результатом такой команды будет сгенерированный пакет .yalo_assets в корне вашего проекта, который, по аналогии с .yalo_locale нужно добавить в pubspec.yaml:</p>
107 dependencies: //... yalo_locale: path: ./.yalo_locale yalo_assets: path: ./.yalo_assets<p>После этих манипуляций вы получаете доступ к классу со статическими и обычными геттерами:</p>
107 dependencies: //... yalo_locale: path: ./.yalo_locale yalo_assets: path: ./.yalo_assets<p>После этих манипуляций вы получаете доступ к классу со статическими и обычными геттерами:</p>
108 class Assets { String get enIntl =&gt; enIntlS; static const String enIntlS = 'assets/i18/en_intl.yaml'; String get ruIntl =&gt; ruIntlS; static const String ruIntlS = 'assets/i18/ru_intl.yaml'; String get notFound1 =&gt; notFound1S; static const String notFound1S = 'assets/images/notFound_1.png'; String get notFound2 =&gt; notFound2S; static const String notFound2S = 'assets/images/notFound_2.png'; String get notFound3 =&gt; notFound3S; static const String notFound3S = 'assets/images/notFound_3.png'; String get notFound4 =&gt; notFound4S; static const String notFound4S = 'assets/images/notFound_4.png'; }<p>Я опустил некоторые дополнительные методы, имеющиеся в данном классе, так как особой востребованностью они не пользовались.</p>
108 class Assets { String get enIntl =&gt; enIntlS; static const String enIntlS = 'assets/i18/en_intl.yaml'; String get ruIntl =&gt; ruIntlS; static const String ruIntlS = 'assets/i18/ru_intl.yaml'; String get notFound1 =&gt; notFound1S; static const String notFound1S = 'assets/images/notFound_1.png'; String get notFound2 =&gt; notFound2S; static const String notFound2S = 'assets/images/notFound_2.png'; String get notFound3 =&gt; notFound3S; static const String notFound3S = 'assets/images/notFound_3.png'; String get notFound4 =&gt; notFound4S; static const String notFound4S = 'assets/images/notFound_4.png'; }<p>Я опустил некоторые дополнительные методы, имеющиеся в данном классе, так как особой востребованностью они не пользовались.</p>
109 <p>Чем это может быть полезно? Главный плюс -- автодополнение. Дополнительный -- у вас появляется возможность отслеживать ассеты на уровне кода. Если какой-либо файл будет удален или изменено его имя -- код на это отреагирует и вы получите статическую ошибку, вместо отлова её в рантайме (если не уследили за этим). Разрешение коллизий имен ассетов (например два файла в одинаковым именем, лежащих в разных папках) тоже есть, и выглядит вот так:</p>
109 <p>Чем это может быть полезно? Главный плюс -- автодополнение. Дополнительный -- у вас появляется возможность отслеживать ассеты на уровне кода. Если какой-либо файл будет удален или изменено его имя -- код на это отреагирует и вы получите статическую ошибку, вместо отлова её в рантайме (если не уследили за этим). Разрешение коллизий имен ассетов (например два файла в одинаковым именем, лежащих в разных папках) тоже есть, и выглядит вот так:</p>
110 class Assets { String get enIntl =&gt; enIntlS; static const String enIntlS = 'assets/i18/en_intl.yaml'; String get ruIntl =&gt; ruIntlS; static const String ruIntlS = 'assets/i18/ru_intl.yaml'; String get notFound =&gt; notFoundS; static const String notFoundS = 'assets/images/blabla/notFound.png'; String get notFound1 =&gt; notFound1S; static const String notFound1S = 'assets/images/notFound_1.png'; String get notFound2 =&gt; notFound2S; static const String notFound2S = 'assets/images/notFound_2.png'; String get notFound3 =&gt; notFound3S; static const String notFound3S = 'assets/images/notFound_3.png'; String get notFound4 =&gt; notFound4S; static const String notFound4S = 'assets/images/notFound_4.png'; String get notFoundCopy =&gt; notFoundCopyS; static const String notFoundCopyS = 'assets/images/old_content/notFound.png'; String get notFoundCopyCopy =&gt; notFoundCopyCopyS; static const String notFoundCopyCopyS = 'assets/images/something_else/notFound.png'; String get notFoundCopyCopyCopy =&gt; notFoundCopyCopyCopyS; static const String notFoundCopyCopyCopyS = 'assets/images/very_important_content/notFound.png'; String get notFound3Copy =&gt; notFound3CopyS; static const String notFound3CopyS = 'assets/images/very_important_content/notFound_3.png'; }<h2>Окончательное заключение</h2>
110 class Assets { String get enIntl =&gt; enIntlS; static const String enIntlS = 'assets/i18/en_intl.yaml'; String get ruIntl =&gt; ruIntlS; static const String ruIntlS = 'assets/i18/ru_intl.yaml'; String get notFound =&gt; notFoundS; static const String notFoundS = 'assets/images/blabla/notFound.png'; String get notFound1 =&gt; notFound1S; static const String notFound1S = 'assets/images/notFound_1.png'; String get notFound2 =&gt; notFound2S; static const String notFound2S = 'assets/images/notFound_2.png'; String get notFound3 =&gt; notFound3S; static const String notFound3S = 'assets/images/notFound_3.png'; String get notFound4 =&gt; notFound4S; static const String notFound4S = 'assets/images/notFound_4.png'; String get notFoundCopy =&gt; notFoundCopyS; static const String notFoundCopyS = 'assets/images/old_content/notFound.png'; String get notFoundCopyCopy =&gt; notFoundCopyCopyS; static const String notFoundCopyCopyS = 'assets/images/something_else/notFound.png'; String get notFoundCopyCopyCopy =&gt; notFoundCopyCopyCopyS; static const String notFoundCopyCopyCopyS = 'assets/images/very_important_content/notFound.png'; String get notFound3Copy =&gt; notFound3CopyS; static const String notFound3CopyS = 'assets/images/very_important_content/notFound_3.png'; }<h2>Окончательное заключение</h2>
111 <p>Надо было что-то оставить на самый финал -- на этом действительно все.</p>
111 <p>Надо было что-то оставить на самый финал -- на этом действительно все.</p>
112 <p>Больше материалов смотрите<a>в моем блоге</a>на Хабре.</p>
112 <p>Больше материалов смотрите<a>в моем блоге</a>на Хабре.</p>
113  
113