Это вторая статья из цикла о разработке приложения на Flutter (предыдущая находится здесь). В этом "номере" я опишу создание сетевого слоя, работу с локализацией, удобный способ работы с ассетами, локальный поиск и создание UI для одного из двух экранов приложения. Также я выведу интересные метрики, например -- сколько данных сможет распарсить ваше приложение за одну милисекунду и начиная с какого размера JSON’а, прилетевшего с бэка UI начнет тормозить. Как говорится -- с места... В карьер!
Сеть
Для отрисовки первого экрана необходимы следующие данные:
image
title
subtitle
price
diff
Исходя из этого получаем следующую сущность, описывающую каждый из токенов:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import '../../../service/types/types.dart';
import 'item_prices.dart';
part 'stock_item.g.dart';
// BTC, ETH etc.
typedef CryptoSymbol = String;
/* Example of data:
{
"id": 1,
"name": "Bitcoin",
"symbol": "BTC",
"max_supply": 21000000,
"circulating_supply": 18897568,
"total_supply": 18897568,
"platform": null,
"cmc_rank": 1,
"last_updated": "2021-12-11T03:44:02.000Z",
"quote": {
"USD": {
"price": 48394.083464545605,
"volume_24h": 32477191827.784477,
"volume_change_24h": 7.5353,
"percent_change_1h": 0.3400355,
"percent_change_24h": 0.05623531,
"percent_change_7d": -7.88809336,
"percent_change_30d": -25.12367453,
"percent_change_60d": -14.67776793,
"percent_change_90d": 6.86740691,
"market_cap": 914530483068.9261,
"market_cap_dominance": 40.8876,
"fully_diluted_market_cap": 1016275752755.46,
"last_updated": "2021-12-11T03:44:02.000Z"
}
}
}
*/
@immutable
@JsonSerializable()
class StockItem {
const StockItem({
required this.id,
required this.name,
required this.symbol,
required this.prices,
});
factory StockItem.fromJson(Json json) => _$StockItemFromJson(json);
final int id;
final String name;
final CryptoSymbol symbol;
@JsonKey(name: 'quote')
final Map<CryptoSymbol, ItemPrices> prices;
ItemPrices get usdPrices => prices['USD']!;
String imageUrl(int size) {
assert(size > 128 && size <= 250);
return '<https://s2.coinmarketcap.com/static/img/coins/${size}x$size/$id.png>';
}
Json toJson() => _$StockItemToJson(this);
}
Поле id появилось как необходимость для отображения логотипов валют. Так как исходный ресурс предоставляет их как раз по id.
И еще одна сущность, описывающая цены криптовалюты в валюте обычной:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import '../../../service/types/types.dart';
part 'item_prices.g.dart';
@immutable
@JsonSerializable()
class ItemPrices {
const ItemPrices({
required this.price,
required this.diff1h,
required this.diff24h,
});
factory ItemPrices.fromJson(Json json) => _$ItemPricesFromJson(json);
final double price;
@JsonKey(name: 'percent_change_1h')
final double diff1h;
@JsonKey(name: 'percent_change_24h')
final double diff24h;
Json toJson() => _$ItemPricesToJson(this);
}
Для сериализации / десериализации моделей я использовал json_serializable. Осталось только загрузить данные. Тут нам на помощь приходит кодогенерация в лице retrofit. Благодаря данному решению мы можем избавиться от необходимости написания хоть какой-то части бойлерплейта (но не всей). Сетевую логику, связанную с получением списка крипты, разместим в классе CryptoProvider.
import 'package:dio/dio.dart';
import 'package:high_low/domain/crypto/dto/stock_response.dart';
import 'package:retrofit/http.dart';
part 'crypto_provider.g.dart';
@RestApi(baseUrl: '<https://pro-api.coinmarketcap.com/v1/>')
abstract class CryptoProvider {
factory CryptoProvider(Dio dio, {String? baseUrl}) = _CryptoProvider;
@GET('cryptocurrency/listings/latest')
Future<StockResponse> fetchLatestData({
@Header('X-CMC_PRO_API_KEY') required String token,
@Query('limit') int limit = 1000,
});
}
Конечно же, в DI-регистратор была добавлена фабрика CryptoProvider и Dio:
import 'package:dio/dio.dart';
import 'package:flutter/widgets.dart';
import '../../domain/crypto/logic/crypto_provider.dart';
import '../routing/default_router_information_parser.dart';
import '../routing/page_builder.dart';
import '../routing/root_router_delegate.dart';
import 'di.dart';
void initDependencies() {
Di.reg<BackButtonDispatcher>(() => RootBackButtonDispatcher());
Di.reg<RouteInformationParser<Object>>(() => DefaultRouterInformationParser());
Di.reg<RouterDelegate<Object>>(() => RootRouterDelegate());
Di.reg(() => PageBuilder());
Di.reg(() => Dio(), asBuilder: true); // <--
Di.reg(() => CryptoProvider(Di.get()), asBuilder: true); // <--
}
На данном этапе у нас получается следующая структура проекта (внутренности service пока опускаю):
|-- domain
| `-- crypto
| |-- dto
| | |-- item_prices.dart
| | |-- stock_item.dart
| | |-- stock_item_example.json
| | `-- stock_response.dart
| `-- logic
| `-- crypto_provider.dart
|-- high_low_app.dart
|-- main.dart
`-- service
|-- config
|-- di
|-- logs
|-- routing
|-- theme
|-- tools
|-- types
Если вы задались вопросом, как получить такую картинку директории, вот ответ. Ну и на данном этапе работа с сетью завершена, все что нужно для отображения главного экрана у нас уже есть.
State
Вот мы и подбираемся к UI с логикой. Давайте начнем с последней, так как иначе она все равно заспойлерится в интерфейсе.
Но, прежде чем начать описывать состояние нашего приложения, нужно сделать большое лирическое отступление. Для тех, кто занимается разработкой приложений на Flutter не секрет, что Dart -- однопоточный язык с возможностью запуска нескольких, так называемых Isolate -- изолированных потоков со своим собственным Event Loop и памятью. И обычно, большинство разработчиков пишет весь код “просто в одном потоке”. То есть не заморачивается с тем, чтобы выносить тяжелые операции, потенциально блокирующие UI в отдельные изоляты (но я никого не виню, стандартное API весьма громоздкое, compute() не то, чтобы спасал, а различные сторонние библиотеки...ну кому они нужны?, изоляты -- сложно ведь). Со временем могут происходить неприятные изменения в приложении или данных, прилетающих с бэка, становится все больше и все начинает лагать. Из-за чего? Давайте проведем небольшое исследование.
Исследование
Я провел 3 эксперимента по 5 раз для двух окружений. Первое окружение: profile-сборка на флагманском устройстве (Samsung Galaxy Note 20 Ultra), находящемся в режиме “обычное использование” -- то есть я не перезагружал телефон перед каждым прогоном, но каждый раз выгружал из памяти приложение, а других активно запущенных приложений не было. Второе окружение: определенного рода симуляция слабого устройства, которое у пользователя вашего приложения тоже может оказаться - это эмулятор со следующими настройками:
2048Mb RAM
256Mb VM Heap
4 Cores CPU
Сам эмулятор был запущен на ноутбуке с Ryzen 7 5800H, никаких фоновых задач нет (только открытая IDEA).
Теперь к сути испытаний -- для главного экрана необходимо загрузить данные о криптовалютах. Я загружал их по 100, 1000 и 5000 штук за один запрос. По окончанию запроса измерял время, требуемое на преобразование ответа сервера (массив байт) в сырую JSON-строку, которая, затем, десереализуется в Map<String, dynamic>, все это - подкапотная логика Dio, в которую я добавил только логирование времени. Вторая операция, подвергнутая анализу - уже преобразование мапки в бизнес-классы, с которыми в реальном приложении мы и работаем.
Для того, чтобы внедрить логирование в Dio пришлось изрядно покопаться в его внутренних органах: все указанные преобразования происходят посредством класса Transformer. Данный класс можно написать самому и скормить Dio, а можно ничего и не делать -- тогда будет использоваться DefaultTransformer. Приведу тот кусок стандартного трансформера, который отвечает за то, чтобы вы смогли получить мапку на выходе (справа от каждой добавленной строки есть комментарий с префиксом <--, в котором описано, что тут происходит):
Future transformResponse(
RequestOptions options, ResponseBody response) async {
if (options.responseType == ResponseType.stream) {
return response;
}
var length = 0;
var received = 0;
var showDownloadProgress = options.onReceiveProgress != null;
if (showDownloadProgress) {
length = int.parse(
response.headers[Headers.contentLengthHeader]?.first ?? '-1');
}
var completer = Completer();
var stream =
response.stream.transform<Uint8List>(StreamTransformer.fromHandlers(
handleData: (data, sink) {
sink.add(data);
if (showDownloadProgress) {
received += data.length;
options.onReceiveProgress?.call(received, length);
}
},
));
// let's keep references to the data chunks and concatenate them later
final chunks = <Uint8List>[];
var finalSize = 0;
int totalDuration = 0; // <-- Total computation time in microseconds
int networkTime = 0; // <-- Time (microseconds), which will spend to accumulate parts of network response
StreamSubscription subscription = stream.listen(
(chunk) {
final start = DateTime.now().microsecondsSinceEpoch; // <-- Before saving each part of the data we start tracking the current time
finalSize += chunk.length;
chunks.add(chunk);
final now = DateTime.now().microsecondsSinceEpoch; // <--
totalDuration += now - start; // <-- After the chunk of data was saved, we check spent time
networkTime += now - start; // <--
},
onError: (Object error, StackTrace stackTrace) {
completer.completeError(error, stackTrace);
},
onDone: () => completer.complete(),
cancelOnError: true,
);
// ignore: unawaited_futures
options.cancelToken?.whenCancel.then((_) {
return subscription.cancel();
});
if (options.receiveTimeout > 0) {
try {
await completer.future
.timeout(Duration(milliseconds: options.receiveTimeout));
} on TimeoutException {
await subscription.cancel();
throw DioError(
requestOptions: options,
error: 'Receiving data timeout[${options.receiveTimeout}ms]',
type: DioErrorType.receiveTimeout,
);
}
} else {
await completer.future;
}
final start = DateTime.now().microsecondsSinceEpoch; // <-- Here we start tracking time before all chunks will be joined into the one Uint8List
final responseBytes = Uint8List(finalSize);
var chunkOffset = 0;
for (var chunk in chunks) {
responseBytes.setAll(chunkOffset, chunk);
chunkOffset += chunk.length;
}
totalDuration += DateTime.now().microsecondsSinceEpoch - start; // <-- And adding the new portion of time
if (options.responseType == ResponseType.bytes) return responseBytes;
String? responseBody;
if (options.responseDecoder != null) {
responseBody = options.responseDecoder!(
responseBytes,
options,
response..stream = Stream.empty(),
);
} else {
final start = DateTime.now().microsecondsSinceEpoch; // <-- We also tracked the decoding of the bytes into the string (raw JSON)
responseBody = utf8.decode(responseBytes, allowMalformed: true);
totalDuration += DateTime.now().microsecondsSinceEpoch - start; // <--
}
if (responseBody.isNotEmpty &&
options.responseType == ResponseType.json &&
_isJsonMime(response.headers[Headers.contentTypeHeader]?.first)) {
final callback = jsonDecodeCallback;
if (callback != null) {
return callback(responseBody);
} else {
final start = DateTime.now().microsecondsSinceEpoch; // <-- And finally - we track the decoding of the raw JSON string into the Map<String, dynamic>
final result = json.decode(responseBody);
totalDuration += DateTime.now().microsecondsSinceEpoch - start; // <--
print('TOTAL PARSING TIME: ${totalDuration / 1000}ms; NETWORK TIME: ${networkTime / 1000}ms'); // <--
return result;
}
}
return responseBody;
}
Ну и второй герой нашего времени - операция преобразования мапки в бизнес-сущности (для этого мы вклиниваем логирование в сгенерированный retrofit класс, в котором и описана вся логика получения данных):
Future<StockResponse> fetchLatestData({required token, limit = 1000}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{r'limit': limit};
final _headers = <String, dynamic>{r'X-CMC_PRO_API_KEY': token};
_headers.removeWhere((k, v) => v == null);
final _data = <String, dynamic>{};
final _result = await _dio.fetch<Map<String, dynamic>>(_setStreamType<StockResponse>(Options(method: 'GET', headers: _headers, extra: _extra)
.compose(_dio.options, 'cryptocurrency/listings/latest', queryParameters: queryParameters, data: _data)
.copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));
bench.start('STOCK RESPONSE DESERIALIZING'); // <-- At here we used the simple performance-tracker
final value = StockResponse.fromJson(_result.data!);
bench.end('STOCK RESPONSE DESERIALIZING'); // <--
return value;
}
Также стоит показать и код самого performance-tracker, используемого выше:
class _Benchmark {
final Map<String, int> _starts = <String, int>{};
void start(dynamic id) {
final String benchId = id.toString();
if (_starts.containsKey(benchId)) {
Logs.warn('Benchmark already have comparing with id=$benchId in time');
} else {
_starts[benchId] = DateTime.now().microsecondsSinceEpoch;
}
}
double end(dynamic id) {
final String benchId = id.toString();
if (!_starts.containsKey(benchId)) {
throw Exception('In Benchmark not placed comparing with id=$benchId');
}
final double diff = (DateTime.now().microsecondsSinceEpoch - _starts[benchId]!) / 1000;
final String info = '$benchId need ${diff}ms';
print(info);
_starts.remove(benchId);
return diff;
}
}
final _Benchmark bench = _Benchmark();
Как говорил кто-то там:
"Лучше показать таблицу с данными, чем ходить вокруг да около"
Поэтому, вот таблица, с дополнительной аннотацией полей:
-
Count -- количество элементов криптовалют, загружаемых за один запрос (да, да, в мире есть, как минимум, 5000 видов крипты)
-
Rows -- количество строк в JSON (если сделать Beautify в Postman)
-
Size -- размер данных в килобайтах
-
[P] / [D] -- префикс окружения, Profile / Debug (описано выше)
-
JSON -- время в милисекундах, потраченное непосредственно на то, чтобы Dio вернул нам мапку
-
Entity -- время в милисекундах, потраченное на то, чтобы преобразовать мапку в бизнес-сущности
-
Total -- сумма JSON + Entity
-
kB / ms - метрика, означающая, “сколько килобайт можно преобразовать за одну милисекунду”.
А вот мои выводы из этой таблицы:
- В лучшем случае, если у пользователя устройство верхнего ценового сегмента -- мы можем рассчитывать на то, что оно будет способно обработать до ~18kB/ms (возможно, самые новые флагманы будут способны и на большее).
- Ремарка про худший случай -- так как [D] окружение было запущено на эмуляторе с JIT-компиляцией, то мы имеем некоторые негативные экстремумы, связанные с тем, что код еще не разогрелся. Это отчетливо видно на объеме данных в 100 единиц - было потрачено чрезвычайно много времени, выбивающееся из статистики. Поэтому я не буду брать значение в 2.629kB/ms как минимальное, а возьму 8.603kB/ms, как более близкое к реальности. Делаем вывод -- мы можем рассчитывать на то, что устройство пользователя сможет обработать хотя бы ~9kB/ms.
- Будем исходить из того, что все большее количество девайсов обладает экранами с частотой обновления 120FPS, это значит, что у нас есть всего 8ms для отрисовки одного кадра, из этих 8ms какое-то время занимает сам процесс рендеринга, примерно, в среднем, это будет 2ms. Итого -- у нас осталось 6ms, чтобы сделать что-то и не потерять кадр. А это значит, что мы можем рассчитывать на то, что пользовательское устройство сможет обработать запрос с размером ответа в (18 + 9) / 2 * (8 - 2) = 81kB, чтобы не потерять ни одного кадра (это в идеале, если нет других негативных факторов). Если дисплей с 60FPS, то (18 + 9) / 2 * (16 - 2) = 189kB.
Что с этой информацией делать? Ну, например, мы можем сделать вывод, что если попытаться разобрать JSON в 1mb в главном потоке приложения, то мы гарантированно получим лаг в 80-160ms, и это уже будет бросаться в глаза пользователю. Если у вас много запросов с жирными ответами -- интерфейс будет лагать намного чаще. Как с этим можно бороться, я уже однажды рассказывал. И пора продолжить этот старый рассказ.
Isolate
С недавним релизом Dart 2.15 произошли позитивные изменения в возможностях использования изолятов. Главным новшеством стал новый метод Isolate.exit(), который позволяет завершить текущий сторонний изолят, передавая в SendPort данные, которые прилетят в соответствующий ReceivePort за константное время. При этом, глубокого копирования, которое происходило раньше, до появления данного метода -- не происходит, а значит -- мы не заблочим наш UI-поток, когда он будет получать большую порцию данных одномоментно из стороннего изолята. Все это доступно “из коробки” посредством старой доброй функции compute(). С её помощью можно выносить вычисления, произодимые в отдельных функциях в сторонний изолят и быстро получать результаты обратно.
Относительно простым решением будет создание своего Transformer, который будет парсить ответы в стороннем изоляте и возвращать результат.
Но, как говорилось в первой статье -- я хочу показать еще и использование своих библиотек, а не только этапы создания приложения и так уж вышло, что у меня есть библиотека isolator, созданная для упрощения работы с изолятами и позволяющая вынести вообще всю логику в сторонние Stateful изоляты. Эти сторонние изоляты, в контексте библиотеки, носят название Backend. И к ним в нагрузку идут легковесные реактивные компаньоны, называемые Frontend -- это может быть любой класс из любого менеджера управления состоянием -- Bloc, Mobx, ChangeNotifier и т. д. К этому классу добавляется mixin Frontend и вы получаете возможность общения с соответствующим Backend. До выхода Dart 2.15 эта библиотека решала одну узкую, но фундаментальную проблему (чтобы её не пришлось решать самостоятельно) -- возможность передачи данных неограниченного объема из стороннего изолята в главный без блокировки последнего. С появлением метода Isolate.exit() эта проблема, кажется, ушла сама собой, поэтому теперь данная библиотека просто позволяет не нагружать основной поток ничем, кроме отрисовки UI (впрочем, как и раньше).
В данный момент на pub.dev доступна первая версия, но при этом все основные работы по написанию v2 завершены, но пока не опубликованы, поэтому если вы захотите попробовать -- можно установить из git:
isolator:
git:
url: <https://github.com/alphamikle/isolator.git>
ref: next
Среди прочих нововведений второй версии присутствует возможность прозрачного использования этого же кода в вебе (но пока еще в разработке). Isolate API не имеет поддержки в вебе, как таковой, однако, при использовании isolator весь код будет работать как и обычно, но в главном потоке.
Frontend
Для начала приложу весь код, а затем буду разбирать каждый из его блоков по отдельности:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:isolator/isolator.dart';
import 'package:isolator/next/maybe.dart';
import '../../../service/di/di.dart';
import '../../../service/di/registrations.dart';
import '../../../service/tools/localization_wrapper.dart';
import '../../crypto/dto/stock_item.dart';
import '../../notification/logic/notification_service.dart';
import 'main_backend.dart';
enum MainEvent {
init,
loadStocks,
startLoadingStocks,
endLoadingStocks,
filterStocks,
updateFilteredStocks,
}
class MainFrontend with Frontend, ChangeNotifier {
late final NotificationService _notificationService;
late final LocalizationWrapper _localizationWrapper;
final List<StockItem> stocks = [];
bool isLaunching = true;
bool isStocksLoading = false;
bool errorOnLoadingStocks = false;
TextEditingController searchController = TextEditingController();
TextEditingController tokenController = TextEditingController();
bool _isInLaunchProcess = false;
bool _isLaunched = false;
String _prevSearch = '';
Future<void> loadStocks() async {
errorOnLoadingStocks = false;
final Maybe<StockItem> stocks = await run(event: MainEvent.loadStocks);
if (stocks.hasList) {
_update(() {
this.stocks.clear();
this.stocks.addAll(stocks.list);
});
}
if (stocks.hasError) {
_update(() {
errorOnLoadingStocks = true;
});
await _notificationService.showSnackBar(content: _localizationWrapper.loc.main.errors.loadingError);
}
}
Future<void> launch({
required NotificationService notificationService,
required LocalizationWrapper localizationWrapper,
}) async {
if (!isLaunching || _isLaunched || _isInLaunchProcess) {
return;
}
_notificationService = notificationService;
_localizationWrapper = localizationWrapper;
_isInLaunchProcess = true;
searchController.addListener(_filterStocks);
await initBackend(initializer: _launch);
_isInLaunchProcess = false;
_isLaunched = true;
_update(() => isLaunching = false);
}
void _filterStocks() {
if (_prevSearch != searchController.text) {
_prevSearch = searchController.text;
run(event: MainEvent.filterStocks, data: searchController.text);
}
}
void _setFilteredStocks({required MainEvent event, required List<StockItem> data}) {
_update(() {
stocks.clear();
stocks.addAll(data);
});
}
void _startLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = true;
});
}
void _endLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = false;
});
}
void _update(VoidCallback dataChanger) {
dataChanger();
notifyListeners();
}
static MainBackend _launch(BackendArgument<void> argument) {
initDependencies();
return MainBackend(argument: argument, cryptoProvider: Di.get());
}
@override
void initActions() {
whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);
whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);
whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);
}
}
Логика работы библиотеки, отчасти, похожа на Bloc -- необходимо зарегистрировать обработчики сообщений, прилетающих с Backend. Регистрируются они в методе initActions:
@override
void initActions() {
whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);
whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);
whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);
}
В качестве идентификатора события выступает любая сущность, но важно то, что проверка на соответствие будет происходить через обычное равенство ==. Также, можно зарегистрировать обработчик на определенный тип идентификаторов, в этом случае он будет обрабатывать все события, идентифицируемые конкретно этим типом:
class SpecificMessageId {
const SpecificMessageId(this.someValue);
final int someValue;
}
void initActions() {
whenEventCome<SpecificMessageId>().run(_specificHandler);
}
Стоит добавить несколько слов и о самих обработчиках. Все обработчики должны соответствовать следующему типу (не соответствующие не получится зарегистрировать):
typedef FrontendAction<Event, Req, Res> =
FutureOr<Res> Function({required Event event, required Req data});
Но, при этом, значение data не обязательно должно прилетать. Идентификатор-событие event будет прилетать всегда. То есть, следующие обработчики зарегистрируются и будут корректными:
void _startLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = true;
});
}
void _endLoadingStocks({required MainEvent event, void data}) {
_update(() {
isStocksLoading = false;
});
}
Смысл обработчиков заключается в том, что если вы ходите только реагировать на события, инициированные Backend -- нужен обработчик. Если же вы хотите вызвать какой-то метод Backend -- можно обойтись и без обработчиков вовсе.
При вызове любого Backend-метода из Frontend вы всегда получите какой-нибудь ответ “на месте”, завернутый в своеобразный union-type Maybe<T>. Union-типов в Dart на данный момент нет, кроме одного встроенного FutureOr<T>, поэтому, для корректной типизации данных методов пришлось создавать Maybe<T>, он может включать в себя просто T, List<T> или ошибку, ну или вообще все три -- null, если метод Backend не возвращает ничего (но, на самом деле, Backend-методы всегда должны возвращать кое-что, что вы увидите немного ниже).
Следующий код демонстрирует вызов MainBackend метода по event = MainEvent.loadStocks и получение результата сразу в месте вызова:
Future<void> loadStocks() async {
errorOnLoadingStocks = false;
final Maybe<StockItem> stocks = await run(event: MainEvent.loadStocks);
if (stocks.hasList) {
_update(() {
this.stocks.clear();
this.stocks.addAll(stocks.list);
});
}
if (stocks.hasError) {
_update(() {
errorOnLoadingStocks = true;
});
await _notificationService.showSnackBar(content:
_localizationWrapper.loc.main.errors.loadingError);
}
}
Немного забегая наперед, покажу и соответствующий этому event метод MainBackend, который и будет исполнен в стороннем изоляте:
Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {
await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
try {
final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
_stocks.clear();
_stocks.addAll(stockItems);
} catch (error) {
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
rethrow;
}
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
return ActionResponse.list(_stocks);
}
Пока не буду описывать его содержимое, об этом будет ниже.
Следующий метод launch нужен для инициализации MainFrontend и MainBackend. В нем вызывается метод initBackend миксина Frontend, в который необходимо передать, как минимум, один аргумент: функцию-инициализатор, которая запустится уже в стороннем изоляте, и эта функция должна возвращать инстанс соответствующего Backend.
Future<void> launch({
required NotificationService notificationService,
required LocalizationWrapper localizationWrapper,
}) async {
if (!isLaunching || _isLaunched || _isInLaunchProcess) {
return;
}
_notificationService = notificationService;
_localizationWrapper = localizationWrapper;
_isInLaunchProcess = true;
searchController.addListener(_filterStocks);
await initBackend(initializer: _launch);
_isInLaunchProcess = false;
_isLaunched = true;
_update(() => isLaunching = false);
}
Давайте взглянем на нее поближе:
static MainBackend _launch(BackendArgument<void> argument) {
initDependencies();
return MainBackend(argument: argument, CryptoProvider: Di.get());
}
В этой функции нам необходимо повторно инициализировать Di-контейнер, так как сторонний изолят не знает ничего о том, что происходило в главном и все фабрики в стороннем изоляте не зарегистрированы. Требования к функции-инициализатору аналогичны требованиям к оригинальной функции entryPoint, используемой в Isolate API. А вот её интерфейс:
typedef BackendInitializer<T, B extends Backend> =
B Function(BackendArgument<T> argument);
Также, Frontend позволяет регистрировать хуки, вызываемые на каждое сообщение от Backend, только на сообщения, которые должны принудительно заставить Frontend уведомить UI об изменении данных; можно подписаться (например одному Frontend на другой), посредством метода subscribeOnEvent. Об этом будет сказано немного подробнее в блоке про UI.
Backend
Я начну с метода Frontend, который вызывается для получения данных о крипте. При первичной отрисовке главного экрана в хуке initState виджета MainView происходит инициализация MainFrontend (см. метод MainFrontend.launch). По завершению которой вызывается метод loadStocks (который был разобран выше):
// main_view.dart
Future<void> _launchMainFrontend() async {
final MainFrontend mainFrontend = Provider.of(context, listen: false);
await mainFrontend.launch(notificationService: Provider.of(context, listen: false), localizationWrapper: Provider.of(context, listen: false));
await mainFrontend.loadStocks();
}
@override
void initState() {
super.initState();
_launchMainFrontend();
// ...
}
Выше уже отсветил один из методов MainBackend, что-же, вот теперь пора представить и сам класс, который будет существовать в отдельном изоляте на протяжении жизни всего приложения:
import 'dart:async';
import '../../crypto/logic/crypto_provider.dart';
import 'package:isolator/isolator.dart';
import '../../crypto/dto/stock_item.dart';
import 'main_frontend.dart';
typedef StockItemFilter = bool Function(StockItem);
class MainBackend extends Backend {
MainBackend({
required BackendArgument<void> argument,
required CryptoProvider cryptoProvider,
}) : _cryptoProvider = cryptoProvider,
super(argument: argument);
final CryptoProvider _cryptoProvider;
final List<StockItem> _stocks = [];
Timer? _searchTimer;
Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {
await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
try {
final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
_stocks.clear();
_stocks.addAll(stockItems);
} catch (error) {
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
rethrow;
}
await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
return ActionResponse.list(_stocks);
}
ActionResponse<StockItem> _filterStocks({required MainEvent event, required String data}) {
final String searchSubString = data;
send(event: MainEvent.startLoadingStocks);
_searchTimer?.cancel();
_searchTimer = Timer(const Duration(milliseconds: 500), () async {
_searchTimer = null;
final List<StockItem> filteredStocks = _stocks.where(_stockFilterPredicate(searchSubString)).toList();
await send(
event: MainEvent.updateFilteredStocks,
data: ActionResponse.list(filteredStocks),
);
await send(event: MainEvent.endLoadingStocks);
});
return ActionResponse.empty();
}
StockItemFilter _stockFilterPredicate(String searchSubString) {
final RegExp filterRegExp = RegExp(searchSubString, caseSensitive: false, unicode: true);
return (StockItem item) {
if (searchSubString.isEmpty) {
return true;
}
return filterRegExp.hasMatch(item.symbol) || filterRegExp.hasMatch(item.name);
};
}
@override
void initActions() {
whenEventCome(MainEvent.loadStocks).run(_loadStocks);
whenEventCome(MainEvent.filterStocks).run(_filterStocks);
}
}
По аналогии с Frontend в любом Backend есть возможность регистрации обработчиков событий с тем же самым API, но небольшим отличием в типе обработчика:
typedef BackendAction<Event, Req, Res> = FutureOr<ActionResponse<Res>> Function({required Event event, required Req data});
Отличие заключается в том, что если Frontend обработчик может не возвращать ничего, то Backend обработчик обязан возвращать результат вида ActionResponse<T>, либо падать с ошибкой. Это является следствием определенных ограничений при работе с типами в Dart.
Также, обработчик является выходной точкой любого Backend, каждый из которых может вызывать обработчики любого другого Backend, делается это посредством специальных сущностей Interactor.
Теперь разберем подробнее метод получения криптовалют. Перед началом загрузки мы посылаем сообщение в MainFrontend, чтобы отобразить в интерфейсе, что идет процесс загрузки.
await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
Затем, происходит сама загрузка данных и их сохранение в MainBackend для возможности локального поиска.
final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
_stocks.clear();
_stocks.addAll(stockItems);
Теперь начинается кое-что интересное, что стало возможным с выходом Dart 2.15. Упомянутая выше возможность библиотеки передавать любой объем данных без просадки кадров достигается (раньше достигалась) посредством разбиения массива данных на чанки и передачей этих чанков во Frontend по очереди. Логика тут была простая, если данных много -- их можно так или иначе представить в виде массива, а его можно без проблем разбить на маленькие куски и передать без проблем с производительностью. Собственно, эта старая логика отображена передачей данных, завернутых в специальный wrapper Chunks:
await send(
event: MainEvent.loadStocks,
data: ActionResponse.chunks(
Chunks(
data: _stocks,
updateAfterFirstChunk: true,
size: 100,
delay: const Duration(milliseconds: 8),
),
),
);
При этом сборка чанков во Frontend происходила “магически-автоматически”, и обработчик, который ожидал получения большой пачки данных -- просто получал свой готовый огромный массив. Все эти возможности придется выпилить, так как особого смысла от них теперь нет.
С приходом новой версии Dart стало возможным передавать любой объем данных любого типа за константное время и без ограничений по типу передаваемых данных -- теперь можно без проблем передавать не только массивы, но и любую другую структуру, если это необходимо. Сейчас достаточно использовать обычный метод отправки сообщений, который будет использовать под капотом пресловутый Isolate.exit:
await send(
event: MainEvent.loadStocks,
data: ActionResponse.list(_stocks),
);
При этом, как говорит документация, возможность быстрой передачи данных доступна только при уничтожении отправляющего изолята. А так как наш MainBackend (да и любой другой Backend) -- стремится жить на протяжении существования всего приложения (по крайней мере такова их задумка, но их и без проблем можно закрывать, но, всё-таки, не таким способом), то использовать Isolate.exit напрямую в этом изоляте нельзя -- он, по большому счету, завершится аварийно. Чтобы обойти это недоразумение наш Backend создает дополнительный транспортный изолят, в который классическим способом (глубоким копированием средствами Dart VM) передается любое количество данных, никак не влияющее на UI-изолят, а затем этот одноразовый транспортный изолят уничтожается, передавая при этом, данные в наш UI-изолят.
Вернемся к разбору нашего метода загрузки крипты. Так как мы организуем “синхронный” вызов Backend-метода из Frontend, то наш Backend-метод должен вернуть этот результат:
return ActionResponse.list(_stocks);
Также, при отправке события начала загрузки данных был указан дополнительный параметр sendDirectly, думаю, самое время описать и его -- так как мы не всегда передаём большое количество данных из Backend во Frontend, то и не всегда нужно пользоваться услугами транспортного изолята -- можно передавать данные напрямую. Если это необходимо -- использование данного параметра позволит отправлять сообщения без сторонней помощи.
Локальный поиск
Более подробно останавливаться на методе локального поиска останавливаться не буду, так как, кажется, статья уже стала лонгридом 🙂. Работает он как поиск по регулярному выражению. Могу добавить только то, что вы можете получить ответ на главный вопрос вселенной с его помощью и даже немного больше.
UI
После завершения данного этапа структура домена main станет такой:
|-- domain
| `-- main
| |-- logic
| | |-- main_backend.dart
| | `-- main_frontend.dart
| `-- ui
| |-- main_header.dart
| |-- main_view.dart
| `-- stock_item_tile.dart
|-- high_low_app.dart
`-- main.dart
Опишем содержимое папочки ui:
main_view.dart содержит StatefulWidget главного экрана
import 'package:flutter/material.dart';
import 'package:isolator/next/frontend/frontend_event_subscription.dart';
import 'package:provider/provider.dart';
import 'package:yalo_assets/lib.dart';
import 'package:yalo_locale/lib.dart';
import '../../../service/theme/app_theme.dart';
import '../../../service/tools/utils.dart';
import '../../crypto/dto/stock_item.dart';
import '../../notification/logic/notification_service.dart';
import '../logic/main_frontend.dart';
import 'main_header.dart';
import 'stock_item_tile.dart';
class MainView extends StatefulWidget {
const MainView({Key? key}) : super(key: key);
@override
_MainViewState createState() => _MainViewState();
}
class _MainViewState extends State<MainView> {
MainFrontend get _mainFrontend => Provider.of(context);
late final FrontendEventSubscription<MainEvent> _eventSubscription;
Widget _stockItemBuilder(BuildContext context, int index) {
final StockItem item = _mainFrontend.stocks[index];
final bool isFirst = index == 0;
final bool isLast = index == _mainFrontend.stocks.length - 1;
return Padding(
padding: EdgeInsets.only(
left: 8,
top: isFirst ? 8 : 0,
right: 8,
bottom: isLast ? MediaQuery.of(context).padding.bottom + 8 : 8,
),
child: StockItemTile(item: item),
);
}
void _onSearchEnd(MainEvent event) {
final MainFrontend mainFrontend = Provider.of<MainFrontend>(context, listen: false);
final LocalizationMessages loc = Messages.of(context);
final int stocksCount = mainFrontend.stocks.length;
final String content = loc.main.search.result(stocksCount);
Provider.of<NotificationService>(context, listen: false).showSnackBar(
content: content,
backgroundColor: AppTheme.of(context, listen: false).okColor,
);
}
Future<void> _launchMainFrontend() async {
final MainFrontend mainFrontend = Provider.of(context, listen: false);
await mainFrontend.launch(notificationService: Provider.of(context, listen: false), localizationWrapper: Provider.of(context, listen: false));
await mainFrontend.loadStocks();
}
@override
void initState() {
super.initState();
_launchMainFrontend();
_eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(
listener: _onSearchEnd,
event: MainEvent.updateFilteredStocks,
onEveryEvent: true,
);
}
@override
void dispose() {
_eventSubscription.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Assets assets = Provider.of<Assets>(context, listen: false);
final AppTheme theme = AppTheme.of(context);
final MaterialStateProperty<Color> buttonColor = MaterialStateProperty.resolveWith((states) => theme.buttonColor);
final ButtonStyle buttonStyle = ButtonStyle(
foregroundColor: buttonColor,
overlayColor: MaterialStateProperty.resolveWith((states) => theme.splashColor),
shadowColor: buttonColor,
);
final List<String> notFoundImages = [
assets.notFound1,
assets.notFound2,
assets.notFound3,
assets.notFound4,
].map((e) => e.replaceFirst('assets/', '')).toList();
Widget body;
if (_mainFrontend.isLaunching) {
body = Center(
child: Text(Messages.of(context).main.loading),
);
} else if (_mainFrontend.errorOnLoadingStocks) {
body = Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Image.asset(notFoundImages[Utils.randomIntBetween(0, notFoundImages.length - 1)]),
),
TextButton(
onPressed: _mainFrontend.loadStocks,
style: buttonStyle,
child: Text(Messages.of(context).main.repeat),
),
],
),
),
);
} else {
body = CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
const MainHeader(),
SliverList(
delegate: SliverChildBuilderDelegate(
_stockItemBuilder,
childCount: _mainFrontend.stocks.length,
),
),
],
);
}
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: body,
),
);
}
}
Что есть интересного здесь? Инициализацию MainFrontend уже обсудили, остался только подписчик на события. Кстати, вот он:
_eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(
listener: _onSearchEnd,
event: MainEvent.updateFilteredStocks,
onEveryEvent: true,
);
Вызов данного метода позволяет уведомляться в том, что наш MainFrontend получил сообщение соответствующего типа от MainBackend. Метод subscribeOnEvent является частью Frontend в принципе.
В результате мы получаем такие уведомления, каждый раз, когда нам прилетает порция данных после поиска:
И это -- является подводкой к теме локализации приложений на Flutter.
Локализация интерфейса
Уже довольно давно я задавался вопросом -- как можно быстро локализовать приложение на Flutter. Если взглянуть на официальный гайд -- то первое впечатление “без бутылки не разберешься”. Второе, собственно -- тоже. И тогда я подумал, что если избавиться от громоздкого .arb, и вместо него использовать .yaml? Так родился пакет assets_codegen (ссылку я не прикладываю, так как он deprecated). Его идея была в следующем -- располагаем файлы локализации в ассетах, аннотируем какой-нибудь класс, чтобы к нему цеплялся код локализации, запускаем flutter pub run build_runner watch и наслаждаемся. Решение было более чем работоспособным, но имелись и минусы -- логика отслеживания изменений в файлах локализации была написана руками, а котогенерация Dart не позволяет отслеживать изменения не в Dart-файлах, и результат совмещения стандартного кодогенератора и рукописного вотчера иной раз удручал. В общем было много раздражающих багов. И вот однажды, уже имея некоторое понимание, как часто приходится добавлять новые строки локализации и сразу же после этого ожидать их появления в коде (спойлер -- крайне редко), я решил написать полностью новый пакет, еще и название которого, родившееся в моей голове, очень мне понравилось.
Так появился пакет yalo. С предельно простой логикой (описанной в документации) -- размещаем файлы локализации в ассетах, запускаем генератор командой
flutter pub run yalo:loc, подключаем к проекту сгенерированный локальный пакет .yalo_locale, используем пару переменных в корневой ...App:
import 'package:flutter/material.dart';
import 'package:yalo_locale/lib.dart';
import 'service/di/di.dart';
class HighLowApp extends StatelessWidget {
const HighLowApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routeInformationParser: Di.get<RouteInformationParser<Object>>(),
routerDelegate: Di.get<RouterDelegate<Object>>(),
backButtonDispatcher: Di.get<BackButtonDispatcher>(),
theme: Theme.of(context).copyWith(brightness: Brightness.dark),
debugShowCheckedModeBanner: false,
localizationsDelegates: localizationsDelegates, // <-- 1
supportedLocales: supportedLocales, // <-- 2
onGenerateTitle: (BuildContext context) => Messages.of(context).common.appTitle,
);
}
}
И используем локализованный контент. С плюрализацией, префиксами, сколько угодно глубокой вложенностью и подстановкой (пока только для числовых данных в плюрализированных строках). Примеры использования вы уже могли заметить выше, но продемонстрирую их отдельно.
Генерация названия приложения:
(BuildContext context) => Messages.of(context).common.appTitle
Подсказка поля ввода поиска:
Messages.of(context).main.search.hint
Количество элементов после поиска в SnackBar:
Messages.of(context).main.search.result(
Provider.of<MainFrontend>(context, listen: false).stocks.length)
Появляется это все из такого файлика:
main:
loading: Загрузка...
search:
hint: Поиск
result:
zero: Мы ничего не нашли
one: Мы нашли ${howMany} элемент
two: Мы нашли ${howMany} элемента
other: Мы нашли ${howMany} элементов
common:
currency: '\$'
percent: '%'
appTitle: High Low
Точнее, файликов, лежащих вот так:
|-- README.md
|-- analysis_options.yaml
|-- assets
| `-- i18
| |-- en_intl.yaml
| `-- ru_intl.yaml
`-- watch.sh
Но вместо префикса файла, можно раскладывать их по папкам -- ../ru/intl.dart
Заключение
На этот раз статья поспела за кодом и все, что реализовано -- тут описано. В третьей статье я сделаю полностью второй экран (учитывая графики и игровую механику, возможно третья часть выйдет во время новогодних праздников), покажу работу с ассетами здорового человека и implicit-анимацию любого текста.
И еще, приложу изменения, которые произошли со времени первой части. И, код текущего состояния проекта.
Особая секция
Как сцены после титров в Marvel -- данная секция для особых зрителей читателей. Уже дописав данную статью я был практически готов её опубликовать. Но чувство перфекционизма старательно откусывало от меня кусочки -- на момент “готовности” статьи isolator не был доработан настолько, чтобы было можно использовать его и в web. И ещё мне хотелось показать не только картинки приложения, но и дать возможность его “потыкать”. И вот я за пару вечеров добавил возможность работы в web (как и прежде -- без многопоточности, но с сохранением полной работоспособности без изменений в вашем коде). Затем встал вопрос о публикации приложения. Публиковать в сторах я планирую в самом конце, а пока можно было бы сделать это на github.pages. Тут-то и начинается самое интересное.
Запустил web-версию локально, все отлично работает, за исключением одного NO! -- API сервиса, который я начал использовать изначально, не позволяет осуществлять CORS-запросы, “чтобы не палить ваши токены авторизации”, видимо, про реверс API приложений они не слышали. Ну да ладно. Я начал искать способы, как можно обойти это ограничение без необходимости пилить свой собственный proxy, хостить его где-то и т. д. Нашел curl-online, сделал запрос через него (через интерфейс самого сервиса) -- все заработало. Сразу начал делать web-имплементацию CryptoProvider, который бы использовался в web-сборке и ходил за данными через web-curl. И снова:
У меня локально все работает
Деплой на github.pages → и снова CORS, но уже у самого курла (почему я не додумался выполнить этот запрос из консоли браузера со страницы приложения на pages - очень большой вопроc). Время - час ночи, и я неунывающими красными глазами начинаю пялить в код пишушейся прокси для этого всего. Еще пол часа и глаза говорят “пора спать”. Проснувшись на следующий день, рано утром, я снова начал искать способы не писать прокси и, видимо, правду говорят - утро вечера мудренее, я додумываюсь поискать альтернативу самому API. И первый же запрос в гугл предоставляет мне прекрасную, полностью бесплатную, без авторизаций (и с очень небольшими ограничениями), апишку.
С одной стороны -- я безмерно рад тому, что не придется пилить никакие прокси, и также рад тому, что смогу показать вам как оно работает в вебе без всяких “но”, но с другой -- если бы я сначала подумал, поискал, а не бросился пилить код, сэкономил бы часов 8 жизни...
В общем результаты таковы, что isolator v2 теперь полностью готов к использованию. Ну и вы можете взглянуть на web-версию того, что уже реализовано. У API есть ограничение на 50 вызовов в минуту, так что если сработает хабраэффект -- вы увидите Экран ошибки, на котором будет достаточно нажать одну кнопку.
Ассеты
Если бы не особая секция, и все страдания, которые там описаны, этот раздел действительно был бы должен оказаться в третьей статье. Изначально я хотел показать работу с ними в этой, но во время написания статьи понял, что нет особых мест, кроме как придуманных исскуственно, где они были бы к месту. Затем, во время реализации логики, связанной с возможностью исчерпания лимита моего токена авторизации на первом ресурсе появилось место, где ассеты будут к месту. Идея была такова -- если ресурс моего токена заканчивается, то при получении ошибки во время запроса отобразится дополнительный экран, где будет висеть какая-нибудь прикольная картинка, а также инпут для ввода вашего собственного токена авторизации, с которым бы у вас лично все заработало. После перехода на новое API логика по использованию вашего токена отпала сама собой, но, потенциально, осталась возможность наткнуться на ошибку из-за лимитов API по RPS. Поэтому, если вы увидите данный экран -- то хабраэффект сработал.
А теперь к самой работе с ассетами! Упомянутый выше пакет yalo, позволяет не только генерировать локализацию из .yaml файлов, но также, он позволяет генерировать код с именами всех ассетов, лежащих в вашей папке assets (или любой другой, если она корректно указана в pubspec.yaml). Сейчас структура папки assets данного проекта имеет следующий вид:
./assets
|-- i18
| |-- en_intl.yaml
| `-- ru_intl.yaml
`-- images
|-- notFound_1.png
|-- notFound_2.png
|-- notFound_3.png
`-- notFound_4.png
При условии, что у вас в проекте уже установлен данный пакет, вы можете запустить следующую команду:
flutter pub run yalo:asset
Результатом такой команды будет сгенерированный пакет .yalo_assets в корне вашего проекта, который, по аналогии с .yalo_locale нужно добавить в pubspec.yaml:
dependencies:
//...
yalo_locale:
path: ./.yalo_locale
yalo_assets:
path: ./.yalo_assets
После этих манипуляций вы получаете доступ к классу со статическими и обычными геттерами:
class Assets {
String get enIntl => enIntlS;
static const String enIntlS = 'assets/i18/en_intl.yaml';
String get ruIntl => ruIntlS;
static const String ruIntlS = 'assets/i18/ru_intl.yaml';
String get notFound1 => notFound1S;
static const String notFound1S = 'assets/images/notFound_1.png';
String get notFound2 => notFound2S;
static const String notFound2S = 'assets/images/notFound_2.png';
String get notFound3 => notFound3S;
static const String notFound3S = 'assets/images/notFound_3.png';
String get notFound4 => notFound4S;
static const String notFound4S = 'assets/images/notFound_4.png';
}
Я опустил некоторые дополнительные методы, имеющиеся в данном классе, так как особой востребованностью они не пользовались.
Чем это может быть полезно? Главный плюс -- автодополнение. Дополнительный -- у вас появляется возможность отслеживать ассеты на уровне кода. Если какой-либо файл будет удален или изменено его имя -- код на это отреагирует и вы получите статическую ошибку, вместо отлова её в рантайме (если не уследили за этим). Разрешение коллизий имен ассетов (например два файла в одинаковым именем, лежащих в разных папках) тоже есть, и выглядит вот так:
class Assets {
String get enIntl => enIntlS;
static const String enIntlS = 'assets/i18/en_intl.yaml';
String get ruIntl => ruIntlS;
static const String ruIntlS = 'assets/i18/ru_intl.yaml';
String get notFound => notFoundS;
static const String notFoundS = 'assets/images/blabla/notFound.png';
String get notFound1 => notFound1S;
static const String notFound1S = 'assets/images/notFound_1.png';
String get notFound2 => notFound2S;
static const String notFound2S = 'assets/images/notFound_2.png';
String get notFound3 => notFound3S;
static const String notFound3S = 'assets/images/notFound_3.png';
String get notFound4 => notFound4S;
static const String notFound4S = 'assets/images/notFound_4.png';
String get notFoundCopy => notFoundCopyS;
static const String notFoundCopyS = 'assets/images/old_content/notFound.png';
String get notFoundCopyCopy => notFoundCopyCopyS;
static const String notFoundCopyCopyS = 'assets/images/something_else/notFound.png';
String get notFoundCopyCopyCopy => notFoundCopyCopyCopyS;
static const String notFoundCopyCopyCopyS = 'assets/images/very_important_content/notFound.png';
String get notFound3Copy => notFound3CopyS;
static const String notFound3CopyS = 'assets/images/very_important_content/notFound_3.png';
}
Окончательное заключение
Надо было что-то оставить на самый финал -- на этом действительно все.
Больше материалов смотрите в моем блоге на Хабре.
<!doctype html>
<html lang="ru" class="no-js no-touch ">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="yandex-verification" content="3019a35aeda6b45d" />
<meta name="robots" content="noindex"/>
<script type="text/javascript">
var html = document.getElementsByTagName('html')[0];
html.className = html.className.replace('no-js', '');
window.React = {};
window.metrics = [];
window.TEST_ENV = false;
window.isSuperuser = false;
window.isStaff = false;
window.isHeadTeacher = false;
</script>
<script type="text/javascript">
window.DEBUG_COUNTERS = true;
var yaParams = {
'course_title': 'b',
'ab': 'b',
'features': JSON.parse('{"category-catalog-redirect": true, "landing-price-mode-switcher": true, "register-instead-of-start-test": true, "main-page-redesign": true, "subscription_genus_basic": true, "adblender": true, "greenlight": true, "phone": true, "professions": true, "course_enrol": true, "new_lessons_page": true, "jivosite": false, "course-page-single-screen": true, "right-price": false, "course_page_header_footer_new": true, "submit-application": false, "prof_dev_certificate": false, "assessment-react": true, "new-events-calendar": true, "new-pre-assessment-screen": true, "recommended_courses": true, "tinkoff_payment": true, "payment-page-3-front-refactor": true, "installment-calculator": true, "boomstream-player": true, "finsystems": true, "new-reviews": true}')
};
</script>
<!-- MindBox JavaScript SDK --->
<script>
mindbox = window.mindbox || function() { mindbox.queue.push(arguments); };
mindbox.queue = mindbox.queue || [];
mindbox('create');
</script>
<script src="https://api.mindbox.ru/scripts/v1/tracker.js" async></script>
<!-- End MindBox JavaScript SDK --->
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function (ids) {
function yamInit(d, w, c, id) {
(w[c] = w[c] || []).push(function () {
try {
const metrika = new Ya.Metrika2({
id: id,
params: window.yaParams || {},
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
webvisor: true
});
w.yaMetriks.push(metrika);
w['yaCounter' + id] = metrika;
} catch (e) {
}
});
var n = d.getElementsByTagName('script')[0],
s = d.createElement('script'),
f = function () {
n.parentNode.insertBefore(s, n);
};
s.type = 'text/javascript';
s.async = true;
s.src = 'https://mc.yandex.ru/metrika/tag.js';
if (w.opera == '[object Opera]') {
d.addEventListener('DOMContentLoaded', f, false);
} else {
f();
}
}
window['yaMetriks'] = [];
(Array.isArray(ids) ? ids : [ids]).forEach(id => {
if (id) {
yamInit(document, window, 'yandex_metrika_callbacks2', id)
}
})
})([34531570, 82755226, 93715742])
</script>
<!-- /Yandex.Metrika counter -->
<script type="text/javascript" id="advcakeAsync">
(function (a) {
var b = a.createElement('script');
b.async = 1;
b.src = '//0gs25f.ru/';
a = a.getElementsByTagName('script')[0];
a.parentNode.insertBefore(b, a)
})(document);
window.advcake_data = window.advcake_data || [];
</script>
<script>
!(function (w, d, t) {
w.TiktokAnalyticsObject = t;
var ttq = (w[t] = w[t] || []);
(ttq.methods = [
'page',
'track',
'identify',
'instances',
'debug',
'on',
'off',
'once',
'ready',
'alias',
'group',
'enableCookie',
'disableCookie'
]),
(ttq.setAndDefer = function (t, e) {
t[e] = function () {
t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
};
});
for (var i = 0; i < ttq.methods.length; i++)
ttq.setAndDefer(ttq, ttq.methods[i]);
(ttq.instance = function (t) {
for (var e = ttq._i[t] || [], n = 0; n < ttq.methods.length; n++)
ttq.setAndDefer(e, ttq.methods[n]);
return e;
}),
(ttq.load = function (e, n) {
var i = 'https://analytics.tiktok.com/i18n/pixel/events.js';
(ttq._i = ttq._i || {}),
(ttq._i[e] = []),
(ttq._i[e]._u = i),
(ttq._t = ttq._t || {}),
(ttq._t[e] = +new Date()),
(ttq._o = ttq._o || {}),
(ttq._o[e] = n || {});
var o = document.createElement('script');
(o.type = 'text/javascript'),
(o.async = !0),
(o.src = i + '?sdkid=' + e + '&lib=' + t);
var a = document.getElementsByTagName('script')[0];
a.parentNode.insertBefore(o, a);
});
ttq.load("C4IDL5C17T561FR1EMKG");
ttq.page();
})(window, document, 'ttq');
</script>
<script>
window.vkAsyncInit = function () {
VK.Retargeting.Init("VK-RTRG-410987-bLXUv");
VK.Retargeting.Hit();
}
</script>
<script src="//vk.com/js/api/openapi.js?159" async></script>
<noscript>
<img src="https://vk.com/rtrg?p=VK-RTRG-410987-bLXUv"
style="position:fixed; left:-999px;" alt="" />
</noscript>
<!-- rick.ai/q -->
<script type="text/javascript">
(function(e) {
var t = e.createElement("script");
t.src = "https://store-b2b.ru/tag.js?id=wsse7xcbtr07r1";
t.type = "module";
t.async = true;
t.crossorigin = "anonymous";
e.head.appendChild(t)
})(document)
</script>
<script type="text/javascript" nomodule src="https://store-b2b.ru/tag.js?id=wsse7xcbtr07r1&nomodule"></script>
<!-- end rick.ai/q -->
<script type="text/javascript">
!(function (n, e, t, r, a, s) {
function i(n, r) {
const a = e.createElement(t),
s = e.getElementsByTagName(t)[0];
(a.async = 1),
(a.src = n),
(a.onerror = r),
s.parentNode.insertBefore(a, s);
}
(n.SalesNinja = ['init', 'start', 'onPersonalization', 'reachGoal'].reduce(
(e, t) => {
return (
(e[t] = function () {
const e = Array.prototype.slice.call(arguments);
e.unshift(t), n[r].apply(0, e);
}),
e
);
},
{ k: r, ready: !1 }
)),
(n[r] = function () {
let e,
t,
a = new Promise((n, r) => {
(e = n), (t = r);
});
return (
(n[r].r = n[r].r || []).push({ s: e, f: t }),
(n[r].c = n[r].c || []).push(arguments),
a
);
}),
i(a, () => {
i(s);
});
})(
window,
document,
'script',
'ninja',
'https://cdn.sales-ninja.me/userBundle.js',
'https://bundle.sales-ninja.me/userBundle.js'
);
ninja('init', 'c20cc0ff-6d2f-42ac-9b66-1103c735a13a');
ninja('start');
</script>
<script type="text/javascript">
window.TMR_PIXEL_ID = 3316675;
var _tmr = window._tmr || (window._tmr = []);
_tmr.push({id: "3316675", type: "pageView", start: (new Date()).getTime()});
(function (d, w, id) {
if (d.getElementById(id)) return;
var ts = d.createElement("script"); ts.type = "text/javascript"; ts.async = true; ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js";
var f = function () {var s = d.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ts, s);};
if (w.opera == "[object Opera]") { d.addEventListener("DOMContentLoaded", f, false); } else { f(); }
})(document, window, "tmr-code");
</script>
<noscript>
<div>
<img src="https://top-fwz1.mail.ru/counter?id=3316675;js=na"
style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" />
</div>
</noscript>
<!-- Pixel Tag Code -->
<script type="text/javascript">
(function (t, l, g, r, m) {
t[g] ||
((g = t[g] =
function () {
g.run ? g.run.apply(g, arguments) : g.queue.push(arguments);
}),
(g.queue = []),
(t = l.createElement(r)),
(t.async = !0),
(t.src = m),
(l = l.getElementsByTagName(r)[0]),
l.parentNode.insertBefore(t, l));
})(window, document, 'tgp', 'script', 'https://telegram.org/js/pixel.js');
tgp('init', '4bxSybss');
</script>
<!-- End Pixel Tag Code -->
<!-- GTM is no more -->
<link rel="canonical" href="https://otus.ru/nest/post/2430/"/>
<link rel="amphtml" href="https://otus.ru/nest/post/2430/?amp"/>
<title>Разработка приложения на Flutter с нуля до релиза: Part 2 | OTUS</title>
<meta property="og:type" content="website"/>
<meta property="og:url" content="https://otus.ru/nest/post/2430/"/>
<meta property="og:title" content="Разработка приложения на Flutter с нуля до релиза: Part 2 | OTUS">
<meta property="og:description" content="Разработка приложения на Flutter с нуля до релиза: Part 2 в OTUS, только интересные посты!">
<meta name="description" content="Разработка приложения на Flutter с нуля до релиза: Part 2 в OTUS, только интересные посты!">
<meta property="og:site_name" content="Otus">
<meta property="fb:app_id" content="486413851704844"/>
<meta property="og:image" content="/static/img/favicons/android-chrome-537x240.jpg?nocache">
<meta property="og:image:width" content="537">
<meta property="og:image:height" content="240">
<link href="/static/img/favicons/android-chrome-537x240.jpg" rel="image_src"/>
<meta property="vk:image" content="/static/img/favicons/android-chrome-537x240.jpg">
<link href="/static/img/favicons/apple-touch-icon.png" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-57x57.png" sizes="57x57" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-60x60.png" sizes="60x60" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-72x72.png" sizes="72x72" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-76x76.png" sizes="76x76" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-114x114.png" sizes="114x114" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-120x120.png" sizes="120x120" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-144x144.png" sizes="144x144" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-152x152.png" sizes="152x152" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-180x180.png" sizes="180x180" rel="apple-touch-icon"/>
<link type="image/png" href="/static/img/favicons/favicon-32x32.png" sizes="32x32" rel="icon"/>
<link type="image/png" href="/static/img/favicons/favicon-16x16.png" sizes="16x16" rel="icon"/>
<link type="image/x-icon" href="/static/img/favicons/favicon.ico" rel="shortcut icon"/>
<link rel="mask-icon" href="/static/img/favicons/safari-pinned-tab.svg" color="#000000"/>
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/static/img/favicons/mstile-144x144.png">
<meta name="theme-color" content="#FFFFFF"/>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
<meta name="sw" content="https://otus.ru/static/js/service-worker.8ed2b.js"/>
<meta name="csrf" id="meta-csrf" content="FGazzZi3liZOHCpItqwEyQ9u0ycfja6NrnxR5Iek8iIwY5puzoRdYxHKg3KMa8pi"/>
<meta name="auth" content="false"/>
<meta name="phone_confirmed" content="false"/>
<meta name="next" content='{"value": "/nest/post/2430/"}'/>
<link href="https://otus.ru/static/css/vendor.react.dd7f4.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/vendor.common.5ac2f.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/otus-react:header-search.37c7a.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/fonts.211eb.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/otus-icons.e3e2d.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/vendor.common.5ac2f.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/vendor.otus-scss.59b5e.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/otus-scss.2de41.css" rel="stylesheet" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Otus",
"url": "https://otus.ru",
"logo": "https://otus.ru/__new_static__/img/meta-image.png",
"sameAs": [
"https://vk.com/otusru",
"https://t.me/Otusjava"
]
}
</script>
</head>
<body class=" body-header3">
<script type="application/ld+json">
{
"@context" : "http://schema.org",
"@type" : "Organization",
"name" : "OTUS",
"url" : "https://otus.ru",
"logo": "https://otus.ru/static/img/favicons/apple-touch-icon-180x180.png",
"sameAs": [
"",
"https://www.youtube.com/channel/UCetgtvy93o3i3CvyGXKFU3g",
"https://www.instagram.com/otus.ru/"
],
"contactPoint": [
{
"@type": "ContactPoint",
"telephone": "+7-499-938-92-02",
"contactType": "customer service",
"areaServed": "RU"
}
]
}
</script>
<!-- TODO FIXME DRY -->>
<script
src="https://smartcaptcha.yandexcloud.net/captcha.js?render=onload&onload=smartCaptchaInit"
defer
></script>
<script>
function smartCaptchaInit() {
const sitekey = 'ysc1_cM9ClhSx0kwuG9QxSMfFmxHnC1gsW7Axbyddkmzref6982c0';
const test = false;
if (!window.smartCaptcha || !sitekey) {
return;
}
const widgetId = window.smartCaptcha.render('captcha-container', {
sitekey,
invisible: true,
test,
hideShield: true,
});
}
</script>
<div id="captcha-container"></div>
<script src="https://otus.ru/js-18n"></script>
<script>
window.texts = {
companyEmail: "help@otus.ru",
separateQuestionCount: " из "
}
window.language = 'ru-ru'
window.config = {
isEnablePhoneConfirm: true
}
</script>
<script src="https://otus.ru/js-18n"></script>
<script>
window.texts = {
companyEmail: "help@otus.ru",
separateQuestionCount: " из "
}
window.language = 'ru-ru'
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://otus.ru/nest/post/2430/"
},
"headline": "Разработка приложения на Flutter с нуля до релиза: Part 2",
"image": "",
"author": {
"@type": "Person",
"name": "Михаил",
"url": "https://otus.ru/profile/174576/"
},
"publisher": {
"@type": "Organization",
"name": "OTUS",
"logo": {
"@type": "ImageObject",
"url": "https://otus.ru/static/img/favicons/apple-touch-icon-180x180.png"
}
},
"datePublished": "2022-01-13T20:20:44.050115+03:00",
"dateModified": ""
}
</script>
<div class="body-wrapper">
<div class="body body_header3 drawer body_not-subscribed drawer--right blog-drawer ">
<div class="before-header-ui">
<div class="before-header-ui__ellipse1"></div>
<div class="before-header-ui__ellipse2"></div>
<div class="before-header-ui__container">
<div class="before-header-ui__img before-header-ui__img_sales"></div>
<div class="before-header-ui__content">
<div class="before-header-ui__title hide-phone">Курсы по нейросетям со скидкой до 30%</div>
<div class="before-header-ui__title show-phone">Курсы по нейросетям со скидкой до 30%</div>
</div>
<a href="https://otus.ru/catalog/courses?categories=neural_networks&utm_source=internal&utm_medium=free&utm_campaign=otus&utm_term=chank&utm_content=sla_sale_20-02-2026-10-04-2026" rel="nofollow noreferrer noopener" target="_blank" class="before-header-ui__button">Выбрать курс</a>
</div>
</div>
<header class="header3 js-header3">
<div class="header3__container">
<a class="header3__logo" href="/">
<img
class="header3__logo-img"
src="/static/img/logos/logo-2022-without-text.svg"
width="82"
height="42"
alt="Logo"
/>
</a>
<nav class="header3__nav">
<div id="headerSearch" class="header3__nav-item header3__nav-item-search">
<div class="header-search-icon">
<svg
class="header-search-icon__icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="11.767"
cy="11.767"
r="8.989"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></circle>
<path
d="M18.018 18.485 21.542 22"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
</div>
<div data-name="learning" class="header3__nav-item js-header3-popup-trigger header3__nav-item_only-desktop header3__nav-item_with-hover " >
<span title="Обучение" class="header3__nav-item-arrow-title">Обучение</span>
<div class="header3__nav-item-arrow-container js-header3-popup-arrow">
<svg
width="10"
height="5"
viewBox="0 0 10 5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="header3__nav-item-arrow"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.10067 0.378818C1.29593 0.183556 1.61251 0.183555 1.80778 0.378818L5.00023 3.57127L8.19272 0.378777C8.38798 0.183515 8.70457 0.183515 8.89983 0.378777C9.09509 0.574039 9.09509 0.890622 8.89983 1.08588L5.3643 4.62142C5.26426 4.72146 5.13237 4.77024 5.00127 4.76777C4.8695 4.77079 4.73676 4.72202 4.6362 4.62146L1.10067 1.08592C0.905408 0.890663 0.905408 0.57408 1.10067 0.378818Z"
fill="currentColor"
></path>
</svg>
</div>
</div>
<div class="header3__nav-item-popup-wrapper js-header3-popup" data-name="learning" style="display: none;">
<div class="header3__nav-item-popup-container js-header3-popup-container">
<div class="header3__nav-item-popup-content">
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Направления</p>
<div class="header3__nav-section-items header3__nav-section-items_learning header3__nav-section-items_learning_rows-8">
<a
class="header3__nav-section-item"
href="/categories/programming/"
>
Программирование (117)
</a>
<a
class="header3__nav-section-item"
href="/categories/architecture/"
>
Архитектура (17)
</a>
<a
class="header3__nav-section-item"
href="/categories/data-science/"
>
Data Science (27)
</a>
<a
class="header3__nav-section-item"
href="/categories/operations/"
>
Инфраструктура (58)
</a>
<a
class="header3__nav-section-item"
href="/categories/gamedev/"
>
GameDev (10)
</a>
<a
class="header3__nav-section-item"
href="/categories/information-security-courses/"
>
Безопасность (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/marketing-business/"
>
Управление (46)
</a>
<a
class="header3__nav-section-item"
href="/categories/analytics/"
>
Аналитика и анализ (25)
</a>
<a
class="header3__nav-section-item"
href="/categories/business-product/"
>
Бизнес и продукт в IT (26)
</a>
<a
class="header3__nav-section-item"
href="/categories/import-substitution/"
>
Импортозамещение (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/testing/"
>
Тестирование (12)
</a>
<a
class="header3__nav-section-item"
href="/categories/neural_networks/"
>
Нейросети (9)
</a>
<a
class="header3__nav-section-item"
href="/categories/it-bez-programmirovanija/"
>
IT без программирования (19)
</a>
<a
class="header3__nav-section-item"
href="/categories/corporate/"
>
Корпоративные курсы (27)
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">События</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/lessons/calendar/2026/"
>
Календарь запуска курсов
</a>
<a
class="header3__nav-section-item"
href="/events/near/"
>
Календарь мероприятий
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Другое</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/categories/spec/"
>
Специализации (13)
</a>
<a
class="header3__nav-section-item"
href="/categories/online/"
>
Подготовительные курсы (14)
</a>
<a
class="header3__nav-section-item header3__nav-section-item_bold"
href="/subscription"
>
Подписка на курсы
</a>
<a
class="header3__nav-section-item"
href="/tests"
>
Проверьте свои знания
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
</div>
</div>
<svg
class="header3__nav-item-popup-figure"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M125.573 140.619C131.659 71.6017 210.245 34.9562 267.027 74.6573L553.942 275.262C610.723 314.962 603.117 401.233 540.247 430.55L222.58 578.681C159.71 607.997 88.7344 558.37 94.8204 489.355L125.573 140.619Z"
stroke="#eaeaea"
class="header3__nav-item-popup-figure-spinner"
></path>
<path
d="M148.472 246.647C133.624 191.005 184.615 140.013 240.257 154.862L519.856 229.476C575.498 244.325 594.059 313.877 553.266 354.67L348.281 559.656C307.488 600.449 237.935 581.888 223.087 526.246L148.472 246.647Z"
fill="url(#paint0_linear-learning)"
></path>
<defs>
<linearGradient
id="paint0_linear-learning"
x1="128.696"
y1="395.739"
x2="443.538"
y2="180.173"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#f9f9f9"></stop>
<stop offset="1" stop-color="#EBEBEB"></stop>
</linearGradient>
</defs>
</svg>
</div>
</div>
<div data-name="info" class="header3__nav-item js-header3-popup-trigger header3__nav-item_only-desktop header3__nav-item_with-hover " >
<span title="Информация" class="header3__nav-item-arrow-title">Информация</span>
<div class="header3__nav-item-arrow-container js-header3-popup-arrow">
<svg
width="10"
height="5"
viewBox="0 0 10 5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="header3__nav-item-arrow"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.10067 0.378818C1.29593 0.183556 1.61251 0.183555 1.80778 0.378818L5.00023 3.57127L8.19272 0.378777C8.38798 0.183515 8.70457 0.183515 8.89983 0.378777C9.09509 0.574039 9.09509 0.890622 8.89983 1.08588L5.3643 4.62142C5.26426 4.72146 5.13237 4.77024 5.00127 4.76777C4.8695 4.77079 4.73676 4.72202 4.6362 4.62146L1.10067 1.08592C0.905408 0.890663 0.905408 0.57408 1.10067 0.378818Z"
fill="currentColor"
></path>
</svg>
</div>
</div>
<div class="header3__nav-item-popup-wrapper js-header3-popup" data-name="info" style="display: none;">
<div class="header3__nav-item-popup-container js-header3-popup-container">
<div class="header3__nav-item-popup-content">
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">OTUS</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/about"
>
О компании
</a>
<a
class="header3__nav-section-item"
href="/smi/"
>
СМИ о нас
</a>
<a
class="header3__nav-section-item js-stats"
href="/journal/"
target="_blank"
rel="noreferrer nofollow"
data-event="header;click_otus_journal"
data-goal="click_otus_journal"
>
OTUS Журнал
</a>
<a
class="header3__nav-section-item"
href="https://direct.otus.ru/"
target="_blank"
rel="noreferrer nofollow"
>
OTUS Директ
</a>
<a
class="header3__nav-section-item"
href="/legal/common/"
>
Сведения об образовательной организации
</a>
<a
class="header3__nav-section-item"
href="/contacts/"
>
Контактная информация
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Студентам</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/reviews"
>
Отзывы
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/about-otus"
>
Как выбрать курс
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/gallery"
>
Истории выпускников
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/employers/all/"
>
Наши партнеры
</a>
<a
class="header3__nav-section-item"
href="/about/loyalty/"
>
Программа лояльности
</a>
<a
class="header3__nav-section-item"
href="/faq/"
>
Вопросы и ответы
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Преподавателям</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/teach/"
>
Стать преподавателем
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/nest/dlja-prepodavatelej/"
>
База знаний
</a>
</div>
</div>
</div>
<div class="header3__nav-column header3__nav-column_space-between">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Ответим на ваши вопросы</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item header3__nav-section-item_phone"
rel="noopener noreferrer"
href="tel:+7 499 938-92-02"
>
<svg
class="header3__phone-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.352 2.75a7.971 7.971 0 0 1 7.041 7.032M14.352 6.293a4.426 4.426 0 0 1 3.5 3.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
clip-rule="evenodd"
d="M7.7 16.299C.803 9.4 1.783 6.241 2.51 5.223c.094-.164 2.396-3.611 4.865-1.589 6.126 5.045-1.63 4.332 3.514 9.477 5.146 5.144 4.431-2.611 9.477 3.514 2.022 2.469-1.425 4.771-1.588 4.864-1.018.728-4.178 1.709-11.078-5.19Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
+7 499 938-92-02
</a>
</div>
</div>
</div>
</div>
<svg
class="header3__nav-item-popup-figure"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M125.573 140.619C131.659 71.6017 210.245 34.9562 267.027 74.6573L553.942 275.262C610.723 314.962 603.117 401.233 540.247 430.55L222.58 578.681C159.71 607.997 88.7344 558.37 94.8204 489.355L125.573 140.619Z"
stroke="#eaeaea"
class="header3__nav-item-popup-figure-spinner"
></path>
<path
d="M148.472 246.647C133.624 191.005 184.615 140.013 240.257 154.862L519.856 229.476C575.498 244.325 594.059 313.877 553.266 354.67L348.281 559.656C307.488 600.449 237.935 581.888 223.087 526.246L148.472 246.647Z"
fill="url(#paint0_linear-info)"
></path>
<defs>
<linearGradient
id="paint0_linear-info"
x1="128.696"
y1="395.739"
x2="443.538"
y2="180.173"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#f9f9f9"></stop>
<stop offset="1" stop-color="#EBEBEB"></stop>
</linearGradient>
</defs>
</svg>
</div>
</div>
<a class="header3__nav-item header3__nav-item-b2b header3__nav-item_with-hover" href="/b2b">
Компаниям
</a>
</nav>
<div class="header3__nav header3__nav_right">
<div data-name="" class="header3__nav-item js-header3-popup-trigger js-open-modal-reg header3__button-sign-up-container" >
<button class="header3__button-sign-up">
<svg class="header3__button-sign-up-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
clip-rule="evenodd"
d="M9.922 21.808c-3.814 0-7.072-.577-7.072-2.887s3.237-4.41 7.072-4.41c3.814 0 7.072 2.08 7.072 4.39 0 2.308-3.237 2.907-7.072 2.907ZM9.922 11.216A4.534 4.534 0 1 0 5.39 6.683a4.518 4.518 0 0 0 4.501 4.533h.032Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
d="M19.131 8.13v4.01M21.178 10.134h-4.09"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Зарегистрироваться
</button>
</div>
<div data-name="" class="header3__nav-item js-header3-popup-trigger js-open-modal-login header3__button-sign-in-container" data-modal-id="new-log-reg">
<button class="header3__button-sign-in">
Войти
</button>
</div>
<div class="header3__hamburger">
<button data-name="hamburger" class="header3__hamburger-button js-header3-popup-trigger">
<svg
class="header3__hamburger-icon js-header3-popup-trigger-icon-default"
width="28"
height="22"
viewBox="0 0 28 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.15999 0.119995C1.09961 0.119995 0.23999 0.979576 0.23999 2.04C0.23999 3.10035 1.09961 3.96 2.15999 3.96H25.84C26.9004 3.96 27.76 3.10035 27.76 2.04C27.76 0.979576 26.9004 0.119995 25.84 0.119995H2.15999ZM2.15999 9.08C1.09961 9.08 0.23999 9.93957 0.23999 11C0.23999 12.0604 1.09961 12.92 2.15999 12.92H25.84C26.9004 12.92 27.76 12.0604 27.76 11C27.76 9.93957 26.9004 9.08 25.84 9.08H2.15999ZM2.15999 18.04C1.09961 18.04 0.23999 18.8996 0.23999 19.96C0.23999 21.0204 1.09961 21.88 2.15999 21.88H25.84C26.9004 21.88 27.76 21.0204 27.76 19.96C27.76 18.8996 26.9004 18.04 25.84 18.04H2.15999Z"
fill="currentColor"
></path>
</svg>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="header3__hamburger-icon-close js-header3-popup-trigger-icon-close"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0.75C11.3096 0.75 10.75 1.30964 10.75 2V10.75L2 10.75C1.30964 10.75 0.75 11.3096 0.75 12C0.75 12.6904 1.30964 13.25 2 13.25L10.75 13.25L10.75 22C10.75 22.6904 11.3096 23.25 12 23.25C12.6904 23.25 13.25 22.6904 13.25 22L13.25 13.25L22 13.25C22.6904 13.25 23.25 12.6904 23.25 12C23.25 11.3096 22.6904 10.75 22 10.75L13.25 10.75V2C13.25 1.30964 12.6904 0.75 12 0.75Z"
fill="currentColor"
></path>
</svg>
</button>
<div
data-name="hamburger"
data-only-click="true"
class="header3__nav-item-popup-wrapper js-header3-popup"
style="display: none;"
>
<div
class="header3__nav-item-popup-container js-header3-popup-container"
>
<svg
class="header3__nav-item-popup-figure"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M125.573 140.619C131.659 71.6017 210.245 34.9562 267.027 74.6573L553.942 275.262C610.723 314.962 603.117 401.233 540.247 430.55L222.58 578.681C159.71 607.997 88.7344 558.37 94.8204 489.355L125.573 140.619Z"
stroke="#eaeaea"
class="header3__nav-item-popup-figure-spinner"
></path>
<path
d="M148.472 246.647C133.624 191.005 184.615 140.013 240.257 154.862L519.856 229.476C575.498 244.325 594.059 313.877 553.266 354.67L348.281 559.656C307.488 600.449 237.935 581.888 223.087 526.246L148.472 246.647Z"
fill="url(#paint0_linear-hamburger)"
></path>
<defs>
<linearGradient
id="paint0_linear-hamburger"
x1="128.696"
y1="395.739"
x2="443.538"
y2="180.173"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#f9f9f9"></stop>
<stop offset="1" stop-color="#EBEBEB"></stop>
</linearGradient>
</defs>
</svg>
<div class="header3__nav-item-popup-content header3__hamburger-tabs">
<ul>
<li
class="header3__hamburger-tabs-tab header3__hamburger-tabs-tab_active"
>
Обучение
</li>
<li class="header3__hamburger-tabs-tab">
Информация
</li>
<li data-url="/b2b" class="header3__hamburger-tabs-tab header3__hamburger-tabs-tab_link">
Компаниям
</li>
</ul>
<div class="header3__hamburger-tabs-tab-content-wrapper">
<div
class="header3__hamburger-tabs-tab-content header3__hamburger-tabs-tab-learning header3__hamburger-tabs-tab-content_active"
>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Направления</p>
<div class="header3__nav-section-items header3__nav-section-items_learning header3__nav-section-items_learning_rows-8">
<a
class="header3__nav-section-item"
href="/categories/programming/"
>
Программирование (117)
</a>
<a
class="header3__nav-section-item"
href="/categories/architecture/"
>
Архитектура (17)
</a>
<a
class="header3__nav-section-item"
href="/categories/data-science/"
>
Data Science (27)
</a>
<a
class="header3__nav-section-item"
href="/categories/operations/"
>
Инфраструктура (58)
</a>
<a
class="header3__nav-section-item"
href="/categories/gamedev/"
>
GameDev (10)
</a>
<a
class="header3__nav-section-item"
href="/categories/information-security-courses/"
>
Безопасность (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/marketing-business/"
>
Управление (46)
</a>
<a
class="header3__nav-section-item"
href="/categories/analytics/"
>
Аналитика и анализ (25)
</a>
<a
class="header3__nav-section-item"
href="/categories/business-product/"
>
Бизнес и продукт в IT (26)
</a>
<a
class="header3__nav-section-item"
href="/categories/import-substitution/"
>
Импортозамещение (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/testing/"
>
Тестирование (12)
</a>
<a
class="header3__nav-section-item"
href="/categories/neural_networks/"
>
Нейросети (9)
</a>
<a
class="header3__nav-section-item"
href="/categories/it-bez-programmirovanija/"
>
IT без программирования (19)
</a>
<a
class="header3__nav-section-item"
href="/categories/corporate/"
>
Корпоративные курсы (27)
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">События</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/lessons/calendar/2026/"
>
Календарь запуска курсов
</a>
<a
class="header3__nav-section-item"
href="/events/near/"
>
Календарь мероприятий
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Другое</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/categories/spec/"
>
Специализации (13)
</a>
<a
class="header3__nav-section-item"
href="/categories/online/"
>
Подготовительные курсы (14)
</a>
<a
class="header3__nav-section-item header3__nav-section-item_bold"
href="/subscription"
>
Подписка на курсы
</a>
<a
class="header3__nav-section-item"
href="/tests"
>
Проверьте свои знания
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
</div>
</div>
<div class="header3__hamburger-tabs-tab-content">
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">OTUS</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/about"
>
О компании
</a>
<a
class="header3__nav-section-item"
href="/smi/"
>
СМИ о нас
</a>
<a
class="header3__nav-section-item js-stats"
href="/journal/"
target="_blank"
rel="noreferrer nofollow"
data-event="header;click_otus_journal"
data-goal="click_otus_journal"
>
OTUS Журнал
</a>
<a
class="header3__nav-section-item"
href="https://direct.otus.ru/"
target="_blank"
rel="noreferrer nofollow"
>
OTUS Директ
</a>
<a
class="header3__nav-section-item"
href="/legal/common/"
>
Сведения об образовательной организации
</a>
<a
class="header3__nav-section-item"
href="/contacts/"
>
Контактная информация
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Студентам</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/reviews"
>
Отзывы
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/about-otus"
>
Как выбрать курс
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/gallery"
>
Истории выпускников
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/employers/all/"
>
Наши партнеры
</a>
<a
class="header3__nav-section-item"
href="/about/loyalty/"
>
Программа лояльности
</a>
<a
class="header3__nav-section-item"
href="/faq/"
>
Вопросы и ответы
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Преподавателям</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/teach/"
>
Стать преподавателем
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/nest/dlja-prepodavatelej/"
>
База знаний
</a>
</div>
</div>
</div>
<div class="header3__nav-column header3__nav-column_space-between">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Ответим на ваши вопросы</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item header3__nav-section-item_phone"
rel="noopener noreferrer"
href="tel:+7 499 938-92-02"
>
<svg
class="header3__phone-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.352 2.75a7.971 7.971 0 0 1 7.041 7.032M14.352 6.293a4.426 4.426 0 0 1 3.5 3.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
clip-rule="evenodd"
d="M7.7 16.299C.803 9.4 1.783 6.241 2.51 5.223c.094-.164 2.396-3.611 4.865-1.589 6.126 5.045-1.63 4.332 3.514 9.477 5.146 5.144 4.431-2.611 9.477 3.514 2.022 2.469-1.425 4.771-1.588 4.864-1.018.728-4.178 1.709-11.078-5.19Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
+7 499 938-92-02
</a>
</div>
</div>
</div>
</div>
</div>
<div class="header3__hamburger-pagination">
<div
class="header3__hamburger-pagination-button header3__hamburger-pagination-button_prev"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.75 11.726h-15M13.7 5.701l6.05 6.024-6.05 6.025"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
<div class="header3__hamburger-pagination-items"></div>
<div
class="header3__hamburger-pagination-button header3__hamburger-pagination-button_next"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.75 11.726h-15M13.7 5.701l6.05 6.024-6.05 6.025"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</header>
<div class="blog js-blog">
<div class="blog__nav-wrapper">
<div class="nav nav_blue nav_mobile-fix">
<div class="nav__scroll">
<div class="container container-relative container-overflow-auto">
<div class="nav__items nav__float-left">
<a href="/nest/" class="nav__item" title="Блоги">Блоги</a>
<a href="/nest/posts/" class="nav__item nav__item_divider nav__item_divider-pad" title="Посты">
Посты
</a>
<a href="/nest/best/" class="nav__item nav__item_divider"
title="Лучшие">
Лучшие
</a>
<a href="/nest/users/" class="nav__item" title="Участники">Участники</a>
<div class="nav__item show-md">
<form action="/nest/search/" method="get" class="inline-block search js-search">
<div class="search__box js-search-box search__box_close">
<input autocomplete="off"
name="q"
placeholder="Поиск блогов, постов, текста в постах"
class="search__input input"
/>
<i class="ic ic-close search__box-close js-cancel"></i>
<button class="button button_gray2 search__box-button">
<i class="ic ic-search-black search__icon"></i>Найти
</button>
</div>
<div class="search__button js-open-search">
<i class="ic ic-search search__icon"></i>Поиск
</div>
</form>
<form action="/nest/post/add/" method="get" class="js-form-need-auth inline-block">
<input type="hidden" name="blog" value="95"/>
</form>
</div>
</div>
<div class="nav__items nav__float-right hide-md">
<div class="nav__item vertical-middle">
<form action="/nest/search/" method="get" class="inline-block search js-search">
<div class="search__box js-search-box search__box_close">
<input autocomplete="off"
name="q"
placeholder="Поиск блогов, постов, текста в постах"
class="search__input input"
/>
<i class="ic ic-close search__box-close js-cancel"></i>
<button class="button button_gray2 search__box-button">
<i class="ic ic-search-black search__icon"></i>Найти
</button>
</div>
<div class="search__button js-open-search">
<i class="ic ic-search search__icon"></i>Поиск
</div>
</form>
<form action="/nest/post/add/" method="get" class="js-form-need-auth inline-block">
<input type="hidden" name="blog" value="95"/>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="blog__content">
<div class="container">
<div class="container__row container__row_gutter-24">
<div class="container__col container__col_8 container__col_md-12">
<div class="blog-post">
<div class="blog__tile blog-post__tile">
<article class="blog-post-content">
<div class="post-info">
<a href="/profile/174576/">
<div class="post-info__avatar ic ic-blog-default-avatar" style="background-image: url(https://cdn.otus.ru/media/public/12/2e/avatar-1801-122eb1.png);"></div>
<div class="post-info__author">Михаил</div>
</a>
<div class="post-info__time">13.01.22 в 17:20</div>
</div>
<h1 class="blog__h1">Разработка приложения на Flutter с нуля до релиза: Part 2</h1>
<div class="post-info">
<div class="post-info__blogs">
<a href="/nest/android/" class="post-info__blog" title="Android" >Android</a> →
<a href="/nest/andoird-art/" class="post-info__blog" title="Полезные материалы по Android" >Полезные материалы по Android</a>
</div>
<div class="post-info__tags">
Теги: разработка, flutter
</div>
</div>
<div class="blog-post-text blog-post-text_markdown markdown">
<p>Это вторая статья из цикла о разработке приложения на Flutter (предыдущая находится <a href="https://otus.ru/nest/post/2429/">здесь</a>). В этом "номере" я опишу создание сетевого слоя, работу с локализацией, удобный способ работы с ассетами, локальный поиск и создание UI для одного из двух экранов приложения. Также я выведу интересные метрики, например -- сколько данных сможет распарсить ваше приложение за одну милисекунду и начиная с какого размера JSON’а, прилетевшего с бэка UI начнет тормозить. Как говорится -- с места... В карьер! <cut></cut></p>
<p><img alt="c59479d0a9ca9471ba6996fe930fdf86_1-1801-b0f02c.jpg" src="https://cdn.otus.ru/media/public/b0/f0/c59479d0a9ca9471ba6996fe930fdf86_1-1801-b0f02c.jpg"></p>
<h2>Сеть</h2>
<p>Для отрисовки первого экрана необходимы следующие данные:</p>
<p><img alt="23105306c072341ee39d9cfae83fddc3_1-1801-20121f.png" src="https://cdn.otus.ru/media/public/20/12/23105306c072341ee39d9cfae83fddc3_1-1801-20121f.png"></p>
<pre><div class="codehilite"><pre><span></span><span class="n">image</span>
<span class="n">title</span>
<span class="n">subtitle</span>
<span class="n">price</span>
<span class="n">diff</span>
</pre></div>
</pre>
<p>Исходя из этого получаем следующую сущность, описывающую каждый из токенов:</p>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'package:flutter/foundation.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:json_annotation/json_annotation.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../../service/types/types.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'item_prices.dart'</span><span class="p">;</span>
<span class="k">part</span> <span class="s1">'stock_item.g.dart'</span><span class="p">;</span>
<span class="c1">// BTC, ETH etc.</span>
<span class="kd">typedef</span> <span class="n">CryptoSymbol</span> <span class="o">=</span> <span class="kt">String</span><span class="p">;</span>
<span class="cm">/* Example of data:</span>
<span class="cm">{</span>
<span class="cm"> "id": 1,</span>
<span class="cm"> "name": "Bitcoin",</span>
<span class="cm"> "symbol": "BTC",</span>
<span class="cm"> "max_supply": 21000000,</span>
<span class="cm"> "circulating_supply": 18897568,</span>
<span class="cm"> "total_supply": 18897568,</span>
<span class="cm"> "platform": null,</span>
<span class="cm"> "cmc_rank": 1,</span>
<span class="cm"> "last_updated": "2021-12-11T03:44:02.000Z",</span>
<span class="cm"> "quote": {</span>
<span class="cm"> "USD": {</span>
<span class="cm"> "price": 48394.083464545605,</span>
<span class="cm"> "volume_24h": 32477191827.784477,</span>
<span class="cm"> "volume_change_24h": 7.5353,</span>
<span class="cm"> "percent_change_1h": 0.3400355,</span>
<span class="cm"> "percent_change_24h": 0.05623531,</span>
<span class="cm"> "percent_change_7d": -7.88809336,</span>
<span class="cm"> "percent_change_30d": -25.12367453,</span>
<span class="cm"> "percent_change_60d": -14.67776793,</span>
<span class="cm"> "percent_change_90d": 6.86740691,</span>
<span class="cm"> "market_cap": 914530483068.9261,</span>
<span class="cm"> "market_cap_dominance": 40.8876,</span>
<span class="cm"> "fully_diluted_market_cap": 1016275752755.46,</span>
<span class="cm"> "last_updated": "2021-12-11T03:44:02.000Z"</span>
<span class="cm"> }</span>
<span class="cm"> }</span>
<span class="cm">}</span>
<span class="cm"> */</span>
<span class="err">@</span><span class="n">immutable</span>
<span class="err">@</span><span class="n">JsonSerializable</span><span class="p">()</span>
<span class="kd">class</span> <span class="nc">StockItem</span> <span class="p">{</span>
<span class="kd">const</span> <span class="n">StockItem</span><span class="p">({</span>
<span class="n">required</span> <span class="k">this</span><span class="p">.</span><span class="n">id</span><span class="p">,</span>
<span class="n">required</span> <span class="k">this</span><span class="p">.</span><span class="n">name</span><span class="p">,</span>
<span class="n">required</span> <span class="k">this</span><span class="p">.</span><span class="n">symbol</span><span class="p">,</span>
<span class="n">required</span> <span class="k">this</span><span class="p">.</span><span class="n">prices</span><span class="p">,</span>
<span class="p">});</span>
<span class="kd">factory</span> <span class="n">StockItem</span><span class="p">.</span><span class="n">fromJson</span><span class="p">(</span><span class="n">Json</span> <span class="n">json</span><span class="p">)</span> <span class="o">=></span> <span class="n">_$StockItemFromJson</span><span class="p">(</span><span class="n">json</span><span class="p">);</span>
<span class="kd">final</span> <span class="kt">int</span> <span class="n">id</span><span class="p">;</span>
<span class="kd">final</span> <span class="kt">String</span> <span class="n">name</span><span class="p">;</span>
<span class="kd">final</span> <span class="n">CryptoSymbol</span> <span class="n">symbol</span><span class="p">;</span>
<span class="err">@</span><span class="n">JsonKey</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">'quote'</span><span class="p">)</span>
<span class="kd">final</span> <span class="n">Map</span><span class="o"><</span><span class="n">CryptoSymbol</span><span class="p">,</span> <span class="n">ItemPrices</span><span class="o">></span> <span class="n">prices</span><span class="p">;</span>
<span class="n">ItemPrices</span> <span class="kd">get</span> <span class="n">usdPrices</span> <span class="o">=></span> <span class="n">prices</span><span class="p">[</span><span class="s1">'USD'</span><span class="p">]</span><span class="o">!</span><span class="p">;</span>
<span class="kt">String</span> <span class="n">imageUrl</span><span class="p">(</span><span class="kt">int</span> <span class="n">size</span><span class="p">)</span> <span class="p">{</span>
<span class="k">assert</span><span class="p">(</span><span class="n">size</span> <span class="o">></span> <span class="m">128</span> <span class="o">&&</span> <span class="n">size</span> <span class="o"><=</span> <span class="m">250</span><span class="p">);</span>
<span class="k">return</span> <span class="s1">'<https://s2.coinmarketcap.com/static/img/coins/</span><span class="si">${</span><span class="n">size</span><span class="si">}</span><span class="s1">x</span><span class="si">$</span><span class="n">size</span><span class="s1">/</span><span class="si">$</span><span class="n">id</span><span class="s1">.png>'</span><span class="p">;</span>
<span class="p">}</span>
<span class="n">Json</span> <span class="n">toJson</span><span class="p">()</span> <span class="o">=></span> <span class="n">_$StockItemToJson</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Поле id появилось как необходимость для отображения логотипов валют. Так как исходный ресурс предоставляет их как раз по id.</p>
<p>И еще одна сущность, описывающая цены криптовалюты в валюте обычной:</p>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'package:flutter/foundation.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:json_annotation/json_annotation.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../../service/types/types.dart'</span><span class="p">;</span>
<span class="k">part</span> <span class="s1">'item_prices.g.dart'</span><span class="p">;</span>
<span class="err">@</span><span class="n">immutable</span>
<span class="err">@</span><span class="n">JsonSerializable</span><span class="p">()</span>
<span class="kd">class</span> <span class="nc">ItemPrices</span> <span class="p">{</span>
<span class="kd">const</span> <span class="n">ItemPrices</span><span class="p">({</span>
<span class="n">required</span> <span class="k">this</span><span class="p">.</span><span class="n">price</span><span class="p">,</span>
<span class="n">required</span> <span class="k">this</span><span class="p">.</span><span class="n">diff1h</span><span class="p">,</span>
<span class="n">required</span> <span class="k">this</span><span class="p">.</span><span class="n">diff24h</span><span class="p">,</span>
<span class="p">});</span>
<span class="kd">factory</span> <span class="n">ItemPrices</span><span class="p">.</span><span class="n">fromJson</span><span class="p">(</span><span class="n">Json</span> <span class="n">json</span><span class="p">)</span> <span class="o">=></span> <span class="n">_$ItemPricesFromJson</span><span class="p">(</span><span class="n">json</span><span class="p">);</span>
<span class="kd">final</span> <span class="kt">double</span> <span class="n">price</span><span class="p">;</span>
<span class="err">@</span><span class="n">JsonKey</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">'percent_change_1h'</span><span class="p">)</span>
<span class="kd">final</span> <span class="kt">double</span> <span class="n">diff1h</span><span class="p">;</span>
<span class="err">@</span><span class="n">JsonKey</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">'percent_change_24h'</span><span class="p">)</span>
<span class="kd">final</span> <span class="kt">double</span> <span class="n">diff24h</span><span class="p">;</span>
<span class="n">Json</span> <span class="n">toJson</span><span class="p">()</span> <span class="o">=></span> <span class="n">_$ItemPricesToJson</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Для сериализации / десериализации моделей я использовал <strong>json_serializable</strong>. Осталось только загрузить данные. Тут нам на помощь приходит кодогенерация в лице <strong>retrofit</strong>. Благодаря данному решению мы можем избавиться от необходимости написания хоть какой-то части бойлерплейта (но не всей). Сетевую логику, связанную с получением списка крипты, разместим в классе CryptoProvider.</p>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'package:dio/dio.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:high_low/domain/crypto/dto/stock_response.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:retrofit/http.dart'</span><span class="p">;</span>
<span class="k">part</span> <span class="s1">'crypto_provider.g.dart'</span><span class="p">;</span>
<span class="err">@</span><span class="n">RestApi</span><span class="p">(</span><span class="nl">baseUrl:</span> <span class="s1">'<https://pro-api.coinmarketcap.com/v1/>'</span><span class="p">)</span>
<span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">CryptoProvider</span> <span class="p">{</span>
<span class="kd">factory</span> <span class="n">CryptoProvider</span><span class="p">(</span><span class="n">Dio</span> <span class="n">dio</span><span class="p">,</span> <span class="p">{</span><span class="kt">String</span><span class="o">?</span> <span class="n">baseUrl</span><span class="p">})</span> <span class="o">=</span> <span class="n">_CryptoProvider</span><span class="p">;</span>
<span class="err">@</span><span class="n">GET</span><span class="p">(</span><span class="s1">'cryptocurrency/listings/latest'</span><span class="p">)</span>
<span class="n">Future</span><span class="o"><</span><span class="n">StockResponse</span><span class="o">></span> <span class="n">fetchLatestData</span><span class="p">({</span>
<span class="err">@</span><span class="n">Header</span><span class="p">(</span><span class="s1">'X-CMC_PRO_API_KEY'</span><span class="p">)</span> <span class="n">required</span> <span class="kt">String</span> <span class="n">token</span><span class="p">,</span>
<span class="err">@</span><span class="n">Query</span><span class="p">(</span><span class="s1">'limit'</span><span class="p">)</span> <span class="kt">int</span> <span class="n">limit</span> <span class="o">=</span> <span class="m">1000</span><span class="p">,</span>
<span class="p">});</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Конечно же, в DI-регистратор была добавлена фабрика CryptoProvider и Dio:</p>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'package:dio/dio.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:flutter/widgets.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../domain/crypto/logic/crypto_provider.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../routing/default_router_information_parser.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../routing/page_builder.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../routing/root_router_delegate.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'di.dart'</span><span class="p">;</span>
<span class="kt">void</span> <span class="n">initDependencies</span><span class="p">()</span> <span class="p">{</span>
<span class="n">Di</span><span class="p">.</span><span class="n">reg</span><span class="o"><</span><span class="n">BackButtonDispatcher</span><span class="o">></span><span class="p">(()</span> <span class="o">=></span> <span class="n">RootBackButtonDispatcher</span><span class="p">());</span>
<span class="n">Di</span><span class="p">.</span><span class="n">reg</span><span class="o"><</span><span class="n">RouteInformationParser</span><span class="o"><</span><span class="kt">Object</span><span class="o">>></span><span class="p">(()</span> <span class="o">=></span> <span class="n">DefaultRouterInformationParser</span><span class="p">());</span>
<span class="n">Di</span><span class="p">.</span><span class="n">reg</span><span class="o"><</span><span class="n">RouterDelegate</span><span class="o"><</span><span class="kt">Object</span><span class="o">>></span><span class="p">(()</span> <span class="o">=></span> <span class="n">RootRouterDelegate</span><span class="p">());</span>
<span class="n">Di</span><span class="p">.</span><span class="n">reg</span><span class="p">(()</span> <span class="o">=></span> <span class="n">PageBuilder</span><span class="p">());</span>
<span class="n">Di</span><span class="p">.</span><span class="n">reg</span><span class="p">(()</span> <span class="o">=></span> <span class="n">Dio</span><span class="p">(),</span> <span class="nl">asBuilder:</span> <span class="kc">true</span><span class="p">);</span> <span class="c1">// <--</span>
<span class="n">Di</span><span class="p">.</span><span class="n">reg</span><span class="p">(()</span> <span class="o">=></span> <span class="n">CryptoProvider</span><span class="p">(</span><span class="n">Di</span><span class="p">.</span><span class="kd">get</span><span class="p">()),</span> <span class="nl">asBuilder:</span> <span class="kc">true</span><span class="p">);</span> <span class="c1">// <--</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>На данном этапе у нас получается следующая структура проекта (внутренности service пока опускаю):</p>
<pre><div class="codehilite"><pre><span></span><span class="o">|--</span> <span class="n">domain</span>
<span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">crypto</span>
<span class="o">|</span> <span class="o">|--</span> <span class="n">dto</span>
<span class="o">|</span> <span class="o">|</span> <span class="o">|--</span> <span class="n">item_prices</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|</span> <span class="o">|</span> <span class="o">|--</span> <span class="n">stock_item</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|</span> <span class="o">|</span> <span class="o">|--</span> <span class="n">stock_item_example</span><span class="p">.</span><span class="n">json</span>
<span class="o">|</span> <span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">stock_response</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">logic</span>
<span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">crypto_provider</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|--</span> <span class="n">high_low_app</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|--</span> <span class="n">main</span><span class="p">.</span><span class="n">dart</span>
<span class="err">`</span><span class="o">--</span> <span class="n">service</span>
<span class="o">|--</span> <span class="n">config</span>
<span class="o">|--</span> <span class="n">di</span>
<span class="o">|--</span> <span class="n">logs</span>
<span class="o">|--</span> <span class="n">routing</span>
<span class="o">|--</span> <span class="n">theme</span>
<span class="o">|--</span> <span class="n">tools</span>
<span class="o">|--</span> <span class="n">types</span>
</pre></div>
</pre>
<p>Если вы задались вопросом, как получить такую картинку директории, вот ответ. Ну и на данном этапе работа с сетью завершена, все что нужно для отображения главного экрана у нас уже есть.</p>
<h2>State</h2>
<p>Вот мы и подбираемся к UI с логикой. Давайте начнем с последней, так как иначе она все равно заспойлерится в интерфейсе.</p>
<p>Но, прежде чем начать описывать состояние нашего приложения, нужно сделать большое лирическое отступление. Для тех, кто занимается разработкой приложений на Flutter не секрет, что Dart -- однопоточный язык с возможностью запуска нескольких, так называемых Isolate -- изолированных потоков со своим собственным Event Loop и памятью. И обычно, большинство разработчиков пишет весь код “просто в одном потоке”. То есть не заморачивается с тем, чтобы выносить тяжелые операции, потенциально блокирующие UI в отдельные изоляты (но я никого не виню, стандартное API весьма громоздкое, <codeinline>compute()</codeinline> не то, чтобы спасал, а различные сторонние библиотеки...ну кому они нужны?, изоляты -- сложно ведь). Со временем могут происходить неприятные изменения в приложении или данных, прилетающих с бэка, становится все больше и все начинает лагать. Из-за чего? Давайте проведем небольшое исследование.</p>
<h3>Исследование</h3>
<p>Я провел 3 эксперимента по 5 раз для двух окружений. Первое окружение: profile-сборка на флагманском устройстве (Samsung Galaxy Note 20 Ultra), находящемся в режиме “обычное использование” -- то есть я не перезагружал телефон перед каждым прогоном, но каждый раз выгружал из памяти приложение, а других активно запущенных приложений не было. Второе окружение: определенного рода симуляция слабого устройства, которое у пользователя вашего приложения тоже может оказаться - это эмулятор со следующими настройками:</p>
<pre><div class="codehilite"><pre><span></span>2048Mb RAM
256Mb VM Heap
4 Cores CPU
</pre></div>
</pre>
<p>Сам эмулятор был запущен на ноутбуке с Ryzen 7 5800H, никаких фоновых задач нет (только открытая IDEA).</p>
<p>Теперь к сути испытаний -- для главного экрана необходимо загрузить данные о криптовалютах. Я загружал их по 100, 1000 и 5000 штук за один запрос. По окончанию запроса измерял время, требуемое на преобразование ответа сервера (массив байт) в сырую JSON-строку, которая, затем, десереализуется в Map<String, dynamic>, все это - подкапотная логика Dio, в которую я добавил только логирование времени. Вторая операция, подвергнутая анализу - уже преобразование мапки в бизнес-классы, с которыми в реальном приложении мы и работаем.</p>
<p>Для того, чтобы внедрить логирование в Dio пришлось изрядно покопаться в его внутренних органах: все указанные преобразования происходят посредством класса Transformer. Данный класс можно написать самому и скормить Dio, а можно ничего и не делать -- тогда будет использоваться <strong>DefaultTransformer</strong>. Приведу тот кусок стандартного трансформера, который отвечает за то, чтобы вы смогли получить мапку на выходе (справа от каждой добавленной строки есть комментарий с префиксом <--, в котором описано, что тут происходит):</p>
<pre><div class="codehilite"><pre><span></span><span class="n">Future</span> <span class="n">transformResponse</span><span class="p">(</span>
<span class="n">RequestOptions</span> <span class="n">options</span><span class="p">,</span> <span class="n">ResponseBody</span> <span class="n">response</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="n">options</span><span class="p">.</span><span class="n">responseType</span> <span class="o">==</span> <span class="n">ResponseType</span><span class="p">.</span><span class="n">stream</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="n">response</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">var</span> <span class="n">length</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span>
<span class="kd">var</span> <span class="n">received</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span>
<span class="kd">var</span> <span class="n">showDownloadProgress</span> <span class="o">=</span> <span class="n">options</span><span class="p">.</span><span class="n">onReceiveProgress</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">showDownloadProgress</span><span class="p">)</span> <span class="p">{</span>
<span class="n">length</span> <span class="o">=</span> <span class="kt">int</span><span class="p">.</span><span class="n">parse</span><span class="p">(</span>
<span class="n">response</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="n">Headers</span><span class="p">.</span><span class="n">contentLengthHeader</span><span class="p">]</span><span class="o">?</span><span class="p">.</span><span class="n">first</span> <span class="o">??</span> <span class="s1">'-1'</span><span class="p">);</span>
<span class="p">}</span>
<span class="kd">var</span> <span class="n">completer</span> <span class="o">=</span> <span class="n">Completer</span><span class="p">();</span>
<span class="kd">var</span> <span class="n">stream</span> <span class="o">=</span>
<span class="n">response</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">transform</span><span class="o"><</span><span class="n">Uint8List</span><span class="o">></span><span class="p">(</span><span class="n">StreamTransformer</span><span class="p">.</span><span class="n">fromHandlers</span><span class="p">(</span>
<span class="nl">handleData:</span> <span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">sink</span><span class="p">)</span> <span class="p">{</span>
<span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">data</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">showDownloadProgress</span><span class="p">)</span> <span class="p">{</span>
<span class="n">received</span> <span class="o">+=</span> <span class="n">data</span><span class="p">.</span><span class="n">length</span><span class="p">;</span>
<span class="n">options</span><span class="p">.</span><span class="n">onReceiveProgress</span><span class="o">?</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="n">received</span><span class="p">,</span> <span class="n">length</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">},</span>
<span class="p">));</span>
<span class="c1">// let's keep references to the data chunks and concatenate them later</span>
<span class="kd">final</span> <span class="n">chunks</span> <span class="o">=</span> <span class="o"><</span><span class="n">Uint8List</span><span class="o">></span><span class="p">[];</span>
<span class="kd">var</span> <span class="n">finalSize</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span>
<span class="kt">int</span> <span class="n">totalDuration</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span> <span class="c1">// <-- Total computation time in microseconds</span>
<span class="kt">int</span> <span class="n">networkTime</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span> <span class="c1">// <-- Time (microseconds), which will spend to accumulate parts of network response</span>
<span class="n">StreamSubscription</span> <span class="n">subscription</span> <span class="o">=</span> <span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span>
<span class="p">(</span><span class="n">chunk</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">start</span> <span class="o">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span><span class="p">;</span> <span class="c1">// <-- Before saving each part of the data we start tracking the current time</span>
<span class="n">finalSize</span> <span class="o">+=</span> <span class="n">chunk</span><span class="p">.</span><span class="n">length</span><span class="p">;</span>
<span class="n">chunks</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">chunk</span><span class="p">);</span>
<span class="kd">final</span> <span class="n">now</span> <span class="o">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span><span class="p">;</span> <span class="c1">// <--</span>
<span class="n">totalDuration</span> <span class="o">+=</span> <span class="n">now</span> <span class="o">-</span> <span class="n">start</span><span class="p">;</span> <span class="c1">// <-- After the chunk of data was saved, we check spent time</span>
<span class="n">networkTime</span> <span class="o">+=</span> <span class="n">now</span> <span class="o">-</span> <span class="n">start</span><span class="p">;</span> <span class="c1">// <--</span>
<span class="p">},</span>
<span class="nl">onError:</span> <span class="p">(</span><span class="kt">Object</span> <span class="n">error</span><span class="p">,</span> <span class="n">StackTrace</span> <span class="n">stackTrace</span><span class="p">)</span> <span class="p">{</span>
<span class="n">completer</span><span class="p">.</span><span class="n">completeError</span><span class="p">(</span><span class="n">error</span><span class="p">,</span> <span class="n">stackTrace</span><span class="p">);</span>
<span class="p">},</span>
<span class="nl">onDone:</span> <span class="p">()</span> <span class="o">=&</span><span class="n">gt</span><span class="p">;</span> <span class="n">completer</span><span class="p">.</span><span class="n">complete</span><span class="p">(),</span>
<span class="nl">cancelOnError:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">);</span>
<span class="c1">// ignore: unawaited_futures</span>
<span class="n">options</span><span class="p">.</span><span class="n">cancelToken</span><span class="o">?</span><span class="p">.</span><span class="n">whenCancel</span><span class="p">.</span><span class="n">then</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="n">subscription</span><span class="p">.</span><span class="n">cancel</span><span class="p">();</span>
<span class="p">});</span>
<span class="k">if</span> <span class="p">(</span><span class="n">options</span><span class="p">.</span><span class="n">receiveTimeout</span> <span class="o">&</span><span class="n">gt</span><span class="p">;</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span>
<span class="k">try</span> <span class="p">{</span>
<span class="kd">await</span> <span class="n">completer</span><span class="p">.</span><span class="n">future</span>
<span class="p">.</span><span class="n">timeout</span><span class="p">(</span><span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="n">options</span><span class="p">.</span><span class="n">receiveTimeout</span><span class="p">));</span>
<span class="p">}</span> <span class="n">on</span> <span class="n">TimeoutException</span> <span class="p">{</span>
<span class="kd">await</span> <span class="n">subscription</span><span class="p">.</span><span class="n">cancel</span><span class="p">();</span>
<span class="k">throw</span> <span class="n">DioError</span><span class="p">(</span>
<span class="nl">requestOptions:</span> <span class="n">options</span><span class="p">,</span>
<span class="nl">error:</span> <span class="s1">'Receiving data timeout[</span><span class="si">${</span><span class="n">options</span><span class="p">.</span><span class="n">receiveTimeout</span><span class="si">}</span><span class="s1">ms]'</span><span class="p">,</span>
<span class="nl">type:</span> <span class="n">DioErrorType</span><span class="p">.</span><span class="n">receiveTimeout</span><span class="p">,</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="kd">await</span> <span class="n">completer</span><span class="p">.</span><span class="n">future</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">final</span> <span class="n">start</span> <span class="o">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span><span class="p">;</span> <span class="c1">// <-- Here we start tracking time before all chunks will be joined into the one Uint8List</span>
<span class="kd">final</span> <span class="n">responseBytes</span> <span class="o">=</span> <span class="n">Uint8List</span><span class="p">(</span><span class="n">finalSize</span><span class="p">);</span>
<span class="kd">var</span> <span class="n">chunkOffset</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">var</span> <span class="n">chunk</span> <span class="k">in</span> <span class="n">chunks</span><span class="p">)</span> <span class="p">{</span>
<span class="n">responseBytes</span><span class="p">.</span><span class="n">setAll</span><span class="p">(</span><span class="n">chunkOffset</span><span class="p">,</span> <span class="n">chunk</span><span class="p">);</span>
<span class="n">chunkOffset</span> <span class="o">+=</span> <span class="n">chunk</span><span class="p">.</span><span class="n">length</span><span class="p">;</span>
<span class="p">}</span>
<span class="n">totalDuration</span> <span class="o">+=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span> <span class="o">-</span> <span class="n">start</span><span class="p">;</span> <span class="c1">// <-- And adding the new portion of time</span>
<span class="k">if</span> <span class="p">(</span><span class="n">options</span><span class="p">.</span><span class="n">responseType</span> <span class="o">==</span> <span class="n">ResponseType</span><span class="p">.</span><span class="n">bytes</span><span class="p">)</span> <span class="k">return</span> <span class="n">responseBytes</span><span class="p">;</span>
<span class="kt">String</span><span class="o">?</span> <span class="n">responseBody</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">options</span><span class="p">.</span><span class="n">responseDecoder</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
<span class="n">responseBody</span> <span class="o">=</span> <span class="n">options</span><span class="p">.</span><span class="n">responseDecoder</span><span class="o">!</span><span class="p">(</span>
<span class="n">responseBytes</span><span class="p">,</span>
<span class="n">options</span><span class="p">,</span>
<span class="n">response</span><span class="p">..</span><span class="n">stream</span> <span class="o">=</span> <span class="n">Stream</span><span class="p">.</span><span class="n">empty</span><span class="p">(),</span>
<span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">start</span> <span class="o">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span><span class="p">;</span> <span class="c1">// <-- We also tracked the decoding of the bytes into the string (raw JSON)</span>
<span class="n">responseBody</span> <span class="o">=</span> <span class="n">utf8</span><span class="p">.</span><span class="n">decode</span><span class="p">(</span><span class="n">responseBytes</span><span class="p">,</span> <span class="nl">allowMalformed:</span> <span class="kc">true</span><span class="p">);</span>
<span class="n">totalDuration</span> <span class="o">+=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span> <span class="o">-</span> <span class="n">start</span><span class="p">;</span> <span class="c1">// <--</span>
<span class="p">}</span>
<span class="k">if</span> <span class="p">(</span><span class="n">responseBody</span><span class="p">.</span><span class="n">isNotEmpty</span> <span class="o">&</span><span class="n">amp</span><span class="p">;</span><span class="o">&</span><span class="n">amp</span><span class="p">;</span>
<span class="n">options</span><span class="p">.</span><span class="n">responseType</span> <span class="o">==</span> <span class="n">ResponseType</span><span class="p">.</span><span class="n">json</span> <span class="o">&</span><span class="n">amp</span><span class="p">;</span><span class="o">&</span><span class="n">amp</span><span class="p">;</span>
<span class="n">_isJsonMime</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="n">Headers</span><span class="p">.</span><span class="n">contentTypeHeader</span><span class="p">]</span><span class="o">?</span><span class="p">.</span><span class="n">first</span><span class="p">))</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">callback</span> <span class="o">=</span> <span class="n">jsonDecodeCallback</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">callback</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="n">callback</span><span class="p">(</span><span class="n">responseBody</span><span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">start</span> <span class="o">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span><span class="p">;</span> <span class="c1">// <-- And finally - we track the decoding of the raw JSON string into the Map<String, dynamic></span>
<span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">decode</span><span class="p">(</span><span class="n">responseBody</span><span class="p">);</span>
<span class="n">totalDuration</span> <span class="o">+=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span> <span class="o">-</span> <span class="n">start</span><span class="p">;</span> <span class="c1">// <--</span>
<span class="n">print</span><span class="p">(</span><span class="s1">'TOTAL PARSING TIME: </span><span class="si">${</span><span class="n">totalDuration</span> <span class="o">/</span> <span class="m">1000</span><span class="si">}</span><span class="s1">ms; NETWORK TIME: </span><span class="si">${</span><span class="n">networkTime</span> <span class="o">/</span> <span class="m">1000</span><span class="si">}</span><span class="s1">ms'</span><span class="p">);</span> <span class="c1">// <--</span>
<span class="k">return</span> <span class="n">result</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">responseBody</span><span class="p">;</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Ну и второй герой нашего времени - операция преобразования мапки в бизнес-сущности (для этого мы вклиниваем логирование в сгенерированный retrofit класс, в котором и описана вся логика получения данных):</p>
<pre><div class="codehilite"><pre><span></span><span class="n">Future</span><span class="o"><</span><span class="n">StockResponse</span><span class="o">></span> <span class="n">fetchLatestData</span><span class="p">({</span><span class="n">required</span> <span class="n">token</span><span class="p">,</span> <span class="n">limit</span> <span class="o">=</span> <span class="m">1000</span><span class="p">})</span> <span class="kd">async</span> <span class="p">{</span>
<span class="kd">const</span> <span class="n">_extra</span> <span class="o">=</span> <span class="o"><</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">></span><span class="p">{};</span>
<span class="kd">final</span> <span class="n">queryParameters</span> <span class="o">=</span> <span class="o"><</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">></span><span class="p">{</span><span class="s1">r'limit'</span><span class="o">:</span> <span class="n">limit</span><span class="p">};</span>
<span class="kd">final</span> <span class="n">_headers</span> <span class="o">=</span> <span class="o"><</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">></span><span class="p">{</span><span class="s1">r'X-CMC_PRO_API_KEY'</span><span class="o">:</span> <span class="n">token</span><span class="p">};</span>
<span class="n">_headers</span><span class="p">.</span><span class="n">removeWhere</span><span class="p">((</span><span class="n">k</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span> <span class="o">=></span> <span class="n">v</span> <span class="o">==</span> <span class="kc">null</span><span class="p">);</span>
<span class="kd">final</span> <span class="n">_data</span> <span class="o">=</span> <span class="o"><</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">></span><span class="p">{};</span>
<span class="kd">final</span> <span class="n">_result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">_dio</span><span class="p">.</span><span class="n">fetch</span><span class="o"><</span><span class="n">Map</span><span class="o"><</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">>></span><span class="p">(</span><span class="n">_setStreamType</span><span class="o"><</span><span class="n">StockResponse</span><span class="o">></span><span class="p">(</span><span class="n">Options</span><span class="p">(</span><span class="nl">method:</span> <span class="s1">'GET'</span><span class="p">,</span> <span class="nl">headers:</span> <span class="n">_headers</span><span class="p">,</span> <span class="nl">extra:</span> <span class="n">_extra</span><span class="p">)</span>
<span class="p">.</span><span class="n">compose</span><span class="p">(</span><span class="n">_dio</span><span class="p">.</span><span class="n">options</span><span class="p">,</span> <span class="s1">'cryptocurrency/listings/latest'</span><span class="p">,</span> <span class="nl">queryParameters:</span> <span class="n">queryParameters</span><span class="p">,</span> <span class="nl">data:</span> <span class="n">_data</span><span class="p">)</span>
<span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">baseUrl:</span> <span class="n">baseUrl</span> <span class="o">??</span> <span class="n">_dio</span><span class="p">.</span><span class="n">options</span><span class="p">.</span><span class="n">baseUrl</span><span class="p">)));</span>
<span class="n">bench</span><span class="p">.</span><span class="n">start</span><span class="p">(</span><span class="s1">'STOCK RESPONSE DESERIALIZING'</span><span class="p">);</span> <span class="c1">// <-- At here we used the simple performance-tracker</span>
<span class="kd">final</span> <span class="n">value</span> <span class="o">=</span> <span class="n">StockResponse</span><span class="p">.</span><span class="n">fromJson</span><span class="p">(</span><span class="n">_result</span><span class="p">.</span><span class="n">data</span><span class="o">!</span><span class="p">);</span>
<span class="n">bench</span><span class="p">.</span><span class="n">end</span><span class="p">(</span><span class="s1">'STOCK RESPONSE DESERIALIZING'</span><span class="p">);</span> <span class="c1">// <--</span>
<span class="k">return</span> <span class="n">value</span><span class="p">;</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Также стоит показать и код самого performance-tracker, используемого выше:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nc">_Benchmark</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">Map</span><span class="o"><</span><span class="kt">String</span><span class="p">,</span> <span class="kt">int</span><span class="o">></span> <span class="n">_starts</span> <span class="o">=</span> <span class="o"><</span><span class="kt">String</span><span class="p">,</span> <span class="kt">int</span><span class="o">></span><span class="p">{};</span>
<span class="kt">void</span> <span class="n">start</span><span class="p">(</span><span class="kt">dynamic</span> <span class="n">id</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">final</span> <span class="kt">String</span> <span class="n">benchId</span> <span class="o">=</span> <span class="n">id</span><span class="p">.</span><span class="n">toString</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span><span class="n">_starts</span><span class="p">.</span><span class="n">containsKey</span><span class="p">(</span><span class="n">benchId</span><span class="p">))</span> <span class="p">{</span>
<span class="n">Logs</span><span class="p">.</span><span class="n">warn</span><span class="p">(</span><span class="s1">'Benchmark already have comparing with id=</span><span class="si">$</span><span class="n">benchId</span><span class="s1"> in time'</span><span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="n">_starts</span><span class="p">[</span><span class="n">benchId</span><span class="p">]</span> <span class="o">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kt">double</span> <span class="n">end</span><span class="p">(</span><span class="kt">dynamic</span> <span class="n">id</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">final</span> <span class="kt">String</span> <span class="n">benchId</span> <span class="o">=</span> <span class="n">id</span><span class="p">.</span><span class="n">toString</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">_starts</span><span class="p">.</span><span class="n">containsKey</span><span class="p">(</span><span class="n">benchId</span><span class="p">))</span> <span class="p">{</span>
<span class="k">throw</span> <span class="n">Exception</span><span class="p">(</span><span class="s1">'In Benchmark not placed comparing with id=</span><span class="si">$</span><span class="n">benchId</span><span class="s1">'</span><span class="p">);</span>
<span class="p">}</span>
<span class="kd">final</span> <span class="kt">double</span> <span class="n">diff</span> <span class="o">=</span> <span class="p">(</span><span class="n">DateTime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">microsecondsSinceEpoch</span> <span class="o">-</span> <span class="n">_starts</span><span class="p">[</span><span class="n">benchId</span><span class="p">]</span><span class="o">!</span><span class="p">)</span> <span class="o">/</span> <span class="m">1000</span><span class="p">;</span>
<span class="kd">final</span> <span class="kt">String</span> <span class="n">info</span> <span class="o">=</span> <span class="s1">'</span><span class="si">$</span><span class="n">benchId</span><span class="s1"> need </span><span class="si">${</span><span class="n">diff</span><span class="si">}</span><span class="s1">ms'</span><span class="p">;</span>
<span class="n">print</span><span class="p">(</span><span class="n">info</span><span class="p">);</span>
<span class="n">_starts</span><span class="p">.</span><span class="n">remove</span><span class="p">(</span><span class="n">benchId</span><span class="p">);</span>
<span class="k">return</span> <span class="n">diff</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">final</span> <span class="n">_Benchmark</span> <span class="n">bench</span> <span class="o">=</span> <span class="n">_Benchmark</span><span class="p">();</span>
</pre></div>
</pre>
<p>Как говорил кто-то там:</p>
<p><strong><em>"Лучше показать таблицу с данными, чем ходить вокруг да около"</em></strong></p>
<p>Поэтому, вот таблица, с дополнительной аннотацией полей:</p>
<ul>
<li><strong>Count</strong> -- количество элементов криптовалют, загружаемых за один запрос (да, да, в мире есть, как минимум, 5000 видов крипты)</li>
<li><strong>Rows </strong>-- количество строк в JSON (если сделать Beautify в Postman)</li>
<li><strong>Size </strong>-- размер данных в килобайтах</li>
<li><strong>[P] / [D]</strong> -- префикс окружения, Profile / Debug (описано выше)</li>
<li><strong>JSON </strong>-- время в милисекундах, потраченное непосредственно на то, чтобы Dio вернул нам мапку</li>
<li><strong>Entity </strong>-- время в милисекундах, потраченное на то, чтобы преобразовать мапку в бизнес-сущности</li>
<li><strong>Total </strong>-- сумма JSON + Entity</li>
<li><strong>kB / ms</strong> - метрика, означающая, “сколько килобайт можно преобразовать за одну милисекунду”.</li>
</ul>
<p><img alt="cf30fa7d87acee76a7c19526260bdbbc_1-1801-7c1051.png" src="https://cdn.otus.ru/media/public/7c/10/cf30fa7d87acee76a7c19526260bdbbc_1-1801-7c1051.png"></p>
<p>А вот мои выводы из этой таблицы:</p>
<ol>
<li>В лучшем случае, если у пользователя устройство верхнего ценового сегмента -- мы можем рассчитывать на то, что оно будет способно обработать до <strong>~18kB/ms</strong> (возможно, самые новые флагманы будут способны и на большее).</li>
<li>Ремарка про худший случай -- так как<strong> [D]</strong> окружение было запущено на эмуляторе с JIT-компиляцией, то мы имеем некоторые негативные экстремумы, связанные с тем, что код еще не разогрелся. Это отчетливо видно на объеме данных в 100 единиц - было потрачено чрезвычайно много времени, выбивающееся из статистики. Поэтому я не буду брать значение в 2.629kB/ms как минимальное, а возьму 8.603kB/ms, как более близкое к реальности. Делаем вывод -- мы можем рассчитывать на то, что устройство пользователя сможет обработать хотя бы <strong>~9kB/ms</strong>.</li>
<li>Будем исходить из того, что все большее количество девайсов обладает экранами с частотой обновления 120FPS, это значит, что у нас есть всего 8ms для отрисовки одного кадра, из этих 8ms какое-то время занимает сам процесс рендеринга, примерно, в среднем, это будет 2ms. Итого -- у нас осталось 6ms, чтобы сделать что-то и не потерять кадр. А это значит, что мы можем рассчитывать на то, что пользовательское устройство сможет обработать запрос с размером ответа в (18 + 9) / 2 * (8 - 2) = <strong>81kB</strong>, чтобы не потерять ни одного кадра (это в идеале, если нет других негативных факторов). Если дисплей с 60FPS, то (18 + 9) / 2 * (16 - 2) = <strong>189kB</strong>.</li>
</ol>
<p>Что с этой информацией делать? Ну, например, мы можем сделать вывод, что если попытаться разобрать JSON в 1mb в главном потоке приложения, то мы гарантированно получим лаг в 80-160ms, и это уже будет бросаться в глаза пользователю. Если у вас много запросов с жирными ответами -- интерфейс будет лагать намного чаще. Как с этим можно бороться, я уже однажды <a href="https://otus.ru/nest/post/2308/">рассказывал</a>. И пора продолжить этот старый рассказ.</p>
<h3>Isolate</h3>
<p>С недавним релизом Dart 2.15 произошли позитивные изменения в возможностях использования изолятов. Главным новшеством стал новый метод <codeinline>Isolate.exit()</codeinline>, который позволяет завершить текущий сторонний изолят, передавая в SendPort данные, которые прилетят в соответствующий ReceivePort за константное время. При этом, глубокого копирования, которое происходило раньше, до появления данного метода -- не происходит, а значит -- мы не заблочим наш UI-поток, когда он будет получать большую порцию данных одномоментно из стороннего изолята. Все это доступно “из коробки” посредством старой доброй функции compute(). С её помощью можно выносить вычисления, произодимые в отдельных функциях в сторонний изолят и быстро получать результаты обратно.</p>
<p>Относительно простым решением будет создание своего Transformer, который будет парсить ответы в стороннем изоляте и возвращать результат.</p>
<p>Но, как говорилось в первой статье -- я хочу показать еще и использование своих библиотек, а не только этапы создания приложения и так уж вышло, что у меня есть библиотека isolator, созданная для упрощения работы с изолятами и позволяющая вынести вообще всю логику в сторонние Stateful изоляты. Эти сторонние изоляты, в контексте библиотеки, носят название Backend. И к ним в нагрузку идут легковесные реактивные компаньоны, называемые Frontend -- это может быть любой класс из любого менеджера управления состоянием -- Bloc, Mobx, ChangeNotifier и т. д. К этому классу добавляется mixin Frontend и вы получаете возможность общения с соответствующим Backend. До выхода Dart 2.15 эта библиотека решала одну узкую, но фундаментальную проблему (чтобы её не пришлось решать самостоятельно) -- возможность передачи данных неограниченного объема из стороннего изолята в главный без блокировки последнего. С появлением метода Isolate.exit() эта проблема, кажется, ушла сама собой, поэтому теперь данная библиотека просто позволяет не нагружать основной поток ничем, кроме отрисовки UI (впрочем, как и раньше).</p>
<p>В данный момент на pub.dev доступна первая версия, но при этом все основные работы по написанию v2 завершены, но пока не опубликованы, поэтому если вы захотите попробовать -- можно установить из git:</p>
<pre><div class="codehilite"><pre><span></span><span class="nl">isolator:</span>
<span class="nl">git:</span>
<span class="nl">url:</span> <span class="o"><</span><span class="nl">https:</span><span class="c1">//github.com/alphamikle/isolator.git></span>
<span class="nl">ref:</span> <span class="n">next</span>
</pre></div>
</pre>
<p>Среди прочих нововведений второй версии присутствует возможность прозрачного использования этого же кода в вебе (но пока еще в разработке). Isolate API не имеет поддержки в вебе, как таковой, однако, при использовании isolator весь код будет работать как и обычно, но в главном потоке.</p>
<h3>Frontend</h3>
<p>Для начала приложу весь код, а затем буду разбирать каждый из его блоков по отдельности:</p>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'dart:async'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:flutter/material.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:flutter/widgets.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:isolator/isolator.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:isolator/next/maybe.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../../service/di/di.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../../service/di/registrations.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../../service/tools/localization_wrapper.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../crypto/dto/stock_item.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../notification/logic/notification_service.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'main_backend.dart'</span><span class="p">;</span>
<span class="n">enum</span> <span class="n">MainEvent</span> <span class="p">{</span>
<span class="n">init</span><span class="p">,</span>
<span class="n">loadStocks</span><span class="p">,</span>
<span class="n">startLoadingStocks</span><span class="p">,</span>
<span class="n">endLoadingStocks</span><span class="p">,</span>
<span class="n">filterStocks</span><span class="p">,</span>
<span class="n">updateFilteredStocks</span><span class="p">,</span>
<span class="p">}</span>
<span class="kd">class</span> <span class="nc">MainFrontend</span> <span class="kd">with</span> <span class="n">Frontend</span><span class="p">,</span> <span class="n">ChangeNotifier</span> <span class="p">{</span>
<span class="n">late</span> <span class="kd">final</span> <span class="n">NotificationService</span> <span class="n">_notificationService</span><span class="p">;</span>
<span class="n">late</span> <span class="kd">final</span> <span class="n">LocalizationWrapper</span> <span class="n">_localizationWrapper</span><span class="p">;</span>
<span class="kd">final</span> <span class="n">List</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">stocks</span> <span class="o">=</span> <span class="p">[];</span>
<span class="kt">bool</span> <span class="n">isLaunching</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="kt">bool</span> <span class="n">isStocksLoading</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="kt">bool</span> <span class="n">errorOnLoadingStocks</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="n">TextEditingController</span> <span class="n">searchController</span> <span class="o">=</span> <span class="n">TextEditingController</span><span class="p">();</span>
<span class="n">TextEditingController</span> <span class="n">tokenController</span> <span class="o">=</span> <span class="n">TextEditingController</span><span class="p">();</span>
<span class="kt">bool</span> <span class="n">_isInLaunchProcess</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="kt">bool</span> <span class="n">_isLaunched</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="kt">String</span> <span class="n">_prevSearch</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>
<span class="n">Future</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">loadStocks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
<span class="n">errorOnLoadingStocks</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="kd">final</span> <span class="n">Maybe</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">stocks</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">run</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">stocks</span><span class="p">.</span><span class="n">hasList</span><span class="p">)</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="n">stocks</span><span class="p">.</span><span class="n">clear</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="n">stocks</span><span class="p">.</span><span class="n">addAll</span><span class="p">(</span><span class="n">stocks</span><span class="p">.</span><span class="n">list</span><span class="p">);</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="k">if</span> <span class="p">(</span><span class="n">stocks</span><span class="p">.</span><span class="n">hasError</span><span class="p">)</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="n">errorOnLoadingStocks</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">});</span>
<span class="kd">await</span> <span class="n">_notificationService</span><span class="p">.</span><span class="n">showSnackBar</span><span class="p">(</span><span class="nl">content:</span> <span class="n">_localizationWrapper</span><span class="p">.</span><span class="n">loc</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">errors</span><span class="p">.</span><span class="n">loadingError</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="n">Future</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">launch</span><span class="p">({</span>
<span class="n">required</span> <span class="n">NotificationService</span> <span class="n">notificationService</span><span class="p">,</span>
<span class="n">required</span> <span class="n">LocalizationWrapper</span> <span class="n">localizationWrapper</span><span class="p">,</span>
<span class="p">})</span> <span class="kd">async</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">isLaunching</span> <span class="o">||</span> <span class="n">_isLaunched</span> <span class="o">||</span> <span class="n">_isInLaunchProcess</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
<span class="n">_notificationService</span> <span class="o">=</span> <span class="n">notificationService</span><span class="p">;</span>
<span class="n">_localizationWrapper</span> <span class="o">=</span> <span class="n">localizationWrapper</span><span class="p">;</span>
<span class="n">_isInLaunchProcess</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="n">searchController</span><span class="p">.</span><span class="n">addListener</span><span class="p">(</span><span class="n">_filterStocks</span><span class="p">);</span>
<span class="kd">await</span> <span class="n">initBackend</span><span class="p">(</span><span class="nl">initializer:</span> <span class="n">_launch</span><span class="p">);</span>
<span class="n">_isInLaunchProcess</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="n">_isLaunched</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="n">_update</span><span class="p">(()</span> <span class="o">=&</span><span class="n">gt</span><span class="p">;</span> <span class="n">isLaunching</span> <span class="o">=</span> <span class="kc">false</span><span class="p">);</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">_filterStocks</span><span class="p">()</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="n">_prevSearch</span> <span class="o">!=</span> <span class="n">searchController</span><span class="p">.</span><span class="n">text</span><span class="p">)</span> <span class="p">{</span>
<span class="n">_prevSearch</span> <span class="o">=</span> <span class="n">searchController</span><span class="p">.</span><span class="n">text</span><span class="p">;</span>
<span class="n">run</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">filterStocks</span><span class="p">,</span> <span class="nl">data:</span> <span class="n">searchController</span><span class="p">.</span><span class="n">text</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">_setFilteredStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="n">required</span> <span class="n">List</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">data</span><span class="p">})</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="n">stocks</span><span class="p">.</span><span class="n">clear</span><span class="p">();</span>
<span class="n">stocks</span><span class="p">.</span><span class="n">addAll</span><span class="p">(</span><span class="n">data</span><span class="p">);</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">_startLoadingStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="kt">void</span> <span class="n">data</span><span class="p">})</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="n">isStocksLoading</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">_endLoadingStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="kt">void</span> <span class="n">data</span><span class="p">})</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="n">isStocksLoading</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">_update</span><span class="p">(</span><span class="n">VoidCallback</span> <span class="n">dataChanger</span><span class="p">)</span> <span class="p">{</span>
<span class="n">dataChanger</span><span class="p">();</span>
<span class="n">notifyListeners</span><span class="p">();</span>
<span class="p">}</span>
<span class="kd">static</span> <span class="n">MainBackend</span> <span class="n">_launch</span><span class="p">(</span><span class="n">BackendArgument</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">argument</span><span class="p">)</span> <span class="p">{</span>
<span class="n">initDependencies</span><span class="p">();</span>
<span class="k">return</span> <span class="n">MainBackend</span><span class="p">(</span><span class="nl">argument:</span> <span class="n">argument</span><span class="p">,</span> <span class="nl">cryptoProvider:</span> <span class="n">Di</span><span class="p">.</span><span class="kd">get</span><span class="p">());</span>
<span class="p">}</span>
<span class="err">@</span><span class="n">override</span>
<span class="kt">void</span> <span class="n">initActions</span><span class="p">()</span> <span class="p">{</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">startLoadingStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_startLoadingStocks</span><span class="p">);</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">endLoadingStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_endLoadingStocks</span><span class="p">);</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">updateFilteredStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_setFilteredStocks</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Логика работы библиотеки, отчасти, похожа на Bloc -- необходимо зарегистрировать обработчики сообщений, прилетающих с Backend. Регистрируются они в методе initActions:</p>
<pre><div class="codehilite"><pre><span></span><span class="err">@</span><span class="n">override</span>
<span class="kt">void</span> <span class="n">initActions</span><span class="p">()</span> <span class="p">{</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">startLoadingStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_startLoadingStocks</span><span class="p">);</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">endLoadingStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_endLoadingStocks</span><span class="p">);</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">updateFilteredStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_setFilteredStocks</span><span class="p">);</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>В качестве идентификатора события выступает любая сущность, но важно то, что проверка на соответствие будет происходить через обычное равенство ==. Также, можно зарегистрировать обработчик на определенный тип идентификаторов, в этом случае он будет обрабатывать все события, идентифицируемые конкретно этим типом:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nc">SpecificMessageId</span> <span class="p">{</span>
<span class="kd">const</span> <span class="n">SpecificMessageId</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="n">someValue</span><span class="p">);</span>
<span class="kd">final</span> <span class="kt">int</span> <span class="n">someValue</span><span class="p">;</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">initActions</span><span class="p">()</span> <span class="p">{</span>
<span class="n">whenEventCome</span><span class="o"><</span><span class="n">SpecificMessageId</span><span class="o">></span><span class="p">().</span><span class="n">run</span><span class="p">(</span><span class="n">_specificHandler</span><span class="p">);</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Стоит добавить несколько слов и о самих обработчиках. Все обработчики должны соответствовать следующему типу (не соответствующие не получится зарегистрировать):</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">typedef</span> <span class="n">FrontendAction</span><span class="o"><</span><span class="n">Event</span><span class="p">,</span> <span class="n">Req</span><span class="p">,</span> <span class="n">Res</span><span class="o">></span> <span class="o">=</span>
<span class="n">FutureOr</span><span class="o"><</span><span class="n">Res</span><span class="o">></span> <span class="n">Function</span><span class="p">({</span><span class="n">required</span> <span class="n">Event</span> <span class="n">event</span><span class="p">,</span> <span class="n">required</span> <span class="n">Req</span> <span class="n">data</span><span class="p">});</span>
</pre></div>
</pre>
<p>Но, при этом, значение data не обязательно должно прилетать. Идентификатор-событие event будет прилетать всегда. То есть, следующие обработчики зарегистрируются и будут корректными:</p>
<pre><div class="codehilite"><pre><span></span><span class="kt">void</span> <span class="n">_startLoadingStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="kt">void</span> <span class="n">data</span><span class="p">})</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="n">isStocksLoading</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">_endLoadingStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="kt">void</span> <span class="n">data</span><span class="p">})</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="n">isStocksLoading</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="p">});</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Смысл обработчиков заключается в том, что если вы ходите только реагировать на события, инициированные Backend -- нужен обработчик. Если же вы хотите вызвать какой-то метод Backend -- можно обойтись и без обработчиков вовсе.</p>
<p>При вызове любого Backend-метода из Frontend вы всегда получите какой-нибудь ответ “на месте”, завернутый в своеобразный union-type Maybe<T>. Union-типов в Dart на данный момент нет, кроме одного встроенного FutureOr<T>, поэтому, для корректной типизации данных методов пришлось создавать Maybe<T>, он может включать в себя просто T, List<T> или ошибку, ну или вообще все три -- null, если метод Backend не возвращает ничего (но, на самом деле, Backend-методы всегда должны возвращать кое-что, что вы увидите немного ниже).</p>
<p>Следующий код демонстрирует вызов MainBackend метода по event = MainEvent.loadStocks и получение результата сразу в месте вызова:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">Future</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">loadStocks</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
<span class="n">errorOnLoadingStocks</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="kd">final</span> <span class="n">Maybe</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">stocks</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">run</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="n">stocks</span><span class="p">.</span><span class="n">hasList</span><span class="p">)</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="n">stocks</span><span class="p">.</span><span class="n">clear</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="n">stocks</span><span class="p">.</span><span class="n">addAll</span><span class="p">(</span><span class="n">stocks</span><span class="p">.</span><span class="n">list</span><span class="p">);</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="k">if</span> <span class="p">(</span><span class="n">stocks</span><span class="p">.</span><span class="n">hasError</span><span class="p">)</span> <span class="p">{</span>
<span class="n">_update</span><span class="p">(()</span> <span class="p">{</span>
<span class="n">errorOnLoadingStocks</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">});</span>
<span class="kd">await</span> <span class="n">_notificationService</span><span class="p">.</span><span class="n">showSnackBar</span><span class="p">(</span><span class="nl">content:</span>
<span class="n">_localizationWrapper</span><span class="p">.</span><span class="n">loc</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">errors</span><span class="p">.</span><span class="n">loadingError</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Немного забегая наперед, покажу и соответствующий этому event метод MainBackend, который и будет исполнен в стороннем изоляте:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">Future</span><span class="o"><</span><span class="n">ActionResponse</span><span class="o"><</span><span class="n">StockItem</span><span class="o">>></span> <span class="n">_loadStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="kt">void</span> <span class="n">data</span><span class="p">})</span> <span class="kd">async</span> <span class="p">{</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">startLoadingStocks</span><span class="p">,</span> <span class="nl">sendDirectly:</span> <span class="kc">true</span><span class="p">);</span>
<span class="k">try</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">List</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">stockItems</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">_cryptoProvider</span><span class="p">.</span><span class="n">fetchLatestData</span><span class="p">();</span>
<span class="n">_stocks</span><span class="p">.</span><span class="n">clear</span><span class="p">();</span>
<span class="n">_stocks</span><span class="p">.</span><span class="n">addAll</span><span class="p">(</span><span class="n">stockItems</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="n">error</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">endLoadingStocks</span><span class="p">,</span> <span class="nl">sendDirectly:</span> <span class="kc">true</span><span class="p">);</span>
<span class="n">rethrow</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">endLoadingStocks</span><span class="p">,</span> <span class="nl">sendDirectly:</span> <span class="kc">true</span><span class="p">);</span>
<span class="k">return</span> <span class="n">ActionResponse</span><span class="p">.</span><span class="n">list</span><span class="p">(</span><span class="n">_stocks</span><span class="p">);</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Пока не буду описывать его содержимое, об этом будет ниже.</p>
<p>Следующий метод launch нужен для инициализации MainFrontend и MainBackend. В нем вызывается метод initBackend миксина Frontend, в который необходимо передать, как минимум, один аргумент: функцию-инициализатор, которая запустится уже в стороннем изоляте, и эта функция должна возвращать инстанс соответствующего Backend.</p>
<pre><div class="codehilite"><pre><span></span><span class="n">Future</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">launch</span><span class="p">({</span>
<span class="n">required</span> <span class="n">NotificationService</span> <span class="n">notificationService</span><span class="p">,</span>
<span class="n">required</span> <span class="n">LocalizationWrapper</span> <span class="n">localizationWrapper</span><span class="p">,</span>
<span class="p">})</span> <span class="kd">async</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">isLaunching</span> <span class="o">||</span> <span class="n">_isLaunched</span> <span class="o">||</span> <span class="n">_isInLaunchProcess</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
<span class="n">_notificationService</span> <span class="o">=</span> <span class="n">notificationService</span><span class="p">;</span>
<span class="n">_localizationWrapper</span> <span class="o">=</span> <span class="n">localizationWrapper</span><span class="p">;</span>
<span class="n">_isInLaunchProcess</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="n">searchController</span><span class="p">.</span><span class="n">addListener</span><span class="p">(</span><span class="n">_filterStocks</span><span class="p">);</span>
<span class="kd">await</span> <span class="n">initBackend</span><span class="p">(</span><span class="nl">initializer:</span> <span class="n">_launch</span><span class="p">);</span>
<span class="n">_isInLaunchProcess</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
<span class="n">_isLaunched</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="n">_update</span><span class="p">(()</span> <span class="o">=></span> <span class="n">isLaunching</span> <span class="o">=</span> <span class="kc">false</span><span class="p">);</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Давайте взглянем на нее поближе:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">static</span> <span class="n">MainBackend</span> <span class="n">_launch</span><span class="p">(</span><span class="n">BackendArgument</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">argument</span><span class="p">)</span> <span class="p">{</span>
<span class="n">initDependencies</span><span class="p">();</span>
<span class="k">return</span> <span class="n">MainBackend</span><span class="p">(</span><span class="nl">argument:</span> <span class="n">argument</span><span class="p">,</span> <span class="nl">CryptoProvider:</span> <span class="n">Di</span><span class="p">.</span><span class="kd">get</span><span class="p">());</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>В этой функции нам необходимо повторно инициализировать Di-контейнер, так как сторонний изолят не знает ничего о том, что происходило в главном и все фабрики в стороннем изоляте не зарегистрированы. Требования к функции-инициализатору аналогичны требованиям к оригинальной функции entryPoint, используемой в Isolate API. А вот её интерфейс:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">typedef</span> <span class="n">BackendInitializer</span><span class="o"><</span><span class="n">T</span><span class="p">,</span> <span class="n">B</span> <span class="kd">extends</span> <span class="n">Backend</span><span class="o">></span> <span class="o">=</span>
<span class="n">B</span> <span class="n">Function</span><span class="p">(</span><span class="n">BackendArgument</span><span class="o"><</span><span class="n">T</span><span class="o">></span> <span class="n">argument</span><span class="p">);</span>
</pre></div>
</pre>
<p>Также, Frontend позволяет регистрировать хуки, вызываемые на каждое сообщение от Backend, только на сообщения, которые должны принудительно заставить Frontend уведомить UI об изменении данных; можно подписаться (например одному Frontend на другой), посредством метода subscribeOnEvent. Об этом будет сказано немного подробнее в блоке про UI.</p>
<h3>Backend</h3>
<p>Я начну с метода Frontend, который вызывается для получения данных о крипте. При первичной отрисовке главного экрана в хуке initState виджета MainView происходит инициализация MainFrontend (см. метод MainFrontend.launch). По завершению которой вызывается метод loadStocks (который был разобран выше):</p>
<pre><div class="codehilite"><pre><span></span><span class="c1">// main_view.dart</span>
<span class="n">Future</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">_launchMainFrontend</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">MainFrontend</span> <span class="n">mainFrontend</span> <span class="o">=</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">);</span>
<span class="kd">await</span> <span class="n">mainFrontend</span><span class="p">.</span><span class="n">launch</span><span class="p">(</span><span class="nl">notificationService:</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">),</span> <span class="nl">localizationWrapper:</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">));</span>
<span class="kd">await</span> <span class="n">mainFrontend</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">();</span>
<span class="p">}</span>
<span class="err">@</span><span class="n">override</span>
<span class="kt">void</span> <span class="n">initState</span><span class="p">()</span> <span class="p">{</span>
<span class="k">super</span><span class="p">.</span><span class="n">initState</span><span class="p">();</span>
<span class="n">_launchMainFrontend</span><span class="p">();</span>
<span class="c1">// ...</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Выше уже отсветил один из методов MainBackend, что-же, вот теперь пора представить и сам класс, который будет существовать в отдельном изоляте на протяжении жизни всего приложения:</p>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'dart:async'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../crypto/logic/crypto_provider.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:isolator/isolator.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../crypto/dto/stock_item.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'main_frontend.dart'</span><span class="p">;</span>
<span class="kd">typedef</span> <span class="n">StockItemFilter</span> <span class="o">=</span> <span class="kt">bool</span> <span class="n">Function</span><span class="p">(</span><span class="n">StockItem</span><span class="p">);</span>
<span class="kd">class</span> <span class="nc">MainBackend</span> <span class="kd">extends</span> <span class="n">Backend</span> <span class="p">{</span>
<span class="n">MainBackend</span><span class="p">({</span>
<span class="n">required</span> <span class="n">BackendArgument</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">argument</span><span class="p">,</span>
<span class="n">required</span> <span class="n">CryptoProvider</span> <span class="n">cryptoProvider</span><span class="p">,</span>
<span class="p">})</span> <span class="o">:</span> <span class="n">_cryptoProvider</span> <span class="o">=</span> <span class="n">cryptoProvider</span><span class="p">,</span>
<span class="k">super</span><span class="p">(</span><span class="nl">argument:</span> <span class="n">argument</span><span class="p">);</span>
<span class="kd">final</span> <span class="n">CryptoProvider</span> <span class="n">_cryptoProvider</span><span class="p">;</span>
<span class="kd">final</span> <span class="n">List</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">_stocks</span> <span class="o">=</span> <span class="p">[];</span>
<span class="n">Timer</span><span class="o">?</span> <span class="n">_searchTimer</span><span class="p">;</span>
<span class="n">Future</span><span class="o"><</span><span class="n">ActionResponse</span><span class="o"><</span><span class="n">StockItem</span><span class="o">>></span> <span class="n">_loadStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="kt">void</span> <span class="n">data</span><span class="p">})</span> <span class="kd">async</span> <span class="p">{</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">startLoadingStocks</span><span class="p">,</span> <span class="nl">sendDirectly:</span> <span class="kc">true</span><span class="p">);</span>
<span class="k">try</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">List</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">stockItems</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">_cryptoProvider</span><span class="p">.</span><span class="n">fetchLatestData</span><span class="p">();</span>
<span class="n">_stocks</span><span class="p">.</span><span class="n">clear</span><span class="p">();</span>
<span class="n">_stocks</span><span class="p">.</span><span class="n">addAll</span><span class="p">(</span><span class="n">stockItems</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="n">error</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">endLoadingStocks</span><span class="p">,</span> <span class="nl">sendDirectly:</span> <span class="kc">true</span><span class="p">);</span>
<span class="n">rethrow</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">endLoadingStocks</span><span class="p">,</span> <span class="nl">sendDirectly:</span> <span class="kc">true</span><span class="p">);</span>
<span class="k">return</span> <span class="n">ActionResponse</span><span class="p">.</span><span class="n">list</span><span class="p">(</span><span class="n">_stocks</span><span class="p">);</span>
<span class="p">}</span>
<span class="n">ActionResponse</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">_filterStocks</span><span class="p">({</span><span class="n">required</span> <span class="n">MainEvent</span> <span class="n">event</span><span class="p">,</span> <span class="n">required</span> <span class="kt">String</span> <span class="n">data</span><span class="p">})</span> <span class="p">{</span>
<span class="kd">final</span> <span class="kt">String</span> <span class="n">searchSubString</span> <span class="o">=</span> <span class="n">data</span><span class="p">;</span>
<span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">startLoadingStocks</span><span class="p">);</span>
<span class="n">_searchTimer</span><span class="o">?</span><span class="p">.</span><span class="n">cancel</span><span class="p">();</span>
<span class="n">_searchTimer</span> <span class="o">=</span> <span class="n">Timer</span><span class="p">(</span><span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">500</span><span class="p">),</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
<span class="n">_searchTimer</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
<span class="kd">final</span> <span class="n">List</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">filteredStocks</span> <span class="o">=</span> <span class="n">_stocks</span><span class="p">.</span><span class="n">where</span><span class="p">(</span><span class="n">_stockFilterPredicate</span><span class="p">(</span><span class="n">searchSubString</span><span class="p">)).</span><span class="n">toList</span><span class="p">();</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span>
<span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">updateFilteredStocks</span><span class="p">,</span>
<span class="nl">data:</span> <span class="n">ActionResponse</span><span class="p">.</span><span class="n">list</span><span class="p">(</span><span class="n">filteredStocks</span><span class="p">),</span>
<span class="p">);</span>
<span class="kd">await</span> <span class="n">send</span><span class="p">(</span><span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">endLoadingStocks</span><span class="p">);</span>
<span class="p">});</span>
<span class="k">return</span> <span class="n">ActionResponse</span><span class="p">.</span><span class="n">empty</span><span class="p">();</span>
<span class="p">}</span>
<span class="n">StockItemFilter</span> <span class="n">_stockFilterPredicate</span><span class="p">(</span><span class="kt">String</span> <span class="n">searchSubString</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">RegExp</span> <span class="n">filterRegExp</span> <span class="o">=</span> <span class="n">RegExp</span><span class="p">(</span><span class="n">searchSubString</span><span class="p">,</span> <span class="nl">caseSensitive:</span> <span class="kc">false</span><span class="p">,</span> <span class="nl">unicode:</span> <span class="kc">true</span><span class="p">);</span>
<span class="k">return</span> <span class="p">(</span><span class="n">StockItem</span> <span class="n">item</span><span class="p">)</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="n">searchSubString</span><span class="p">.</span><span class="n">isEmpty</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">filterRegExp</span><span class="p">.</span><span class="n">hasMatch</span><span class="p">(</span><span class="n">item</span><span class="p">.</span><span class="n">symbol</span><span class="p">)</span> <span class="o">||</span> <span class="n">filterRegExp</span><span class="p">.</span><span class="n">hasMatch</span><span class="p">(</span><span class="n">item</span><span class="p">.</span><span class="n">name</span><span class="p">);</span>
<span class="p">};</span>
<span class="p">}</span>
<span class="err">@</span><span class="n">override</span>
<span class="kt">void</span> <span class="n">initActions</span><span class="p">()</span> <span class="p">{</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_loadStocks</span><span class="p">);</span>
<span class="n">whenEventCome</span><span class="p">(</span><span class="n">MainEvent</span><span class="p">.</span><span class="n">filterStocks</span><span class="p">).</span><span class="n">run</span><span class="p">(</span><span class="n">_filterStocks</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>По аналогии с Frontend в любом Backend есть возможность регистрации обработчиков событий с тем же самым API, но небольшим отличием в типе обработчика:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">typedef</span> <span class="n">BackendAction</span><span class="o"><</span><span class="n">Event</span><span class="p">,</span> <span class="n">Req</span><span class="p">,</span> <span class="n">Res</span><span class="o">></span> <span class="o">=</span> <span class="n">FutureOr</span><span class="o"><</span><span class="n">ActionResponse</span><span class="o"><</span><span class="n">Res</span><span class="o">>></span> <span class="n">Function</span><span class="p">({</span><span class="n">required</span> <span class="n">Event</span> <span class="n">event</span><span class="p">,</span> <span class="n">required</span> <span class="n">Req</span> <span class="n">data</span><span class="p">});</span>
</pre></div>
</pre>
<p>Отличие заключается в том, что если Frontend обработчик может не возвращать ничего, то Backend обработчик обязан возвращать результат вида ActionResponse<T>, либо падать с ошибкой. Это является следствием определенных ограничений при работе с типами в Dart.</p>
<p>Также, обработчик является выходной точкой любого Backend, каждый из которых может вызывать обработчики любого другого Backend, делается это посредством специальных сущностей Interactor. </p>
<p>Теперь разберем подробнее метод получения криптовалют. Перед началом загрузки мы посылаем сообщение в MainFrontend, чтобы отобразить в интерфейсе, что идет процесс загрузки.</p>
<p>await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
Затем, происходит сама загрузка данных и их сохранение в MainBackend для возможности локального поиска.</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">final</span> <span class="n">List</span><span class="o"><</span><span class="n">StockItem</span><span class="o">></span> <span class="n">stockItems</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">_cryptoProvider</span><span class="p">.</span><span class="n">fetchLatestData</span><span class="p">();</span>
<span class="n">_stocks</span><span class="p">.</span><span class="n">clear</span><span class="p">();</span>
<span class="n">_stocks</span><span class="p">.</span><span class="n">addAll</span><span class="p">(</span><span class="n">stockItems</span><span class="p">);</span>
</pre></div>
</pre>
<p>Теперь начинается кое-что интересное, что стало возможным с выходом Dart 2.15. Упомянутая выше возможность библиотеки передавать любой объем данных без просадки кадров достигается (раньше достигалась) посредством разбиения массива данных на чанки и передачей этих чанков во Frontend по очереди. Логика тут была простая, если данных много -- их можно так или иначе представить в виде массива, а его можно без проблем разбить на маленькие куски и передать без проблем с производительностью. Собственно, эта старая логика отображена передачей данных, завернутых в специальный wrapper Chunks:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">await</span> <span class="n">send</span><span class="p">(</span>
<span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">,</span>
<span class="nl">data:</span> <span class="n">ActionResponse</span><span class="p">.</span><span class="n">chunks</span><span class="p">(</span>
<span class="n">Chunks</span><span class="p">(</span>
<span class="nl">data:</span> <span class="n">_stocks</span><span class="p">,</span>
<span class="nl">updateAfterFirstChunk:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nl">size:</span> <span class="m">100</span><span class="p">,</span>
<span class="nl">delay:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">8</span><span class="p">),</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="p">);</span>
</pre></div>
</pre>
<p>При этом сборка чанков во Frontend происходила “магически-автоматически”, и обработчик, который ожидал получения большой пачки данных -- просто получал свой готовый огромный массив. Все эти возможности придется выпилить, так как особого смысла от них теперь нет.</p>
<p>С приходом новой версии Dart стало возможным передавать любой объем данных любого типа за константное время и без ограничений по типу передаваемых данных -- теперь можно без проблем передавать не только массивы, но и любую другую структуру, если это необходимо. Сейчас достаточно использовать обычный метод отправки сообщений, который будет использовать под капотом пресловутый Isolate.exit:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">await</span> <span class="n">send</span><span class="p">(</span>
<span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">,</span>
<span class="nl">data:</span> <span class="n">ActionResponse</span><span class="p">.</span><span class="n">list</span><span class="p">(</span><span class="n">_stocks</span><span class="p">),</span>
<span class="p">);</span>
</pre></div>
</pre>
<p>При этом, как говорит документация, возможность быстрой передачи данных доступна только при уничтожении отправляющего изолята. А так как наш MainBackend (да и любой другой Backend) -- стремится жить на протяжении существования всего приложения (по крайней мере такова их задумка, но их и без проблем можно закрывать, но, всё-таки, не таким способом), то использовать Isolate.exit напрямую в этом изоляте нельзя -- он, по большому счету, завершится аварийно. Чтобы обойти это недоразумение наш Backend создает дополнительный транспортный изолят, в который классическим способом (глубоким копированием средствами Dart VM) передается любое количество данных, никак не влияющее на UI-изолят, а затем этот одноразовый транспортный изолят уничтожается, передавая при этом, данные в наш UI-изолят.</p>
<p>Вернемся к разбору нашего метода загрузки крипты. Так как мы организуем “синхронный” вызов Backend-метода из Frontend, то наш Backend-метод должен вернуть этот результат:</p>
<pre><div class="codehilite"><pre><span></span><span class="k">return</span> <span class="n">ActionResponse</span><span class="p">.</span><span class="n">list</span><span class="p">(</span><span class="n">_stocks</span><span class="p">);</span>
</pre></div>
</pre>
<p>Также, при отправке события начала загрузки данных был указан дополнительный параметр sendDirectly, думаю, самое время описать и его -- так как мы не всегда передаём большое количество данных из Backend во Frontend, то и не всегда нужно пользоваться услугами транспортного изолята -- можно передавать данные напрямую. Если это необходимо -- использование данного параметра позволит отправлять сообщения без сторонней помощи.</p>
<h3>Локальный поиск</h3>
<p>Более подробно останавливаться на методе локального поиска останавливаться не буду, так как, кажется, статья уже стала лонгридом 🙂. Работает он как поиск по регулярному выражению. Могу добавить только то, что вы можете получить ответ на главный вопрос вселенной с его помощью и даже немного больше.</p>
<h3>UI</h3>
<p>После завершения данного этапа структура домена main станет такой:</p>
<pre><div class="codehilite"><pre><span></span><span class="o">|--</span> <span class="n">domain</span>
<span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">main</span>
<span class="o">|</span> <span class="o">|--</span> <span class="n">logic</span>
<span class="o">|</span> <span class="o">|</span> <span class="o">|--</span> <span class="n">main_backend</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|</span> <span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">main_frontend</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">ui</span>
<span class="o">|</span> <span class="o">|--</span> <span class="n">main_header</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|</span> <span class="o">|--</span> <span class="n">main_view</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">stock_item_tile</span><span class="p">.</span><span class="n">dart</span>
<span class="o">|--</span> <span class="n">high_low_app</span><span class="p">.</span><span class="n">dart</span>
<span class="err">`</span><span class="o">--</span> <span class="n">main</span><span class="p">.</span><span class="n">dart</span>
</pre></div>
</pre>
<p>Опишем содержимое папочки ui:</p>
<pre><div class="codehilite"><pre><span></span>main_view.dart содержит StatefulWidget главного экрана
</pre></div>
</pre>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'package:flutter/material.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:isolator/next/frontend/frontend_event_subscription.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:provider/provider.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:yalo_assets/lib.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:yalo_locale/lib.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../../service/theme/app_theme.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../../service/tools/utils.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../crypto/dto/stock_item.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../../notification/logic/notification_service.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'../logic/main_frontend.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'main_header.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'stock_item_tile.dart'</span><span class="p">;</span>
<span class="kd">class</span> <span class="nc">MainView</span> <span class="kd">extends</span> <span class="n">StatefulWidget</span> <span class="p">{</span>
<span class="kd">const</span> <span class="n">MainView</span><span class="p">({</span><span class="n">Key</span><span class="o">?</span> <span class="n">key</span><span class="p">})</span> <span class="o">:</span> <span class="k">super</span><span class="p">(</span><span class="nl">key:</span> <span class="n">key</span><span class="p">);</span>
<span class="err">@</span><span class="n">override</span>
<span class="n">_MainViewState</span> <span class="n">createState</span><span class="p">()</span> <span class="o">=></span> <span class="n">_MainViewState</span><span class="p">();</span>
<span class="p">}</span>
<span class="kd">class</span> <span class="nc">_MainViewState</span> <span class="kd">extends</span> <span class="n">State</span><span class="o"><</span><span class="n">MainView</span><span class="o">></span> <span class="p">{</span>
<span class="n">MainFrontend</span> <span class="kd">get</span> <span class="n">_mainFrontend</span> <span class="o">=></span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">);</span>
<span class="n">late</span> <span class="kd">final</span> <span class="n">FrontendEventSubscription</span><span class="o"><</span><span class="n">MainEvent</span><span class="o">></span> <span class="n">_eventSubscription</span><span class="p">;</span>
<span class="n">Widget</span> <span class="n">_stockItemBuilder</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">,</span> <span class="kt">int</span> <span class="n">index</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">StockItem</span> <span class="n">item</span> <span class="o">=</span> <span class="n">_mainFrontend</span><span class="p">.</span><span class="n">stocks</span><span class="p">[</span><span class="n">index</span><span class="p">];</span>
<span class="kd">final</span> <span class="kt">bool</span> <span class="n">isFirst</span> <span class="o">=</span> <span class="n">index</span> <span class="o">==</span> <span class="m">0</span><span class="p">;</span>
<span class="kd">final</span> <span class="kt">bool</span> <span class="n">isLast</span> <span class="o">=</span> <span class="n">index</span> <span class="o">==</span> <span class="n">_mainFrontend</span><span class="p">.</span><span class="n">stocks</span><span class="p">.</span><span class="n">length</span> <span class="o">-</span> <span class="m">1</span><span class="p">;</span>
<span class="k">return</span> <span class="n">Padding</span><span class="p">(</span>
<span class="nl">padding:</span> <span class="n">EdgeInsets</span><span class="p">.</span><span class="n">only</span><span class="p">(</span>
<span class="nl">left:</span> <span class="m">8</span><span class="p">,</span>
<span class="nl">top:</span> <span class="n">isFirst</span> <span class="o">?</span> <span class="m">8</span> <span class="o">:</span> <span class="m">0</span><span class="p">,</span>
<span class="nl">right:</span> <span class="m">8</span><span class="p">,</span>
<span class="nl">bottom:</span> <span class="n">isLast</span> <span class="o">?</span> <span class="n">MediaQuery</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">).</span><span class="n">padding</span><span class="p">.</span><span class="n">bottom</span> <span class="o">+</span> <span class="m">8</span> <span class="o">:</span> <span class="m">8</span><span class="p">,</span>
<span class="p">),</span>
<span class="nl">child:</span> <span class="n">StockItemTile</span><span class="p">(</span><span class="nl">item:</span> <span class="n">item</span><span class="p">),</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="kt">void</span> <span class="n">_onSearchEnd</span><span class="p">(</span><span class="n">MainEvent</span> <span class="n">event</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">MainFrontend</span> <span class="n">mainFrontend</span> <span class="o">=</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="o"><</span><span class="n">MainFrontend</span><span class="o">></span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">);</span>
<span class="kd">final</span> <span class="n">LocalizationMessages</span> <span class="n">loc</span> <span class="o">=</span> <span class="n">Messages</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">);</span>
<span class="kd">final</span> <span class="kt">int</span> <span class="n">stocksCount</span> <span class="o">=</span> <span class="n">mainFrontend</span><span class="p">.</span><span class="n">stocks</span><span class="p">.</span><span class="n">length</span><span class="p">;</span>
<span class="kd">final</span> <span class="kt">String</span> <span class="n">content</span> <span class="o">=</span> <span class="n">loc</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">search</span><span class="p">.</span><span class="n">result</span><span class="p">(</span><span class="n">stocksCount</span><span class="p">);</span>
<span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="o"><</span><span class="n">NotificationService</span><span class="o">></span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">).</span><span class="n">showSnackBar</span><span class="p">(</span>
<span class="nl">content:</span> <span class="n">content</span><span class="p">,</span>
<span class="nl">backgroundColor:</span> <span class="n">AppTheme</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">).</span><span class="n">okColor</span><span class="p">,</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="n">Future</span><span class="o"><</span><span class="kt">void</span><span class="o">></span> <span class="n">_launchMainFrontend</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">MainFrontend</span> <span class="n">mainFrontend</span> <span class="o">=</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">);</span>
<span class="kd">await</span> <span class="n">mainFrontend</span><span class="p">.</span><span class="n">launch</span><span class="p">(</span><span class="nl">notificationService:</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">),</span> <span class="nl">localizationWrapper:</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">));</span>
<span class="kd">await</span> <span class="n">mainFrontend</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">();</span>
<span class="p">}</span>
<span class="err">@</span><span class="n">override</span>
<span class="kt">void</span> <span class="n">initState</span><span class="p">()</span> <span class="p">{</span>
<span class="k">super</span><span class="p">.</span><span class="n">initState</span><span class="p">();</span>
<span class="n">_launchMainFrontend</span><span class="p">();</span>
<span class="n">_eventSubscription</span> <span class="o">=</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="o"><</span><span class="n">MainFrontend</span><span class="o">></span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">).</span><span class="n">subscribeOnEvent</span><span class="p">(</span>
<span class="nl">listener:</span> <span class="n">_onSearchEnd</span><span class="p">,</span>
<span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">updateFilteredStocks</span><span class="p">,</span>
<span class="nl">onEveryEvent:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="err">@</span><span class="n">override</span>
<span class="kt">void</span> <span class="n">dispose</span><span class="p">()</span> <span class="p">{</span>
<span class="n">_eventSubscription</span><span class="p">.</span><span class="n">close</span><span class="p">();</span>
<span class="k">super</span><span class="p">.</span><span class="n">dispose</span><span class="p">();</span>
<span class="p">}</span>
<span class="err">@</span><span class="n">override</span>
<span class="n">Widget</span> <span class="n">build</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">final</span> <span class="n">Assets</span> <span class="n">assets</span> <span class="o">=</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="o"><</span><span class="n">Assets</span><span class="o">></span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">);</span>
<span class="kd">final</span> <span class="n">AppTheme</span> <span class="n">theme</span> <span class="o">=</span> <span class="n">AppTheme</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">);</span>
<span class="kd">final</span> <span class="n">MaterialStateProperty</span><span class="o"><</span><span class="n">Color</span><span class="o">></span> <span class="n">buttonColor</span> <span class="o">=</span> <span class="n">MaterialStateProperty</span><span class="p">.</span><span class="n">resolveWith</span><span class="p">((</span><span class="n">states</span><span class="p">)</span> <span class="o">=></span> <span class="n">theme</span><span class="p">.</span><span class="n">buttonColor</span><span class="p">);</span>
<span class="kd">final</span> <span class="n">ButtonStyle</span> <span class="n">buttonStyle</span> <span class="o">=</span> <span class="n">ButtonStyle</span><span class="p">(</span>
<span class="nl">foregroundColor:</span> <span class="n">buttonColor</span><span class="p">,</span>
<span class="nl">overlayColor:</span> <span class="n">MaterialStateProperty</span><span class="p">.</span><span class="n">resolveWith</span><span class="p">((</span><span class="n">states</span><span class="p">)</span> <span class="o">=></span> <span class="n">theme</span><span class="p">.</span><span class="n">splashColor</span><span class="p">),</span>
<span class="nl">shadowColor:</span> <span class="n">buttonColor</span><span class="p">,</span>
<span class="p">);</span>
<span class="kd">final</span> <span class="n">List</span><span class="o"><</span><span class="kt">String</span><span class="o">></span> <span class="n">notFoundImages</span> <span class="o">=</span> <span class="p">[</span>
<span class="n">assets</span><span class="p">.</span><span class="n">notFound1</span><span class="p">,</span>
<span class="n">assets</span><span class="p">.</span><span class="n">notFound2</span><span class="p">,</span>
<span class="n">assets</span><span class="p">.</span><span class="n">notFound3</span><span class="p">,</span>
<span class="n">assets</span><span class="p">.</span><span class="n">notFound4</span><span class="p">,</span>
<span class="p">].</span><span class="n">map</span><span class="p">((</span><span class="n">e</span><span class="p">)</span> <span class="o">=></span> <span class="n">e</span><span class="p">.</span><span class="n">replaceFirst</span><span class="p">(</span><span class="s1">'assets/'</span><span class="p">,</span> <span class="s1">''</span><span class="p">)).</span><span class="n">toList</span><span class="p">();</span>
<span class="n">Widget</span> <span class="n">body</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">_mainFrontend</span><span class="p">.</span><span class="n">isLaunching</span><span class="p">)</span> <span class="p">{</span>
<span class="n">body</span> <span class="o">=</span> <span class="n">Center</span><span class="p">(</span>
<span class="nl">child:</span> <span class="n">Text</span><span class="p">(</span><span class="n">Messages</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">).</span><span class="n">main</span><span class="p">.</span><span class="n">loading</span><span class="p">),</span>
<span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">_mainFrontend</span><span class="p">.</span><span class="n">errorOnLoadingStocks</span><span class="p">)</span> <span class="p">{</span>
<span class="n">body</span> <span class="o">=</span> <span class="n">Center</span><span class="p">(</span>
<span class="nl">child:</span> <span class="n">Padding</span><span class="p">(</span>
<span class="nl">padding:</span> <span class="kd">const</span> <span class="n">EdgeInsets</span><span class="p">.</span><span class="n">all</span><span class="p">(</span><span class="m">16</span><span class="p">),</span>
<span class="nl">child:</span> <span class="n">Column</span><span class="p">(</span>
<span class="nl">mainAxisSize:</span> <span class="n">MainAxisSize</span><span class="p">.</span><span class="n">min</span><span class="p">,</span>
<span class="nl">crossAxisAlignment:</span> <span class="n">CrossAxisAlignment</span><span class="p">.</span><span class="n">center</span><span class="p">,</span>
<span class="nl">children:</span> <span class="p">[</span>
<span class="n">Padding</span><span class="p">(</span>
<span class="nl">padding:</span> <span class="kd">const</span> <span class="n">EdgeInsets</span><span class="p">.</span><span class="n">only</span><span class="p">(</span><span class="nl">bottom:</span> <span class="m">16</span><span class="p">),</span>
<span class="nl">child:</span> <span class="n">Image</span><span class="p">.</span><span class="n">asset</span><span class="p">(</span><span class="n">notFoundImages</span><span class="p">[</span><span class="n">Utils</span><span class="p">.</span><span class="n">randomIntBetween</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="n">notFoundImages</span><span class="p">.</span><span class="n">length</span> <span class="o">-</span> <span class="m">1</span><span class="p">)]),</span>
<span class="p">),</span>
<span class="n">TextButton</span><span class="p">(</span>
<span class="nl">onPressed:</span> <span class="n">_mainFrontend</span><span class="p">.</span><span class="n">loadStocks</span><span class="p">,</span>
<span class="nl">style:</span> <span class="n">buttonStyle</span><span class="p">,</span>
<span class="nl">child:</span> <span class="n">Text</span><span class="p">(</span><span class="n">Messages</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">).</span><span class="n">main</span><span class="p">.</span><span class="n">repeat</span><span class="p">),</span>
<span class="p">),</span>
<span class="p">],</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="n">body</span> <span class="o">=</span> <span class="n">CustomScrollView</span><span class="p">(</span>
<span class="nl">physics:</span> <span class="kd">const</span> <span class="n">BouncingScrollPhysics</span><span class="p">(),</span>
<span class="nl">slivers:</span> <span class="p">[</span>
<span class="kd">const</span> <span class="n">MainHeader</span><span class="p">(),</span>
<span class="n">SliverList</span><span class="p">(</span>
<span class="nl">delegate:</span> <span class="n">SliverChildBuilderDelegate</span><span class="p">(</span>
<span class="n">_stockItemBuilder</span><span class="p">,</span>
<span class="nl">childCount:</span> <span class="n">_mainFrontend</span><span class="p">.</span><span class="n">stocks</span><span class="p">.</span><span class="n">length</span><span class="p">,</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="p">],</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">Scaffold</span><span class="p">(</span>
<span class="nl">body:</span> <span class="n">AnimatedSwitcher</span><span class="p">(</span>
<span class="nl">duration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">250</span><span class="p">),</span>
<span class="nl">child:</span> <span class="n">body</span><span class="p">,</span>
<span class="p">),</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Что есть интересного здесь? Инициализацию MainFrontend уже обсудили, остался только подписчик на события. Кстати, вот он:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">_eventSubscription</span> <span class="o">=</span> <span class="n">Provider</span><span class="p">.</span><span class="n">of</span><span class="o"><</span><span class="n">MainFrontend</span><span class="o">></span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="nl">listen:</span> <span class="kc">false</span><span class="p">).</span><span class="n">subscribeOnEvent</span><span class="p">(</span>
<span class="nl">listener:</span> <span class="n">_onSearchEnd</span><span class="p">,</span>
<span class="nl">event:</span> <span class="n">MainEvent</span><span class="p">.</span><span class="n">updateFilteredStocks</span><span class="p">,</span>
<span class="nl">onEveryEvent:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">);</span>
</pre></div>
</pre>
<p>Вызов данного метода позволяет уведомляться в том, что наш MainFrontend получил сообщение соответствующего типа от MainBackend. Метод subscribeOnEvent является частью Frontend в принципе.</p>
<p>В результате мы получаем такие уведомления, каждый раз, когда нам прилетает порция данных после поиска:</p>
<p><img alt="d9f381028d7f48a145aa2f0145564115_3-1801-7843ad.png" src="https://cdn.otus.ru/media/public/78/43/d9f381028d7f48a145aa2f0145564115_3-1801-7843ad.png"></p>
<p>И это -- является подводкой к теме локализации приложений на Flutter.</p>
<h3>Локализация интерфейса</h3>
<p>Уже довольно давно я задавался вопросом -- как можно быстро локализовать приложение на Flutter. Если взглянуть на официальный гайд -- то первое впечатление “без бутылки не разберешься”. Второе, собственно -- тоже. И тогда я подумал, что если избавиться от громоздкого .arb, и вместо него использовать .yaml? Так родился пакет assets_codegen (ссылку я не прикладываю, так как он deprecated). Его идея была в следующем -- располагаем файлы локализации в ассетах, аннотируем какой-нибудь класс, чтобы к нему цеплялся код локализации, запускаем flutter pub run build_runner watch и наслаждаемся. Решение было более чем работоспособным, но имелись и минусы -- логика отслеживания изменений в файлах локализации была написана руками, а котогенерация Dart не позволяет отслеживать изменения не в Dart-файлах, и результат совмещения стандартного кодогенератора и рукописного вотчера иной раз удручал. В общем было много раздражающих багов. И вот однажды, уже имея некоторое понимание, как часто приходится добавлять новые строки локализации и сразу же после этого ожидать их появления в коде (спойлер -- крайне редко), я решил написать полностью новый пакет, еще и название которого, родившееся в моей голове, очень мне понравилось.</p>
<p><img alt="2a59c93f4b32ef6c51737f56011bcc88_1-1801-f995d1.jpg" src="https://cdn.otus.ru/media/public/f9/95/2a59c93f4b32ef6c51737f56011bcc88_1-1801-f995d1.jpg"></p>
<p>Так появился пакет yalo. С предельно простой логикой (описанной в документации) -- размещаем файлы локализации в ассетах, запускаем генератор командой</p>
<p>flutter pub run yalo:loc, подключаем к проекту сгенерированный локальный пакет .yalo_locale, используем пару переменных в корневой ...App:</p>
<pre><div class="codehilite"><pre><span></span><span class="k">import</span> <span class="s1">'package:flutter/material.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'package:yalo_locale/lib.dart'</span><span class="p">;</span>
<span class="k">import</span> <span class="s1">'service/di/di.dart'</span><span class="p">;</span>
<span class="kd">class</span> <span class="nc">HighLowApp</span> <span class="kd">extends</span> <span class="n">StatelessWidget</span> <span class="p">{</span>
<span class="kd">const</span> <span class="n">HighLowApp</span><span class="p">({</span><span class="n">Key</span><span class="o">?</span> <span class="n">key</span><span class="p">})</span> <span class="o">:</span> <span class="k">super</span><span class="p">(</span><span class="nl">key:</span> <span class="n">key</span><span class="p">);</span>
<span class="err">@</span><span class="n">override</span>
<span class="n">Widget</span> <span class="n">build</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="n">MaterialApp</span><span class="p">.</span><span class="n">router</span><span class="p">(</span>
<span class="nl">routeInformationParser:</span> <span class="n">Di</span><span class="p">.</span><span class="kd">get</span><span class="o"><</span><span class="n">RouteInformationParser</span><span class="o"><</span><span class="kt">Object</span><span class="o">>></span><span class="p">(),</span>
<span class="nl">routerDelegate:</span> <span class="n">Di</span><span class="p">.</span><span class="kd">get</span><span class="o"><</span><span class="n">RouterDelegate</span><span class="o"><</span><span class="kt">Object</span><span class="o">>></span><span class="p">(),</span>
<span class="nl">backButtonDispatcher:</span> <span class="n">Di</span><span class="p">.</span><span class="kd">get</span><span class="o"><</span><span class="n">BackButtonDispatcher</span><span class="o">></span><span class="p">(),</span>
<span class="nl">theme:</span> <span class="n">Theme</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">).</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">brightness:</span> <span class="n">Brightness</span><span class="p">.</span><span class="n">dark</span><span class="p">),</span>
<span class="nl">debugShowCheckedModeBanner:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nl">localizationsDelegates:</span> <span class="n">localizationsDelegates</span><span class="p">,</span> <span class="c1">// <-- 1</span>
<span class="nl">supportedLocales:</span> <span class="n">supportedLocales</span><span class="p">,</span> <span class="c1">// <-- 2</span>
<span class="nl">onGenerateTitle:</span> <span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">)</span> <span class="o">=></span> <span class="n">Messages</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">).</span><span class="n">common</span><span class="p">.</span><span class="n">appTitle</span><span class="p">,</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>И используем локализованный контент. С плюрализацией, префиксами, сколько угодно глубокой вложенностью и подстановкой (пока только для числовых данных в плюрализированных строках). Примеры использования вы уже могли заметить выше, но продемонстрирую их отдельно.</p>
<p>Генерация названия приложения:</p>
<pre><div class="codehilite"><pre><span></span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">)</span> <span class="o">=></span> <span class="n">Messages</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">).</span><span class="n">common</span><span class="p">.</span><span class="n">appTitle</span>
</pre></div>
</pre>
<p>Подсказка поля ввода поиска:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">Messages</span><span class="p">.</span><span class="n">of</span><span class="p">(</span><span class="n">context</span><span class="p">).</span><span class="n">main</span><span class="p">.</span><span class="n">search</span><span class="p">.</span><span class="n">hint</span>
</pre></div>
</pre>
<p>Количество элементов после поиска в SnackBar:</p>
<pre><div class="codehilite"><pre><span></span>Messages.of(context).main.search.result(
Provider.of<MainFrontend>(context, listen: false).stocks.length)
</pre></div>
</pre>
<p>Появляется это все из такого файлика:</p>
<pre><div class="codehilite"><pre><span></span><span class="nl">main:</span>
<span class="nl">loading:</span> <span class="err">Загрузка</span><span class="p">...</span>
<span class="nl">search:</span>
<span class="nl">hint:</span> <span class="err">Поиск</span>
<span class="nl">result:</span>
<span class="nl">zero:</span> <span class="err">Мы</span> <span class="err">ничего</span> <span class="err">не</span> <span class="err">нашли</span>
<span class="nl">one:</span> <span class="err">Мы</span> <span class="err">нашли</span> <span class="n">$</span><span class="p">{</span><span class="n">howMany</span><span class="p">}</span> <span class="err">элемент</span>
<span class="nl">two:</span> <span class="err">Мы</span> <span class="err">нашли</span> <span class="n">$</span><span class="p">{</span><span class="n">howMany</span><span class="p">}</span> <span class="err">элемента</span>
<span class="nl">other:</span> <span class="err">Мы</span> <span class="err">нашли</span> <span class="n">$</span><span class="p">{</span><span class="n">howMany</span><span class="p">}</span> <span class="err">элементов</span>
<span class="nl">common:</span>
<span class="nl">currency:</span> <span class="s1">'</span><span class="se">\$</span><span class="s1">'</span>
<span class="nl">percent:</span> <span class="s1">'%'</span>
<span class="nl">appTitle:</span> <span class="n">High</span> <span class="n">Low</span>
</pre></div>
</pre>
<p>Точнее, файликов, лежащих вот так:</p>
<pre><div class="codehilite"><pre><span></span>|-- README.md
|-- analysis_options.yaml
|-- assets
| <span class="sb">`-- i18</span>
<span class="sb">| |-- en_intl.yaml</span>
<span class="sb">| `</span>-- ru_intl.yaml
`-- watch.sh
</pre></div>
</pre>
<p>Но вместо префикса файла, можно раскладывать их по папкам -- ../ru/intl.dart</p>
<h2>Заключение</h2>
<p>На этот раз статья поспела за кодом и все, что реализовано -- тут описано. В третьей статье я сделаю полностью второй экран (учитывая графики и игровую механику, возможно третья часть выйдет во время новогодних праздников), покажу работу с ассетами здорового человека и implicit-анимацию любого текста.</p>
<p>И еще, приложу изменения, которые произошли со времени первой части. И, код текущего состояния проекта.</p>
<h3>Особая секция</h3>
<p>Как сцены после титров в Marvel -- данная секция для особых зрителей читателей. Уже дописав данную статью я был практически готов её опубликовать. Но чувство перфекционизма старательно откусывало от меня кусочки -- на момент “готовности” статьи isolator не был доработан настолько, чтобы было можно использовать его и в web. И ещё мне хотелось показать не только картинки приложения, но и дать возможность его “потыкать”. И вот я за пару вечеров добавил возможность работы в web (как и прежде -- без многопоточности, но с сохранением полной работоспособности без изменений в вашем коде). Затем встал вопрос о публикации приложения. Публиковать в сторах я планирую в самом конце, а пока можно было бы сделать это на github.pages. Тут-то и начинается самое интересное.</p>
<p>Запустил web-версию локально, все отлично работает, за исключением одного NO! -- API сервиса, который я начал использовать изначально, не позволяет осуществлять CORS-запросы, “чтобы не палить ваши токены авторизации”, видимо, про реверс API приложений они не слышали. Ну да ладно. Я начал искать способы, как можно обойти это ограничение без необходимости пилить свой собственный proxy, хостить его где-то и т. д. Нашел curl-online, сделал запрос через него (через интерфейс самого сервиса) -- все заработало. Сразу начал делать web-имплементацию CryptoProvider, который бы использовался в web-сборке и ходил за данными через web-curl. И снова:</p>
<p><codeinline>У меня локально все работает</codeinline></p>
<p>Деплой на github.pages → и снова CORS, но уже у самого курла (почему я не додумался выполнить этот запрос из консоли браузера со страницы приложения на pages - очень большой вопроc). Время - час ночи, и я неунывающими красными глазами начинаю пялить в код пишушейся прокси для этого всего. Еще пол часа и глаза говорят “пора спать”. Проснувшись на следующий день, рано утром, я снова начал искать способы не писать прокси и, видимо, правду говорят - утро вечера мудренее, я додумываюсь поискать альтернативу самому API. И первый же запрос в гугл предоставляет мне <a href="https://www.coingecko.com/en/api/documentation?">прекрасную</a>, полностью бесплатную, без авторизаций (и с очень небольшими ограничениями), апишку.</p>
<p>С одной стороны -- я безмерно рад тому, что не придется пилить никакие прокси, и также рад тому, что смогу показать вам как оно работает в вебе без всяких “но”, но с другой -- если бы я сначала подумал, поискал, а не бросился пилить код, сэкономил бы часов 8 жизни...</p>
<p>В общем результаты таковы, что isolator v2 теперь полностью готов к использованию. Ну и вы можете взглянуть на web-версию того, что уже реализовано. У API есть ограничение на 50 вызовов в минуту, так что если сработает хабраэффект -- вы увидите Экран ошибки, на котором будет достаточно нажать одну кнопку.</p>
<h2>Ассеты</h2>
<p>Если бы не особая секция, и все страдания, которые там описаны, этот раздел действительно был бы должен оказаться в третьей статье. Изначально я хотел показать работу с ними в этой, но во время написания статьи понял, что нет особых мест, кроме как придуманных исскуственно, где они были бы к месту. Затем, во время реализации логики, связанной с возможностью исчерпания лимита моего токена авторизации на первом ресурсе появилось место, где ассеты будут к месту. Идея была такова -- если ресурс моего токена заканчивается, то при получении ошибки во время запроса отобразится дополнительный экран, где будет висеть какая-нибудь прикольная картинка, а также инпут для ввода вашего собственного токена авторизации, с которым бы у вас лично все заработало. После перехода на новое API логика по использованию вашего токена отпала сама собой, но, потенциально, осталась возможность наткнуться на ошибку из-за лимитов API по RPS. Поэтому, если вы увидите данный экран -- то хабраэффект сработал.</p>
<p>А теперь к самой работе с ассетами! Упомянутый выше пакет yalo, позволяет не только генерировать локализацию из .yaml файлов, но также, он позволяет генерировать код с именами всех ассетов, лежащих в вашей папке assets (или любой другой, если она корректно указана в pubspec.yaml). Сейчас структура папки assets данного проекта имеет следующий вид:</p>
<pre><div class="codehilite"><pre><span></span><span class="p">.</span><span class="o">/</span><span class="n">assets</span>
<span class="o">|--</span> <span class="n">i18</span>
<span class="o">|</span> <span class="o">|--</span> <span class="n">en_intl</span><span class="p">.</span><span class="n">yaml</span>
<span class="o">|</span> <span class="err">`</span><span class="o">--</span> <span class="n">ru_intl</span><span class="p">.</span><span class="n">yaml</span>
<span class="err">`</span><span class="o">--</span> <span class="n">images</span>
<span class="o">|--</span> <span class="n">notFound_1</span><span class="p">.</span><span class="n">png</span>
<span class="o">|--</span> <span class="n">notFound_2</span><span class="p">.</span><span class="n">png</span>
<span class="o">|--</span> <span class="n">notFound_3</span><span class="p">.</span><span class="n">png</span>
<span class="err">`</span><span class="o">--</span> <span class="n">notFound_4</span><span class="p">.</span><span class="n">png</span>
</pre></div>
</pre>
<p>При условии, что у вас в проекте уже установлен данный пакет, вы можете запустить следующую команду:</p>
<pre><div class="codehilite"><pre><span></span>flutter pub run yalo:asset
</pre></div>
</pre>
<p>Результатом такой команды будет сгенерированный пакет .yalo_assets в корне вашего проекта, который, по аналогии с .yalo_locale нужно добавить в pubspec.yaml:</p>
<pre><div class="codehilite"><pre><span></span><span class="nl">dependencies:</span>
<span class="c1">//...</span>
<span class="nl">yalo_locale:</span>
<span class="nl">path:</span> <span class="p">.</span><span class="o">/</span><span class="p">.</span><span class="n">yalo_locale</span>
<span class="nl">yalo_assets:</span>
<span class="nl">path:</span> <span class="p">.</span><span class="o">/</span><span class="p">.</span><span class="n">yalo_assets</span>
</pre></div>
</pre>
<p>После этих манипуляций вы получаете доступ к классу со статическими и обычными геттерами:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nc">Assets</span> <span class="p">{</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">enIntl</span> <span class="o">=></span> <span class="n">enIntlS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">enIntlS</span> <span class="o">=</span> <span class="s1">'assets/i18/en_intl.yaml'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">ruIntl</span> <span class="o">=></span> <span class="n">ruIntlS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">ruIntlS</span> <span class="o">=</span> <span class="s1">'assets/i18/ru_intl.yaml'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound1</span> <span class="o">=></span> <span class="n">notFound1S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound1S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_1.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound2</span> <span class="o">=></span> <span class="n">notFound2S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound2S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_2.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound3</span> <span class="o">=></span> <span class="n">notFound3S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound3S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_3.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound4</span> <span class="o">=></span> <span class="n">notFound4S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound4S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_4.png'</span><span class="p">;</span>
<span class="p">}</span>
</pre></div>
</pre>
<p>Я опустил некоторые дополнительные методы, имеющиеся в данном классе, так как особой востребованностью они не пользовались.</p>
<p>Чем это может быть полезно? Главный плюс -- автодополнение. Дополнительный -- у вас появляется возможность отслеживать ассеты на уровне кода. Если какой-либо файл будет удален или изменено его имя -- код на это отреагирует и вы получите статическую ошибку, вместо отлова её в рантайме (если не уследили за этим). Разрешение коллизий имен ассетов (например два файла в одинаковым именем, лежащих в разных папках) тоже есть, и выглядит вот так:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nc">Assets</span> <span class="p">{</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">enIntl</span> <span class="o">=></span> <span class="n">enIntlS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">enIntlS</span> <span class="o">=</span> <span class="s1">'assets/i18/en_intl.yaml'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">ruIntl</span> <span class="o">=></span> <span class="n">ruIntlS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">ruIntlS</span> <span class="o">=</span> <span class="s1">'assets/i18/ru_intl.yaml'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound</span> <span class="o">=></span> <span class="n">notFoundS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFoundS</span> <span class="o">=</span> <span class="s1">'assets/images/blabla/notFound.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound1</span> <span class="o">=></span> <span class="n">notFound1S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound1S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_1.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound2</span> <span class="o">=></span> <span class="n">notFound2S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound2S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_2.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound3</span> <span class="o">=></span> <span class="n">notFound3S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound3S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_3.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound4</span> <span class="o">=></span> <span class="n">notFound4S</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound4S</span> <span class="o">=</span> <span class="s1">'assets/images/notFound_4.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFoundCopy</span> <span class="o">=></span> <span class="n">notFoundCopyS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFoundCopyS</span> <span class="o">=</span> <span class="s1">'assets/images/old_content/notFound.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFoundCopyCopy</span> <span class="o">=></span> <span class="n">notFoundCopyCopyS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFoundCopyCopyS</span> <span class="o">=</span> <span class="s1">'assets/images/something_else/notFound.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFoundCopyCopyCopy</span> <span class="o">=></span> <span class="n">notFoundCopyCopyCopyS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFoundCopyCopyCopyS</span> <span class="o">=</span> <span class="s1">'assets/images/very_important_content/notFound.png'</span><span class="p">;</span>
<span class="kt">String</span> <span class="kd">get</span> <span class="n">notFound3Copy</span> <span class="o">=></span> <span class="n">notFound3CopyS</span><span class="p">;</span>
<span class="kd">static</span> <span class="kd">const</span> <span class="kt">String</span> <span class="n">notFound3CopyS</span> <span class="o">=</span> <span class="s1">'assets/images/very_important_content/notFound_3.png'</span><span class="p">;</span>
<span class="p">}</span>
</pre></div>
</pre>
<h2>Окончательное заключение</h2>
<p>Надо было что-то оставить на самый финал -- на этом действительно все.</p>
<p>Больше материалов смотрите <a href="https://habr.com/ru/users/alphamikle/">в моем блоге</a> на Хабре. </p>
</div>
</article>
</div>
<div class="blog__tile blog__tile_slim">
<div class="blog-post__footer">
<div class="blog-post__footer-item blog-post__footer-item_right">
<div class="inline-block">
<div class="blog-post__footer-text blog-post__footer-text_share">Поделиться</div>
<div class="blog-post__footer-icons">
<div class="blog-post__footer-icon-box hover-ic js-share" data-href="https://otus.ru/nest/post/2430/" data-type="tg">
<div class="blog-post__footer-icon ic ic-tg-square ic-tg-square-hover"></div>
</div>
<div class="blog-post__footer-icon-box hover-ic js-share" data-href="https://otus.ru/nest/post/2430/" data-type="tw">
<div class="blog-post__footer-icon ic ic-twitter-square ic-twitter-square-hover"></div>
</div>
<div class="blog-post__footer-icon-box hover-ic js-share" data-href="https://otus.ru/nest/post/2430/" data-type="vk">
<div class="blog-post__footer-icon ic ic-vk-square ic-vk-square-hover"></div>
</div>
</div>
</div>
<div class="inline-block float-right_ssm">
<div class="blog__counters-item blog__counters-item_bookmark">
<a href="#"
class="js-blog-post-mark blog-post__footer-icon blog-post__footer-icon_star"
post_id=2430></a>
</div>
</div>
</div>
<div class="blog-post__footer-item">
<div class="blog-vote">
<div class="blog-vote__item blog-vote__item_border blog-vote__item_left js-blog-post-dislike"
data-url="/nest/vote/post/" data-id=2430></div>
<div class="blog-vote__item blog-vote__item_border blog-vote__item_center blog-vote__item_pos js-post-votes"
data-url="/nest/vote/post/" data-id=2430>
1
</div>
<div class="blog-vote__item blog-vote__item_border blog-vote__item_right js-blog-post-like"
data-url="/nest/vote/post/" data-id=2430></div>
</div>
</div>
<div class="inline-block float-right_ssm">
<a class="blog-post__footer-item" href="/nest/post/2430/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text js-blog-post-comments-counter">0</div>
</a>
<div class="blog-post__footer-item">
<div class="blog-post__footer-icon blog-post__footer-icon_views"></div>
<div class="blog-post__footer-text">2</div>
</div>
</div>
</div>
</div>
</div>
<div
id="formSubscribe"
class="blog-subscribe__container js-subscribe-container"
>
<form
method="post"
novalidate
action="/nest/blog/subscribe/"
class="js-ajax-form js-new-validation blog-subscribe blog__external"
data-before-send='[
{"action": "addClass", "el": "#formSubscribe .js-content", "class": "hidden"},
{"action": "show", "el": "#formSubscribe .js-loader"}
]'
data-after-complete='[
{"action": "hide", "el": "#formSubscribe .js-loader"}
]'
data-after-error='[{"action": "removeClass", "el": "#formSubscribe .js-content", "class": "hidden"}]'
data-after-success='[
{"action": "addClass", "el": "#formSubscribe .js-block-common", "class": "hidden"},
{"action": "show", "el": "#formSubscribe .js-block-success"},
{"action": "addClass", "el": "#formSubscribe.js-subscribe-container", "class": "blog-subscribe__container_finish"}
]'
data-success-msg="false"
>
<div class="js-block-common">
<p class="blog-subscribe__title">Не пропустите новые полезные статьи!</p>
<div class="blog-subscribe__fields">
<input type="hidden" name="csrfmiddlewaretoken" value="FGazzZi3liZOHCpItqwEyQ9u0ycfja6NrnxR5Iek8iIwY5puzoRdYxHKg3KMa8pi">
<input type="hidden" name="object_id" value="95"/>
<div class="js-content">
<div class="new-input-line blog-subscribe__input-line">
<div class="new-input-group new-input-group_right blog-subscribe__group">
<div class="new-input new-input_fake new-input_full">
<input
type="email"
class="new-input new-input_full js-placeholder new-input_border-no blog-subscribe__input"
data-title="email"
autocomplete="email"
name="email"
required
placeholder="Введите ваш email"
/>
<div class="new-input__error-sign new-ic new-ic-warning js-new-input-error">
<div class="new-input__error-text-container">
<p class="new-input__error-text js-new-input-error-text"></p>
</div>
</div>
</div>
<button
type="submit"
class="new-input-group__addon new-input-group__addon_button new-button new-button_blue blog-subscribe__button"
>
Подписаться
</button>
</div>
</div>
<div class="new-input-line new-input-line_last">
<label class="checkbox checkbox_new">
<input required type="checkbox" checked name="subscription_agree" value="true">
<div class="checkbox__label blog-subscribe__checkbox-text">
Соглашаюсь получать полезные новости, статьи,
приглашения на мастер-классы и специальные предложения OTUS
</div>
</label>
<div class="new-input__error-sign new-ic new-ic-warning js-new-input-error">
<div class="new-input__error-text-container">
<p class="new-input__error-text js-new-input-error-text"></p>
</div>
</div>
</div>
</div>
<div class="hide js-loader">
<i class="ic loader loader_md loader_absolute-center ic-loader"></i>
</div>
</div>
</div>
<div class="js-block-success blog-subscribe__success hide">
<p class="blog-subscribe__title">Спасибо за подписку!</p>
<p class="blog-subscribe__text text-center">
Мы отправили вам письмо для подтверждения вашего email.
<br/>
С уважением, OTUS!
</p>
</div>
</form>
</div>
<div class="blog__tile" id="author_block">
<div class="blog__h2">Автор</div>
<div class="post-info">
<a href="/profile/174576/">
<div class="post-info__avatar post-info__avatar_big ic ic-blog-default-avatar" style="background-image: url(https://cdn.otus.ru/media/public/12/2e/avatar-1801-122eb1.png);"></div>
<div class="post-info__author">Михаил</div>
</a>
<div class="post-info__text">
Рейтинг:
<div class="post-info__text-ratingprofile__rating_pos">
+16
</div>
</div>
<div class="post-info__text">
1872 дня
</div>
<div class="post-info__button">
</div>
</div>
</div>
<div class="blog-tile-wrapper">
<div class="blog-tile">
<div class="blog-tile__item blog-tile__item_no-padding-bottom blog-tile__item_last">
<div class="blog__h2 blog__h2_slim">Похожие посты</div>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/373/" title="Как одному написать сложную программу?">
Как одному написать сложную программу?
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/373/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/373/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+9</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/605/" title="Принципы программирования: стек и куча: что это такое?">
Принципы программирования: стек и куча: что это такое?
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/605/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/605/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+1</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/609/" title="Что такое сигнатура функции? Signature (сигнатура) — это что?">
Что такое сигнатура функции? Signature (сигнатура) — это что?
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/609/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/609/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+1</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/680/" title="Рынок труда PHP-разработчиков на 1 квартал 2019 года">
Рынок труда PHP-разработчиков на 1 квартал 2019 года
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/680/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">1</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/680/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+2</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/872/" title="Прокрастинация в программировании и методы борьбы с ней">
Прокрастинация в программировании и методы борьбы с ней
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/872/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/872/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">0</div>
</a>
</div>
</div>
</div>
<div class="blog__comments blog__comments-blog-default">
<div data-comments>
<div class="blog__h2">
<div class="text text_inline text_default js-blog-comments-counter">
0 комментариев
</div>
</div>
<div class="js-comments">
</div>
<div class="blog-comment-deny">
Для комментирования необходимо <a href="/login/?next=https%3A%2F%2Fotus.ru%2Fnest%2Fpost%2F2430%2F">авторизоваться</a>
</div>
</div>
</div>
</div>
<div class="container__col container__col_4 container__col_md-0">
<div class="blog-tile-wrapper">
<div class="blog-tile">
<div class="blog-tile__title">Популярное</div>
<div class="blog-tile__item blog-tile__item_last">Сегодня тут пусто</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="blog-footer">
<div class="container cookies__container">
<div class="cookies__margin-block cookies_hide js-cookie">
<div class="cookies">
<div class="cookies__title">
Посещая наш сайт, вы принимаете <a class="cookies__link" href="/legal/cookie/" target="_blank">политику использования cookie-файлов</a>
</div>
<button class="js-cookie-accept cookies__button">ОК</button>
</div>
</div>
</div>
<footer class="footer2 footer2_header3 footer2_desktop footer2_not-subscribed no-print ">
<div class="footer2__container container">
<div class="footer2__container-box">
<div class="footer2__content">
<div class="container__row">
<div class="container__col container__col_4">
<div class="container__row">
<div class="container__col container__col_6 container__col_md-5">
<div class="footer2__links">
<div class="footer2__links-row">
<a href="/about" class="footer2__link" title="О нас">О нас</a>
</div>
<div class="footer2__links-row">
<a href="/smi/" class="footer2__link" title="СМИ о нас">СМИ о нас</a>
</div>
<div class="footer2__links-row">
<a href="/reviews" class="footer2__link" title="Отзывы">Отзывы</a>
</div>
<div class="footer2__links-row">
<a href="/contacts/" class="footer2__link" title="Контакты">Контакты</a>
</div>
<div class="footer2__links-row">
<a href="/journal/" class="footer2__link" title="Блог">Блог</a>
</div>
<div class="footer2__links-row">
<a href="/faq/" class="footer2__link" title="FAQ">FAQ</a>
</div>
</div>
</div>
<div class="container__col container__col_6 container__col_md-7">
<div class="footer2__icons">
<div class="footer2__social">
<a href="https://vk.com/club145052891" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-vk-footer2 ic-vk-footer2-hover"></a>
<a href="https://zen.yandex.ru/id/5bbcbc1ba5bd5400a990e7d9" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-zen ic-zen-hover"></a>
<a href="https://www.youtube.com/channel/UCetgtvy93o3i3CvyGXKFU3g" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-yt-footer2 ic-yt-footer2-hover"></a>
</div>
<a href="https://ttttt.me/Otusjava" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic">
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Канал в Telegram</p>
</a>
<a href="https://ttttt.me/joinchat/JMakp0NXc-L8nNneHCtx7A" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic" >
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Группа в Telegram</p>
</a>
</div>
</div>
</div>
</div>
<div class="container__col container__col_4">
<div class="footer2__links footer2__links_center">
<div class="footer2__links-row">
<a href="/b2b" class="footer2__link" rel="nofollow" title="Корпоративное обучение">
Корпоративное обучение
</a>
</div>
<div class="footer2__links-row">
<a href="/lessons/" class="footer2__link" title="Каталог курсов">
Каталог курсов
</a>
</div>
<div class="footer2__links-row">
<a href="/about/loyalty/" class="footer2__link" title="Программы лояльности">Программы лояльности</a>
</div>
<div class="footer2__links-row">
<a href="/professions/" class="footer2__link" title="Каталог профессий">Каталог профессий</a>
</div>
<div class="footer2__links-row">
<a href="/employers/all/" class="footer2__link" title="Наши партнеры">Наши партнеры</a>
</div>
<div class="footer2__links-row">
<a href="/teach/" class="footer2__link" title="Стать преподавателем">
Стать преподавателем
</a>
</div>
</div>
</div>
<div class="container__col container__col_4">
<p class="footer2__text footer2__text_margin-bot">Подписка на новости IT, анонсы открытых уроков, спец. предложения</p>
<form method="post" class="footer2__subscribe js-subscribe" action="/lessons/subscribe/">
<input type="hidden" name="csrfmiddlewaretoken" value="FGazzZi3liZOHCpItqwEyQ9u0ycfja6NrnxR5Iek8iIwY5puzoRdYxHKg3KMa8pi">
<input
required
type="email"
name="email"
class="input footer2__subscribe-input"
placeholder="Электронная почта"
value=""
/>
<button
class="footer2__subscribe-button button button_blue button_as-input"
type="submit"
disabled
>
Подписаться
</button>
<div class="new-input-line new-input-line_relative new-input-line_triple footer2__subscribe-policy">
<label class="new-checkbox new-checkbox_vertical-center new-log-reg__checkbox">
<input type="checkbox" checked name="terms_agree" value="true"
class="js-remove-field-error">
<div class="new-checkbox__label">
Я принимаю условия
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/privacy/"
>
Политики обработки персональных данных
</a>
и
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/terms/"
>
Пользовательского соглашения
</a>
и даю
<a target="_blank" class="new-link-dotted-blue" href="
/legal/lead_privacy_agree/"
">
свое согласие на обработку персональных данных
</a>
</div>
</label>
</div>
</form>
<p class="footer2__text footer2__text_margin-bot">
По всем вопросам пишите на
<a class="footer2__link" href="mailto:help@otus.ru" target="_blank" rel="nofollow noreferer" title="help@otus.ru">help@otus.ru</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/common/"
target="_blank"
rel="nofollow noreferer"
title="Сведения об образовательной организации"
>
Сведения об образовательной организации
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/it_company_accreditation/"
target="_blank"
rel="nofollow noreferer"
title="OTUS является аккредитованной IT-компанией"
>
OTUS является аккредитованной IT-компанией
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/recommendations/"
target="_blank"
rel="nofollow noreferer"
title="Сведения о рекомендательных технологиях"
>
Сведения о рекомендательных технологиях
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="https://reestr.digital.gov.ru/reestr/2704482/"
target="_blank"
rel="nofollow noreferer"
title="В реестре отечественного ПО №24216"
>
В реестре отечественного ПО №24216
</a>
</p>
</div>
</div>
</div>
<div class="footer2__info">
<div class="container__row">
<div class="container__col container__col_bottom container__col_8">
<div class="container__row">
<div class="container__col container__col_3 container__col_md-7">
<p
class="footer2__text footer2__text_nowrap footer2__text_margin-right "
>
© 2015-2026 OTUS
</p>
</div>
<div class="container__col container__col_3 container__col_md-5">
<a
class="footer2__link footer2__link-white-space-normal"
href="/legal/terms/"
title="Условия использования сервиса"
>
Условия использования сервиса
</a>
</div>
</div>
</div>
<div class="container__col container__col_middle container__col_4">
<div class="footer2__info-logos footer2__info-logos_desktop">
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box">
<div class="runet__wrapper">
<div class="runet">
<div class="runet__text">Премия Рунета <br>2018</div>
</div>
</div>
</div>
<div class="footer2__info-logo-box">
<a class="footer2__info-logo footer2__info-logo_skolkovo" href="http://sk.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box footer2__info-logo-box_not-first footer2__info-logo-box_kts">
<a class="footer2__info-logo footer2__info-logo_kts" href="https://ktsstudio.com/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first">
<a class="footer2__info-logo footer2__info-logo_uno" href="http://goodlookin.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box"></div>
<div class="footer2__info-logo-box">
<div class="runet-mobile">
<div class="runet-mobile__text">Премия Рунета <br>2018</div>
</div>
</div>
<div class="footer2__info-logo-box"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</footer>
<div class="footer2 footer2_mobile no-print ">
<div class="container footer2-mobile__container">
<div class="footer2-mobile__wrapper">
<div class="footer2-mobile__row">
<div class="container__row">
<div class="container__col container__col_12">
<p class="footer2__text footer2__text_margin-bot">Подписка на новости IT, анонсы открытых уроков, спец. предложения</p>
<form method="post" class="footer2__subscribe js-subscribe" action="/lessons/subscribe/">
<input type="hidden" name="csrfmiddlewaretoken" value="FGazzZi3liZOHCpItqwEyQ9u0ycfja6NrnxR5Iek8iIwY5puzoRdYxHKg3KMa8pi">
<input
required
type="email"
name="email"
class="input footer2__subscribe-input"
placeholder="Электронная почта"
value=""
/>
<button
class="footer2__subscribe-button button button_blue button_as-input"
type="submit"
disabled
>
Подписаться
</button>
<div class="new-input-line new-input-line_relative new-input-line_triple footer2__subscribe-policy">
<label class="new-checkbox new-checkbox_vertical-center new-log-reg__checkbox">
<input type="checkbox" checked name="terms_agree" value="true"
class="js-remove-field-error">
<div class="new-checkbox__label">
Я принимаю условия
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/privacy/"
>
Политики обработки персональных данных
</a>
и
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/terms/"
>
Пользовательского соглашения
</a>
и даю
<a target="_blank" class="new-link-dotted-blue" href="
/legal/lead_privacy_agree/"
">
свое согласие на обработку персональных данных
</a>
</div>
</label>
</div>
</form>
<p class="footer2__text footer2__text_margin-bot">
По всем вопросам пишите на
<a class="footer2__link"
href="mailto:help@otus.ru"
target="_blank" rel="nofollow noreferer"
title="help@otus.ru">help@otus.ru</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a class="footer2__link"
href="/legal/common/"
target="_blank" rel="nofollow noreferer"
title="Сведения об образовательной организации"
>
Сведения об образовательной организации
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/it_company_accreditation/"
target="_blank"
rel="nofollow noreferer"
title="OTUS является аккредитованной IT-компанией"
>
OTUS является аккредитованной IT-компанией
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/recommendations/"
target="_blank"
rel="nofollow noreferer"
title="Сведения о рекомендательных технологиях"
>
Сведения о рекомендательных технологиях
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="https://reestr.digital.gov.ru/reestr/2704482/"
target="_blank"
rel="nofollow noreferer"
title="В реестре отечественного ПО №24216"
>
В реестре отечественного ПО №24216
</a>
</p>
</div>
</div>
</div>
<div class="footer2-mobile__row footer2-mobile__row_social">
<div class="container__row">
<div class="container__col container__col_7 footer2-mobile__col_tg">
<a href="https://ttttt.me/Otusjava" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic">
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Канал в Telegram</p>
</a>
<a href="https://ttttt.me/joinchat/JMakp0NXc-L8nNneHCtx7A" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic" >
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Группа в Telegram</p>
</a>
</div>
<div class="container__col container__col_5 footer2-mobile__col_social">
<div class="footer2__social footer2__social_mobile">
<a href="https://vk.com/club145052891" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-vk-footer2 ic-vk-footer2-hover"></a>
<a href="https://zen.yandex.ru/id/5bbcbc1ba5bd5400a990e7d9" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-zen ic-zen-hover"></a>
<a href="https://www.youtube.com/channel/UCetgtvy93o3i3CvyGXKFU3g" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-yt-footer2 ic-yt-footer2-hover"></a>
</div>
</div>
</div>
</div>
<div class="footer2-mobile__row">
<div class="container__row">
<div class="container__col container__col_6 container__col_xs375-8">
<div class="footer2__links ">
<div class="footer2__links-row">
<a href="/b2b" class="footer2__link" rel="nofollow" title="Корпоративное обучение">
Корпоративное обучение
</a>
</div>
<div class="footer2__links-row">
<a href="/lessons/" class="footer2__link" rel=nofollow title="Каталог курсов">
Каталог курсов
</a>
</div>
<div class="footer2__links-row">
<a href="/about/loyalty/" class="footer2__link" rel=nofollow title="Программы лояльности">Программы лояльности</a>
</div>
<div class="footer2__links-row">
<a href="/professions/" class="footer2__link" rel=nofollow title="Каталог профессий">Каталог профессий</a>
</div>
<div class="footer2__links-row">
<a href="/employers/all/" class="footer2__link" rel=nofollow title="Наши партнеры">Наши партнеры</a>
</div>
<div class="footer2__links-row">
<a href="/teach/" class="footer2__link" rel=nofollow title="Стать преподавателем">
Стать преподавателем
</a>
</div>
</div>
</div>
<div class="container__col container__col_6 container__col_xs375-4">
<div class="footer2__links">
<div class="footer2__links-row">
<a href="/about" class="footer2__link" rel=nofollow title="О нас">О нас</a>
</div>
<div class="footer2__links-row">
<a href="/smi/" class="footer2__link" rel=nofollow title="СМИ о нас">СМИ о нас</a>
</div>
<div class="footer2__links-row">
<a href="/reviews" class="footer2__link" rel=nofollow title="Отзывы">Отзывы</a>
</div>
<div class="footer2__links-row">
<a href="/contacts/" class="footer2__link" rel=nofollow title="Контакты">Контакты</a>
</div>
<div class="footer2__links-row">
<a href="/journal/" class="footer2__link" rel=nofollow title="Блог">Блог</a>
</div>
<div class="footer2__links-row">
<a href="/faq/" class="footer2__link" rel=nofollow title="FAQ">FAQ</a>
</div>
</div>
</div>
</div>
</div>
<div class="footer2-mobile__row footer2-mobile__row_mark">
<div class="footer2-mobile__mark">
<p
class="footer2__text footer2__text_nowrap footer2__text_margin-right footer2__text_margin-right"
>
© 2015-2026 OTUS
</p>
<a
class="footer2__link"
href="/legal/terms/"
title="Пользовательское соглашение"
>
Пользовательское соглашение
</a>
</div>
<div class="footer2-mobile__logos">
<div class="footer2__info-logos footer2__info-logos_tablet">
<div class="footer2__info-logos-row_mobile">
<div class="footer2__info-logo-box">
<a class="footer2__info-logo footer2__info-logo_skolkovo" href="http://sk.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box">
<div class="runet-mobile">
<div class="runet-mobile__text">Премия Рунета <br>2018</div>
</div>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first footer2__info-logo-box_kts">
<a class="footer2__info-logo footer2__info-logo_kts" href="https://ktsstudio.com/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first">
<a class="footer2__info-logo footer2__info-logo_uno" href="http://goodlookin.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
</div>
<div class="footer2__info-logos footer2__info-logos_mobile">
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box">
<div class="runet-mobile">
<div class="runet-mobile__text">Премия Рунета <br>2018</div>
</div>
</div>
<div class="footer2__info-logo-box">
<a class="footer2__info-logo footer2__info-logo_skolkovo" href="http://sk.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box footer2__info-logo-box_not-first footer2__info-logo-box_kts">
<a class="footer2__info-logo footer2__info-logo_kts" href="https://ktsstudio.com/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first">
<a class="footer2__info-logo footer2__info-logo_uno" href="http://goodlookin.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-container hide-top hide-transparent new-log-reg-container"
data-query='{"next": "/nest/post/2430/"}'
data-modal-ajax="/login-registration/"
data-modal-ajax-once="true"
data-modal-id="new-log-reg"
data-modal-hide-remove-class="hide-top,hide-transparent"
data-modal-hide-add-class="hide-transparent"
data-modal-loading="true"
data-modal-hide-add-delay-class="hide-top">
<div class="new-log-reg-loader">
<div class="new-log-reg__login">
<div class="loader loader_md loader_absolute-center ic-loader ic"></div>
</div>
</div>
<div class="new-log-reg-wrapper js-modal-content">
<div class="new-log-reg__login"></div>
</div>
</div>
<div class="modal-container hide-top hide-transparent new-log-reg-container"
data-query='{"next": "/nest/post/2430/"}'
data-modal-ajax="/login-registration/"
data-modal-id="new-log-reg-event"
data-modal-hide-remove-class="hide-top,hide-transparent"
data-modal-hide-add-class="hide-transparent"
data-modal-loading="true"
data-modal-hide-add-delay-class="hide-top">
<div class="new-log-reg-loader">
<div class="new-log-reg__login">
<div class="loader loader_md loader_absolute-center ic-loader ic"></div>
</div>
</div>
<div class="new-log-reg-wrapper js-modal-content">
<div class="new-log-reg__login"></div>
</div>
</div>
<div class="modal-container hide" data-modal-id="restore-password">
<div class="modal-wrapper modal-wrapper_dark js-modal-wrapper">
<div class="modal new-log-reg__popup">
<div class="new-log-reg__popup-body">
<div class="modal__close new-ic new-ic-close-inverse new-log-reg__popup-close js-close-modal"></div>
<p class="new-log-reg__popup-title">Восстановление пароля</p>
<form method="post" class="js-restore-password" action="/api/restore_password.send_email">
<input type="hidden" name="csrfmiddlewaretoken" value="FGazzZi3liZOHCpItqwEyQ9u0ycfja6NrnxR5Iek8iIwY5puzoRdYxHKg3KMa8pi">
<div class="new-log-reg__popup-text new-log-reg__popup-text_slim">
Введите электронную почту для восстановления пароля
</div>
<div class="new-input-line new-input-line_slim new-input-line_relative">
<input type="email"
class="new-input new-input_full js-placeholder js-input
js-required
"
maxlength=""
name="email"
autocomplete="off"
required
placeholder="Электронная почта"
/>
<div class="new-input-error new-input-error_bottom js-validation-error hide "></div>
<div class="new-input-error new-input-error_info new-input-error_bottom hide"></div>
</div>
<div class="new-input-line js-error hide">
<span class="new-input-line__error new-log-reg__popup-error">
Пользователь с таким email не найден
</span>
</div>
<div class="new-input-line">
<button class="new-button new-button_md new-button_full new-button_blue"
type="submit">Восстановить
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="modal-container hide" data-modal-id="phone_duplicate">
<div class="modal-wrapper modal-wrapper_dark js-modal-wrapper" data-no-wrapper-close="true">
<div class="modal new-log-reg__popup new-log-reg__popup-body">
<button class="new-log-reg__popup-close new-ic-close-inverse js-close-modal js-stats"
data-event="Register;double_tel_bad"
style=""></button>
<form>
<p class="new-log-reg__popup-title">
Ваш телефон уже привязан к<br/>другой учетной записи
</p>
<p class="new-log-reg__popup-text">
Ваш телефон <b><nobr class="js-phone-duplicate-phone"></nobr></b> уже
привязан к учетной записи <b><nobr class="js-phone-duplicate-acc"></nobr></b>.
Выберите учетную запись, с которой желаете продолжить работу.
Мы привяжем к ней телефон.
</p>
<input class="js-dod-phone-dup-modal-phone" type="hidden" value="" name="phone">
<div class="dod-phone-dup-modal__loader-container">
<div class="js-loader-content">
<div class="new-input-line">
<button
class="new-button new-button_md new-button_full new-button_one-line
new-button_blue-inverse js-stats js-dod-phone-dup-modal-submit"
type="submit"
data-value="1"
data-event="Register;double_tel_ok"
>
Текущий аккаунт <nobr class="js-phone-duplicate-self-acc"></nobr>
</button>
</div>
<div class="new-input-line">
<button
class="new-button new-button_md new-button_full new-button_one-line
new-button_blue js-stats js-dod-phone-dup-modal-submit"
type="submit"
data-value="0"
data-event="Register;double_tel_ok"
>
Войти в аккаунт <nobr class="js-phone-duplicate-acc"></nobr>
</button>
</div>
<div class="new-input-line js-error hide">
<span class="new-input-line__error new-log-reg__popup-error js-error-text"></span>
</div>
</div>
<div
class="js-loader loader loader_absolute-center loader_md ic ic-loader"
style="display: none;"
></div>
</div>
</form>
</div>
</div>
</div>
<div class="modal-container hide" data-modal-id="teacher-enrollment">
<div class="modal-wrapper modal-wrapper_dark js-modal-wrapper" data-no-wrapper-close="true">
<div class="modal new-log-reg__popup">
<form class="new-log-reg__popup-body js-email-terms-update js-form-in-modal"
action="/teacher/request/"
data-modal="teacher-enrollment">
<div class="modal__close new-ic new-ic-close-inverse new-log-reg__popup-close js-close-modal"></div>
<div class="new-log-reg__popup-title">
Заполните номер телефона
</div>
<div class="new-log-reg__popup-text new-log-reg__popup-text_slim">
Для отправки заявки в преподаватели заполните номер телефона
</div>
<div class="new-input-line new-input-line_slim new-input-line_relative">
<input type="text"
class="new-input new-input_full js-placeholder js-input
js-required
"
maxlength="255"
name="phone"
autocomplete="off"
data-js-mask="phone"
required
placeholder="Телефон"
/>
<div class="new-input-error new-input-error_bottom js-validation-error hide "></div>
<div class="new-input-error new-input-error_info new-input-error_bottom hide"></div>
</div>
<div class="new-input-line js-error hide">
<span class="new-input-line__error new-log-reg__popup-error js-text"></span>
</div>
<div class="new-input-line">
<button class="new-button new-button_md new-button_full new-button_blue"
type="submit">Отправить
</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://otus.ru/static/js/common.792bd.js" ></script>
<script src="https://otus.ru/static/js/vendor.common.2ae83.js" ></script>
<script src="https://otus.ru/static/js/vendor.otus.90c85.js" ></script>
<script src="https://otus.ru/static/js/otus.714df.js" ></script>
<script src="https://otus.ru/static/js/vendor.react.3b8bf.js" ></script>
<script src="https://otus.ru/static/js/vendor.common.2ae83.js" ></script>
<script src="https://otus.ru/static/js/vendor.otus-react:header-search.706a9.js" ></script>
<script src="https://otus.ru/static/js/otus-react:header-search.34d9b.js" ></script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?r=w1EsimsoVZRm*rb04TRjaay1IST5bHFNpthXLyHrq1GCPGzgOmMRcY3mDDdxA17TQOlx6ykxwlrtBtW7sMri/2f364oz1QGDuVvxHFqMqER5NT9mlhp1lYbEMKPIB7NgDRQQp5s2IxBuu*caPsHzTtfILgZJsV5bbkH0m8*GleA-&pixel_id=1000094479';</script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?p=VK-RTRG-182645-Hs1B';</script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?p=VK-RTRG-110005-dVnpE';</script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?p=VK-RTRG-226047-a8wzo';</script>
<noscript>
<div>
<img src="https://mc.yandex.ru/watch/34531570" style="position:absolute; left:-9999px; top: 0;" alt=""/>
<img src="https://mc.yandex.ru/watch/82755226" style="position:absolute; left:-9999px; top: 0;" alt=""/>
<img src="https://mc.yandex.ru/watch/93715742" style="position:absolute; left:-9999px; top: 0;" alt=""/>
</div>
</noscript>
<img height="1" width="1" src="https://happy.otus.ru/pixel/otus.gif?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyZWZlcmVyIjoiaHR0cHM6Ly9vdHVzLnJ1L25lc3QvcG9zdC8yNDMwLz91dG1fc291cmNlPXR5cGVpbiZ1dG1fbWVkaXVtPWRpcmVjdCZ1dG1fY2FtcGFpZ249Tm9uZSJ9.OJ8CrHjAajcyH4sn17uoaxb8hWaJqkxbIcmFUt-7B2c" style="position:absolute; left:-9999px; top: 0;" alt="" />
</body>
</html>