Как стать автором
Обновить

Разработка приложения на Flutter с нуля до релиза: Part 2

Open source *Разработка мобильных приложений *Dart *Flutter *
Tutorial

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

Автор: Staselnik - собственная работа, CC BY-SA 3.0, https://commons.wikimedia.org/w/index.php?curid=33424138
Автор: Staselnik - собственная работа, CC BY-SA 3.0, https://commons.wikimedia.org/w/index.php?curid=33424138

Ссылки на статьи цикла

Часть 1: Идея + Базовая инфраструктура

Часть 2: Сеть, локализация, локальный поиск, главный экран

Сеть

Для отрисовки первого экрана необходимы следующие данные:

Фрагмент первого экрана
Фрагмент первого экрана
image
title
subtitle
price
diff

Исходя из этого получаем следующую сущность, описывающую каждый из токенов:

import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';

import '../../../service/types/types.dart';
import 'item_prices.dart';
part 'stock_item.g.dart';
// BTC, ETH etc.
typedef CryptoSymbol = String;
/* Example of data:
{
  "id": 1,
  "name": "Bitcoin",
  "symbol": "BTC",
  "max_supply": 21000000,
  "circulating_supply": 18897568,
  "total_supply": 18897568,
  "platform": null,
  "cmc_rank": 1,
  "last_updated": "2021-12-11T03:44:02.000Z",
  "quote": {
    "USD": {
      "price": 48394.083464545605,
      "volume_24h": 32477191827.784477,
      "volume_change_24h": 7.5353,
      "percent_change_1h": 0.3400355,
      "percent_change_24h": 0.05623531,
      "percent_change_7d": -7.88809336,
      "percent_change_30d": -25.12367453,
      "percent_change_60d": -14.67776793,
      "percent_change_90d": 6.86740691,
      "market_cap": 914530483068.9261,
      "market_cap_dominance": 40.8876,
      "fully_diluted_market_cap": 1016275752755.46,
      "last_updated": "2021-12-11T03:44:02.000Z"
    }
  }
}
 */
@immutable
@JsonSerializable()
class StockItem {
  const StockItem({
    required this.id,
    required this.name,
    required this.symbol,
    required this.prices,
  });
  factory StockItem.fromJson(Json json) => _$StockItemFromJson(json);
  final int id;
  final String name;
  final CryptoSymbol symbol;
  @JsonKey(name: 'quote')
  final Map<CryptoSymbol, ItemPrices> prices;
  ItemPrices get usdPrices => prices['USD']!;
  String imageUrl(int size) {
    assert(size > 128 && size <= 250);
    return '<https://s2.coinmarketcap.com/static/img/coins/${size}x$size/$id.png>';
  }
  Json toJson() => _$StockItemToJson(this);
}

Поле id появилось как необходимость для отображения логотипов валют. Так как исходный ресурс предоставляет их как раз по id.

И еще одна сущность, описывающая цены криптовалюты в валюте обычной:

import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';

import '../../../service/types/types.dart';
part 'item_prices.g.dart';

@immutable
@JsonSerializable()
class ItemPrices {
  const ItemPrices({
    required this.price,
    required this.diff1h,
    required this.diff24h,
  });
  factory ItemPrices.fromJson(Json json) => _$ItemPricesFromJson(json);
  final double price;
  @JsonKey(name: 'percent_change_1h')
  final double diff1h;
  @JsonKey(name: 'percent_change_24h')
  final double diff24h;
  Json toJson() => _$ItemPricesToJson(this);
}

Для сериализации / десериализации моделей я использовал json_serializable. Осталось только загрузить данные. Тут нам на помощь приходит кодогенерация в лице retrofit. Благодаря данному решению мы можем избавиться от необходимости написания хоть какой-то части бойлерплейта (но не всей). Сетевую логику, связанную с получением списка крипты разместим в классе CryptoProvider.

import 'package:dio/dio.dart';
import 'package:high_low/domain/crypto/dto/stock_response.dart';
import 'package:retrofit/http.dart';

part 'crypto_provider.g.dart';
@RestApi(baseUrl: '<https://pro-api.coinmarketcap.com/v1/>')
abstract class CryptoProvider {
  factory CryptoProvider(Dio dio, {String? baseUrl}) = _CryptoProvider;
  @GET('cryptocurrency/listings/latest')
  Future<StockResponse> fetchLatestData({
    @Header('X-CMC_PRO_API_KEY') required String token,
    @Query('limit') int limit = 1000,
  });
}

Конечно же, в DI-регистратор была добавлена фабрика CryptoProvider и Dio:

import 'package:dio/dio.dart';
import 'package:flutter/widgets.dart';

import '../../domain/crypto/logic/crypto_provider.dart';
import '../routing/default_router_information_parser.dart';
import '../routing/page_builder.dart';
import '../routing/root_router_delegate.dart';
import 'di.dart';
void initDependencies() {
  Di.reg<BackButtonDispatcher>(() => RootBackButtonDispatcher());
  Di.reg<RouteInformationParser<Object>>(() => DefaultRouterInformationParser());
  Di.reg<RouterDelegate<Object>>(() => RootRouterDelegate());
  Di.reg(() => PageBuilder());
  Di.reg(() => Dio(), asBuilder: true); // <--
  Di.reg(() => CryptoProvider(Di.get()), asBuilder: true); // <--
}

На данном этапе у нас получается следующая структура проекта (внутренности service пока опускаю):

|-- domain
|   `-- crypto
|       |-- dto
|       |   |-- item_prices.dart
|       |   |-- stock_item.dart
|       |   |-- stock_item_example.json
|       |   `-- stock_response.dart
|       `-- logic
|           `-- crypto_provider.dart
|-- high_low_app.dart
|-- main.dart
`-- service
    |-- config
    |-- di
    |-- logs
    |-- routing
    |-- theme
    |-- tools
    |-- types

Если вы задались вопросом, как получить такую картинку директории, вот ответ. Ну и на данном этапе работа с сетью завершена, все что нужно для отображения главного экрана у нас уже есть.

State

Вот мы и подбираемся к UI с логикой. Давайте начнем с последней, так как иначе она все равно заспойлерится в интерфейсе.

Но, прежде чем начать описывать состояние нашего приложения, нужно сделать большое лирическое отступление. Для тех, кто занимается разработкой приложений на Flutter не секрет, что Dart - однопоточный язык с возможностью запуска нескольких, так называемых Isolate - изолированных потоков со своим собственным Event Loop и памятью. И обычно, большинство разработчиков пишет весь код “просто в одном потоке”. То есть не заморачивается с тем, чтобы выносить тяжелые операции, потенциально блокирующие UI в отдельные изоляты (но я никого не виню, стандартное API весьма громоздкое, compute() не то, чтобы спасал, а различные сторонние библиотеки...ну кому они нужны?, изоляты - сложно ведь). Со временем могут происходить неприятные изменения в приложении или данных, прилетающих с бэка, становится все больше и все начинает лагать. Из-за чего? Давайте проведем небольшое исследование.

Исследование

Я провел 3 эксперимента по 5 раз для двух окружений. Первое окружение: profile-сборка на флагманском устройстве (Samsung Galaxy Note 20 Ultra), находящемся в режиме “обычное использование” - то есть я не перезагружал телефон перед каждым прогоном, но каждый раз выгружал из памяти приложение, а других активно запущенных приложений не было. Второе окружение: определенного рода симуляция слабого устройства, которое у пользователя вашего приложения тоже может оказаться - это эмулятор со следующими настройками:

  • 2048Mb RAM

  • 256Mb VM Heap

  • 4 Cores CPU

Сам эмулятор был запущен на ноутбуке с Ryzen 7 5800H, никаких фоновых задач нет (только открытая IDEA).

Теперь к сути испытаний - для главного экрана необходимо загрузить данные о криптовалютах. Я загружал их по 100, 1000 и 5000 штук за один запрос. По окончанию запроса измерял время, требуемое на преобразование ответа сервера (массив байт) в сырую JSON-строку, которая, затем, десереализуется в Map<String, dynamic>, все это - подкапотная логика Dio, в которую я добавил только логирование времени. Вторая операция, подвергнутая анализу - уже преобразование мапки в бизнес-классы, с которыми в реальном приложении мы и работаем.

Для того, чтобы внедрить логирование в Dio пришлось изрядно покопаться в его внутренних органах: все указанные преобразования происходят посредством класса Transformer. Данный класс можно написать самому и скормить Dio, а можно ничего и не делать - тогда будет использоваться DefaultTransformer. Приведу тот кусок стандартного трансформера, который отвечает за то, чтобы вы смогли получить мапку на выходе (справа от каждой добавленной строки есть комментарий с префиксом <--, в котором описано, что тут происходит):

Future transformResponse(
      RequestOptions options, ResponseBody response) async {
    if (options.responseType == ResponseType.stream) {
      return response;
    }
    var length = 0;
    var received = 0;
    var showDownloadProgress = options.onReceiveProgress != null;
    if (showDownloadProgress) {
      length = int.parse(
          response.headers[Headers.contentLengthHeader]?.first ?? '-1');
    }
    var completer = Completer();
    var stream =
        response.stream.transform<Uint8List>(StreamTransformer.fromHandlers(
      handleData: (data, sink) {
        sink.add(data);
        if (showDownloadProgress) {
          received += data.length;
          options.onReceiveProgress?.call(received, length);
        }
      },
    ));
    // let's keep references to the data chunks and concatenate them later
    final chunks = <Uint8List>[];
    var finalSize = 0;

int totalDuration = 0; // <-- Total computation time in microseconds
int networkTime = 0; // <-- Time (microseconds), which will spend to accumulate parts of network response

StreamSubscription subscription = stream.listen(
  (chunk) {
    final start = DateTime.now().microsecondsSinceEpoch; // <-- Before saving each part of the data we start tracking the current time
    finalSize += chunk.length;
    chunks.add(chunk);
    final now = DateTime.now().microsecondsSinceEpoch; // <--
    totalDuration += now - start; // <-- After the chunk of data was saved, we check spent time
    networkTime += now - start; // <--
  },
  onError: (Object error, StackTrace stackTrace) {
    completer.completeError(error, stackTrace);
  },
  onDone: () =&gt; completer.complete(),
  cancelOnError: true,
);
// ignore: unawaited_futures
options.cancelToken?.whenCancel.then((_) {
  return subscription.cancel();
});
if (options.receiveTimeout &gt; 0) {
  try {
    await completer.future
        .timeout(Duration(milliseconds: options.receiveTimeout));
  } on TimeoutException {
    await subscription.cancel();
    throw DioError(
      requestOptions: options,
      error: 'Receiving data timeout[${options.receiveTimeout}ms]',
      type: DioErrorType.receiveTimeout,
    );
  }
} else {
  await completer.future;
}
final start = DateTime.now().microsecondsSinceEpoch; // <-- 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 &amp;&amp;
    options.responseType == ResponseType.json &amp;&amp;
    _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 - метрика, означающая, “сколько килобайт можно преобразовать за одну милисекунду”

Таблица!
Таблица!

А вот мои выводы из этой таблицы:

  1. В лучшем случае, если у пользователя устройство верхнего ценового сегмента - мы можем рассчитывать на то, что оно будет способно обработать до ~18kB/ms (возможно, самые новые флагманы будут способны и на большее)

  2. Ремарка про худший случай - так как [D] окружение было запущено на эмуляторе с JIT-компиляцией, то мы имеем некоторые негативные экстремумы, связанные с тем, что код еще не разогрелся. Это отчетливо видно на объеме данных в 100 единиц - было потрачено чрезвычайно много времени, выбивающееся из статистики. Поэтому я не буду брать значение в 2.629kB/ms как минимальное, а возьму 8.603kB/ms, как более близкое к реальности. Делаем вывод - мы можем рассчитывать на то, что устройство пользователя сможет обработать хотя бы ~9kB/ms

  3. Будем исходить из того, что все большее количество девайсов обладает экранами с частотой обновления 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(() =&gt; isLaunching = false);

  }
  void _filterStocks() {
    if (_prevSearch != searchController.text) {
      _prevSearch = searchController.text;
      run(event: MainEvent.filterStocks, data: searchController.text);
    }
  }
  void _setFilteredStocks({required MainEvent event, required List<StockItem> data}) {
    _update(() {
      stocks.clear();
      stocks.addAll(data);
    });
  }
  void _startLoadingStocks({required MainEvent event, void data}) {
    _update(() {
      isStocksLoading = true;
    });
  }
  void _endLoadingStocks({required MainEvent event, void data}) {
    _update(() {
      isStocksLoading = false;
    });
  }
  void _update(VoidCallback dataChanger) {
    dataChanger();
    notifyListeners();
  }
  static MainBackend _launch(BackendArgument<void> argument) {
    initDependencies();
    return MainBackend(argument: argument, cryptoProvider: Di.get());
  }
  @override
  void initActions() {
    whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);
    whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);
    whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);
  }
}

Логика работы библиотеки, отчасти, похожа на Bloc - необходимо зарегистрировать обработчики сообщений, прилетающих с Backend. Регистрируются они в методе initActions:

@override
  void initActions() {
    whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);
    whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);
    whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);
  }

В качестве идентификатора события выступает любая сущность, но важно то, что проверка на соответствие будет происходить через обычное равенство ==. Также, можно зарегистрировать обработчик на определенный тип идентификаторов, в этом случае он будет обрабатывать все события, идентифицируемые конкретно этим типом:

class SpecificMessageId {
    const SpecificMessageId(this.someValue);
    final int someValue;
}

void initActions() {
    whenEventCome<SpecificMessageId>().run(_specificHandler);
  }

Стоит добавить несколько слов и о самих обработчиках. Все обработчики должны соответствовать следующему типу (не соответствующие не получится зарегистрировать):

typedef FrontendAction<Event, Req, Res> =
  FutureOr<Res> Function({required Event event, required Req data});

Но, при этом, значение data не обязательно должно прилетать. Идентификатор-событие event будет прилетать всегда. То есть, следующие обработчики зарегистрируются и будут корректными:

void _startLoadingStocks({required MainEvent event, void data}) {
    _update(() {
      isStocksLoading = true;
    });
  }

void _endLoadingStocks({required MainEvent event, void data}) {
    _update(() {
      isStocksLoading = false;
    });
  }

Смысл обработчиков заключается в том, что если вы ходите только реагировать на события, инициированные Backend - нужен обработчик. Если же вы хотите вызвать какой-то метод Backend - можно обойтись и без обработчиков вовсе.


При вызове любого Backend-метода из Frontend вы всегда получите какой-нибудь ответ “на месте”, завернутый в своеобразный union-type Maybe<T>. Union-типов в Dart на данный момент нет, кроме одного встроенного FutureOr<T>, поэтому, для корректной типизации данных методов пришлось создавать Maybe<T>, он может включать в себя просто T, List<T> или ошибку, ну или вообще все три - null, если метод Backend не возвращает ничего (но, на самом деле, Backend-методы всегда должны возвращать кое-что, что вы увидите немного ниже).

Следующий код демонстрирует вызов MainBackend метода по event = MainEvent.loadStocks и получение результата сразу в месте вызова:

Future<void> loadStocks() async {
    errorOnLoadingStocks = false;
    final Maybe<StockItem> stocks = await run(event: MainEvent.loadStocks);
    if (stocks.hasList) {
      _update(() {
        this.stocks.clear();
        this.stocks.addAll(stocks.list);
      });
    }
    if (stocks.hasError) {
      _update(() {
        errorOnLoadingStocks = true;
      });
      await _notificationService.showSnackBar(content:
                                    _localizationWrapper.loc.main.errors.loadingError);
    }
  }

Немного забегая наперед покажу и соответствующий этому event метод MainBackend, который и будет исполнен в стороннем изоляте:

Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {
    await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
    try {
      final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
      _stocks.clear();
      _stocks.addAll(stockItems);
    } catch (error) {
      await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
      rethrow;
    }
    await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
    return ActionResponse.list(_stocks);
  }

Пока не буду описывать его содержимое, об этом будет ниже.


Следующий метод launch нужен для инициализации MainFrontend и MainBackend. В нем вызывается метод initBackend миксина Frontend, в который необходимо передать, как минимум, один аргумент: функцию-инициализатор, которая запустится уже в стороннем изоляте, и эта функция должна возвращать инстанс соответствующего Backend.

Future<void> launch({
    required NotificationService notificationService,
    required LocalizationWrapper localizationWrapper,
  }) async {
    if (!isLaunching || _isLaunched || _isInLaunchProcess) {
      return;
    }
    _notificationService = notificationService;
    _localizationWrapper = localizationWrapper;

_isInLaunchProcess = true;
searchController.addListener(_filterStocks);
await initBackend(initializer: _launch);
_isInLaunchProcess = false;
_isLaunched = true;
_update(() => isLaunching = false);

  }

Давайте взглянем на нее поближе:

static MainBackend _launch(BackendArgument<void> argument) {
    initDependencies();
    return MainBackend(argument: argument, CryptoProvider: Di.get());
  }

В этой функции нам необходимо повторно инициализировать Di-контейнер, так как сторонний изолят не знает ничего о том, что происходило в главном и все фабрики в стороннем изоляте не зарегистрированы. Требования к функции-инициализатору аналогичны требованиям к оригинальной функции entryPoint, используемой в Isolate API. А вот её интерфейс:

typedef BackendInitializer<T, B extends Backend> =
																								B Function(BackendArgument<T> argument);

Также, Frontend позволяет регистрировать хуки, вызываемые на каждое сообщение от Backend, только на сообщения, которые должны принудительно заставить Frontend уведомить UI об изменении данных; можно подписаться (например одному Frontend на другой), посредством метода subscribeOnEvent. Об этом будет сказано немного подробнее в блоке про UI.

Backend

Я начну с метода Frontend, который вызывается для получения данных о крипте. При первичной отрисовке главного экрана в хуке initState виджета MainView происходит инициализация MainFrontend (см. метод MainFrontend.launch). По завершению которой вызывается метод loadStocks (который был разобран выше):

// main_view.dart

Future<void> _launchMainFrontend() async {
    final MainFrontend mainFrontend = Provider.of(context, listen: false);
    await mainFrontend.launch(notificationService: Provider.of(context, listen: false), localizationWrapper: Provider.of(context, listen: false));
    await mainFrontend.loadStocks();
  }
@override
  void initState() {
    super.initState();
    _launchMainFrontend();
        // ...
  }

Выше уже отсветил один из методов MainBackend, что-же, вот теперь пора представить и сам класс, который будет существовать в отдельном изоляте на протяжении жизни всего приложения:

import 'dart:async';

import '../../crypto/logic/crypto_provider.dart';
import 'package:isolator/isolator.dart';
import '../../crypto/dto/stock_item.dart';
import 'main_frontend.dart';
typedef StockItemFilter = bool Function(StockItem);
class MainBackend extends Backend {
  MainBackend({
    required BackendArgument<void> argument,
    required CryptoProvider cryptoProvider,
  })  : _cryptoProvider = cryptoProvider,
        super(argument: argument);
  final CryptoProvider _cryptoProvider;
  final List<StockItem> _stocks = [];
  Timer? _searchTimer;
  Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {
    await send(event: MainEvent.startLoadingStocks, sendDirectly: true);
    try {
      final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
      _stocks.clear();
      _stocks.addAll(stockItems);
    } catch (error) {
      await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
      rethrow;
    }
    await send(event: MainEvent.endLoadingStocks, sendDirectly: true);
    return ActionResponse.list(_stocks);
  }
  ActionResponse<StockItem> _filterStocks({required MainEvent event, required String data}) {
    final String searchSubString = data;
    send(event: MainEvent.startLoadingStocks);
    _searchTimer?.cancel();
    _searchTimer = Timer(const Duration(milliseconds: 500), () async {
      _searchTimer = null;
      final List<StockItem> filteredStocks = _stocks.where(_stockFilterPredicate(searchSubString)).toList();
      await send(
        event: MainEvent.updateFilteredStocks,
        data: ActionResponse.list(filteredStocks),
      );
      await send(event: MainEvent.endLoadingStocks);
    });
    return ActionResponse.empty();
  }
  StockItemFilter _stockFilterPredicate(String searchSubString) {
    final RegExp filterRegExp = RegExp(searchSubString, caseSensitive: false, unicode: true);
    return (StockItem item) {
      if (searchSubString.isEmpty) {
        return true;
      }
      return filterRegExp.hasMatch(item.symbol) || filterRegExp.hasMatch(item.name);
    };
  }
  @override
  void initActions() {
    whenEventCome(MainEvent.loadStocks).run(_loadStocks);
    whenEventCome(MainEvent.filterStocks).run(_filterStocks);
  }
}

По аналогии с Frontend в любом Backend есть возможность регистрации обработчиков событий с тем же самым API, но небольшим отличием в типе обработчика:

typedef BackendAction<Event, Req, Res> = FutureOr<ActionResponse<Res>> Function({required Event event, required Req data});

Отличие заключается в том, что если Frontend обработчик может не возвращать ничего, то Backend обработчик обязан возвращать результат вида ActionResponse<T>, либо падать с ошибкой. Это является следствием определенных ограничений при работе с типами в Dart.

Также, обработчик является выходной точкой любого Backend, каждый из которых может вызывать обработчики любого другого Backend, делается это посредством специальных сущностей Interactor. Вот и вот небольшой пример.


Теперь разберем подробнее метод получения криптовалют. Перед началом загрузки мы посылаем сообщение в MainFrontend, чтобы отобразить в интерфейсе, что идет процесс загрузки.

await send(event: MainEvent.startLoadingStocks, sendDirectly: true);

Затем, происходит сама загрузка данных и их сохранение в MainBackend для возможности локального поиска.

final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();
_stocks.clear();
_stocks.addAll(stockItems);

Теперь начинается кое-что интересное, что стало возможным с выходом Dart 2.15. Упомянутая выше возможность библиотеки передавать любой объем данных без просадки кадров достигается (раньше достигалась) посредством разбиения массива данных на чанки и передачей этих чанков во Frontend по очереди. Логика тут была простая, если данных много - их можно так или иначе представить в виде массива, а его можно без проблем разбить на маленькие куски и передать без проблем с производительностью. Собственно, эта старая логика отображена передачей данных, завернутых в специальный wrapper Chunks:

await send(
        event: MainEvent.loadStocks,
        data: ActionResponse.chunks(
          Chunks(
            data: _stocks,
            updateAfterFirstChunk: true,
            size: 100,
            delay: const Duration(milliseconds: 8),
          ),
        ),
      );

При этом сборка чанков во Frontend происходила “магически-автоматически”, и обработчик, который ожидал получения большой пачки данных - просто получал свой готовый огромный массив. Все эти возможности придется выпилить, так как особого смысла от них теперь нет.

С приходом новой версии Dart стало возможным передавать любой объем данных любого типа за константное время и без ограничений по типу передаваемых данных - теперь можно без проблем передавать не только массивы, но и любую другую структуру, если это необходимо. Сейчас достаточно использовать обычный метод отправки сообщений, который будет использовать под капотом пресловутый Isolate.exit:

await send(
        event: MainEvent.loadStocks,
        data: ActionResponse.list(_stocks),
      );

При этом, как говорит документация, возможность быстрой передачи данных доступна только при уничтожении отправляющего изолята. А так как наш MainBackend (да и любой другой Backend) - стремится жить на протяжении существования всего приложения (по крайней мере такова их задумка, но их и без проблем можно закрывать, но, всё-таки, не таким способом), то использовать Isolate.exit напрямую в этом изоляте нельзя - он, по большому счету, завершится аварийно. Чтобы обойти это недоразумение наш Backend создает дополнительный транспортный изолят, в который классическим способом (глубоким копированием средствами Dart VM) передается любое количество данных, никак не влияющее на UI-изолят, а затем этот одноразовый транспортный изолят уничтожается, передавая при этом, данные в наш UI-изолят.

Вернемся к разбору нашего метода загрузки крипты. Так как мы организуем “синхронный” вызов Backend-метода из Frontend, то наш Backend-метод должен вернуть этот результат:

return ActionResponse.list(_stocks);

Также, при отправке события начала загрузки данных был указан дополнительный параметр sendDirectly, думаю, самое время описать и его - так как мы не всегда передаём большое количество данных из Backend во Frontend, то и не всегда нужно пользоваться услугами транспортного изолята - можно передавать данные напрямую. Если это необходимо - использование данного параметра позволит отправлять сообщения без сторонней помощи.

Локальный поиск

Более подробно останавливаться на методе локального поиска останавливаться не буду, так как, кажется, статья уже стала лонгридом 🙂. Работает он как поиск по регулярному выражению. Могу добавить только то, что вы можете получить ответ на главный вопрос вселенной с его помощью и даже немного больше.

UI

После завершения данного этапа структура домена main станет такой:

|-- domain
|   `-- main
|       |-- logic
|       |   |-- main_backend.dart
|       |   `-- main_frontend.dart
|       `-- ui
|           |-- main_header.dart
|           |-- main_view.dart
|           `-- stock_item_tile.dart
|-- high_low_app.dart
`-- main.dart

Опишем содержимое папочки ui:

main_view.dart содержит StatefulWidget главного экрана

import 'package:flutter/material.dart';
import 'package:isolator/next/frontend/frontend_event_subscription.dart';
import 'package:provider/provider.dart';
import 'package:yalo_assets/lib.dart';
import 'package:yalo_locale/lib.dart';

import '../../../service/theme/app_theme.dart';
import '../../../service/tools/utils.dart';
import '../../crypto/dto/stock_item.dart';
import '../../notification/logic/notification_service.dart';
import '../logic/main_frontend.dart';
import 'main_header.dart';
import 'stock_item_tile.dart';
class MainView extends StatefulWidget {
  const MainView({Key? key}) : super(key: key);
  @override
  _MainViewState createState() => _MainViewState();
}
class _MainViewState extends State<MainView> {
  MainFrontend get _mainFrontend => Provider.of(context);
  late final FrontendEventSubscription<MainEvent> _eventSubscription;
  Widget _stockItemBuilder(BuildContext context, int index) {
    final StockItem item = _mainFrontend.stocks[index];
    final bool isFirst = index == 0;
    final bool isLast = index == _mainFrontend.stocks.length - 1;
return Padding(
  padding: EdgeInsets.only(
    left: 8,
    top: isFirst ? 8 : 0,
    right: 8,
    bottom: isLast ? MediaQuery.of(context).padding.bottom + 8 : 8,
  ),
  child: StockItemTile(item: item),
);

  }
  void _onSearchEnd(MainEvent event) {
    final MainFrontend mainFrontend = Provider.of<MainFrontend>(context, listen: false);
    final LocalizationMessages loc = Messages.of(context);
    final int stocksCount = mainFrontend.stocks.length;
    final String content = loc.main.search.result(stocksCount);
    Provider.of<NotificationService>(context, listen: false).showSnackBar(
      content: content,
      backgroundColor: AppTheme.of(context, listen: false).okColor,
    );
  }
  Future<void> _launchMainFrontend() async {
    final MainFrontend mainFrontend = Provider.of(context, listen: false);
    await mainFrontend.launch(notificationService: Provider.of(context, listen: false), localizationWrapper: Provider.of(context, listen: false));
    await mainFrontend.loadStocks();
  }
  @override
  void initState() {
    super.initState();
    _launchMainFrontend();
    _eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(
      listener: _onSearchEnd,
      event: MainEvent.updateFilteredStocks,
      onEveryEvent: true,
    );
  }
  @override
  void dispose() {
    _eventSubscription.close();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    final Assets assets = Provider.of<Assets>(context, listen: false);
    final AppTheme theme = AppTheme.of(context);
    final MaterialStateProperty<Color> buttonColor = MaterialStateProperty.resolveWith((states) => theme.buttonColor);
    final ButtonStyle buttonStyle = ButtonStyle(
      foregroundColor: buttonColor,
      overlayColor: MaterialStateProperty.resolveWith((states) => theme.splashColor),
      shadowColor: buttonColor,
    );
    final List<String> notFoundImages = [
      assets.notFound1,
      assets.notFound2,
      assets.notFound3,
      assets.notFound4,
    ].map((e) => e.replaceFirst('assets/', '')).toList();
Widget body;

if (_mainFrontend.isLaunching) {
  body = Center(
    child: Text(Messages.of(context).main.loading),
  );
} else if (_mainFrontend.errorOnLoadingStocks) {
  body = Center(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Padding(
            padding: const EdgeInsets.only(bottom: 16),
            child: Image.asset(notFoundImages[Utils.randomIntBetween(0, notFoundImages.length - 1)]),
          ),
          TextButton(
            onPressed: _mainFrontend.loadStocks,
            style: buttonStyle,
            child: Text(Messages.of(context).main.repeat),
          ),
        ],
      ),
    ),
  );
} else {
  body = CustomScrollView(
    physics: const BouncingScrollPhysics(),
    slivers: [
      const MainHeader(),
      SliverList(
        delegate: SliverChildBuilderDelegate(
          _stockItemBuilder,
          childCount: _mainFrontend.stocks.length,
        ),
      ),
    ],
  );
}

return Scaffold(
  body: AnimatedSwitcher(
    duration: const Duration(milliseconds: 250),
    child: body,
  ),
);

  }
}

Что есть интересного здесь? Инициализацию MainFrontend уже обсудили, остался только подписчик на события. Кстати, вот он:

_eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(
      listener: _onSearchEnd,
      event: MainEvent.updateFilteredStocks,
      onEveryEvent: true,
    );

Вызов данного метода позволяет уведомляться в том, что наш MainFrontend получил сообщение соответствующего типа от MainBackend. Метод subscribeOnEvent является частью Frontend в принципе.

В результате мы получаем такие уведомления, каждый раз, когда нам прилетает порция данных после поиска:

И это - является подводкой к теме локализации приложений на Flutter.

Локализация интерфейса

Уже довольно давно я задавался вопросом - как можно быстро локализовать приложение на Flutter. Если взглянуть на официальный гайд - то первое впечатление “без бутылки не разберешься”. Второе, собственно - тоже. И тогда я подумал, что если избавиться от громоздкого .arb, и вместо него использовать .yaml? Так родился пакет assets_codegen (ссылку я не прикладываю, так как он deprecated). Его идея была в следующем - располагаем файлы локализации в ассетах, аннотируем какой-нибудь класс, чтобы к нему цеплялся код локализации, запускаем flutter pub run build_runner watch и наслаждаемся. Решение было более чем работоспособным, но имелись и минусы - логика отслеживания изменений в файлах локализации была написана руками, а котогенерация Dart не позволяет отслеживать изменения не в Dart-файлах, и результат совмещения стандартного кодогенератора и рукописного вотчера иной раз удручал. В общем было много раздражающих багов. И вот однажды, уже имея некоторое понимание, как часто приходится добавлять новые строки локализации и сразу же после этого ожидать их появления в коде (спойлер - крайне редко), я решил написать полностью новый пакет, еще и название которого, родившееся в моей голове, очень мне понравилось.

Так появился пакет yalo. С предельно простой логикой (описанной в документации) - размещаем файлы локализации в ассетах, запускаем генератор командой

flutter pub run yalo:loc, подключаем к проекту сгенерированный локальный пакет .yalo_locale, используем пару переменных в корневой ...App:

import 'package:flutter/material.dart';
import 'package:yalo_locale/lib.dart';

import 'service/di/di.dart';
class HighLowApp extends StatelessWidget {
  const HighLowApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: Di.get<RouteInformationParser<Object>>(),
      routerDelegate: Di.get<RouterDelegate<Object>>(),
      backButtonDispatcher: Di.get<BackButtonDispatcher>(),
      theme: Theme.of(context).copyWith(brightness: Brightness.dark),
      debugShowCheckedModeBanner: false,
      localizationsDelegates: localizationsDelegates, // <-- 1
      supportedLocales: supportedLocales, // <-- 2
      onGenerateTitle: (BuildContext context) => Messages.of(context).common.appTitle,
    );
  }
}

И используем локализованный контент. С плюрализацией, префиксами, сколько угодно глубокой вложенностью и подстановкой (пока только для числовых данных в плюрализированных строках). Примеры использования вы уже могли заметить выше, но продемонстрирую их отдельно.

Генерация названия приложения:

(BuildContext context) => Messages.of(context).common.appTitle

Подсказка поля ввода поиска:

Messages.of(context).main.search.hint

Количество элементов после поиска в SnackBar:

Messages.of(context).main.search.result(
  											Provider.of<MainFrontend>(context, listen: false).stocks.length)

Появляется это все из такого файлика:

main:
  loading: Загрузка...
  search:
    hint: Поиск
    result:
      zero: Мы ничего не нашли
      one: Мы нашли ${howMany} элемент
      two: Мы нашли ${howMany} элемента
      other: Мы нашли ${howMany} элементов

common:
  currency: '\$'
  percent: '%'
  appTitle: High Low

Точнее, файликов, лежащих вот так:

|-- README.md
|-- analysis_options.yaml
|-- assets
|   `-- i18
|       |-- en_intl.yaml
|       `-- ru_intl.yaml
`-- watch.sh

Но вместо префикса файла, можно раскладывать их по папкам - ../ru/intl.dart

Заключение

На этот раз статья поспела за кодом и все, что реализовано - тут описано. В третьей статье я сделаю полностью второй экран (учитывая графики и игровую механику, возможно третья часть выйдет во время новогодних праздников), покажу работу с ассетами здорового человека и implicit-анимацию любого текста.

И еще, приложу изменения, которые произошли со времени первой части. И, код текущего состояния проекта.

Особая секция

Как сцены после титров в Marvel - данная секция для особых зрителей читателей. Уже дописав данную статью я был практически готов её опубликовать. Но чувство перфекционизма старательно откусывало от меня кусочки - на момент “готовности” статьи isolator не был доработан настолько, чтобы было можно использовать его и в web. И ещё мне хотелось показать не только картинки приложения, но и дать возможность его “потыкать”. И вот я за пару вечеров добавил возможность работы в web (как и прежде - без многопоточности, но с сохранением полной работоспособности без изменений в вашем коде). Затем встал вопрос о публикации приложения. Публиковать в сторах я планирую в самом конце, а пока можно было бы сделать это на github.pages. Тут-то и начинается самое интересное.

Запустил web-версию локально, все отлично работает, за исключением одного NO! - API сервиса, который я начал использовать изначально, не позволяет осуществлять CORS-запросы, “чтобы не палить ваши токены авторизации”, видимо, про реверс API приложений они не слышали. Ну да ладно. Я начал искать способы, как можно обойти это ограничение без необходимости пилить свой собственный proxy, хостить его где-то и т.д. Нашел curl-online, сделал запрос через него (через интерфейс самого сервиса) - все заработало. Сразу начал делать web-имплементацию CryptoProvider, который бы использовался в web-сборке и ходил за данными через web-curl. И снова:

У меня локально все работает

Деплой на github.pages → и снова CORS, но уже у самого курла (почему я не додумался выполнить этот запрос из консоли браузера со страницы приложения на pages - очень большой вопроc). Время - час ночи, и я неунывающими красными глазами начинаю пялить в код пишушейся прокси для этого всего. Еще пол часа и глаза говорят “пора спать”. Проснувшись на следующий день, рано утром, я снова начал искать способы не писать прокси и, видимо, правду говорят - утро вечера мудренее, я додумываюсь поискать альтернативу самому API. И первый же запрос в гугл предоставляет мне [прекрасную](https://www.coingecko.com/en/api/documentation?), полностью бесплатную, без авторизаций (и с очень небольшими ограничениями), апишку.

С одной стороны - я безмерно рад тому, что не придется пилить никакие прокси, и также рад тому, что смогу показать вам как оно работает в вебе без всяких “но”, но с другой - если бы я сначала подумал, поискал, а не бросился пилить код, сэкономил бы часов 8 жизни...

В общем результаты таковы, что isolator v2 теперь полностью готов к использованию. Ну и вы можете взглянуть на web-версию того, что уже реализовано. У API есть ограничение на 50 вызовов в минуту, так что если сработает хабраэффект - вы увидите Экран ошибки, на котором будет достаточно нажать одну кнопку.

Ассеты

Если бы не особая секция, и все страдания, которые там описаны, этот раздел действительно был бы должен оказаться в третьей статье. Изначально я хотел показать работу с ними в этой, но во время написания статьи понял, что нет особых мест, кроме как придуманных исскуственно, где они были бы к месту. Затем, во время реализации логики, связанной с возможностью исчерпания лимита моего токена авторизации на первом ресурсе появилось место, где ассеты будут к месту. Идея была такова - если ресурс моего токена заканчивается, то при получении ошибки во время запроса отобразится дополнительный экран, где будет висеть какая-нибудь прикольная картинка, а также инпут для ввода вашего собственного токена авторизации, с которым бы у вас лично все заработало. После перехода на новое API логика по использованию вашего токена отпала сама собой, но, потенциально, осталась возможность наткнуться на ошибку из-за лимитов API по RPS. Поэтому, если вы увидите данный экран - то хабраэффект сработал.

А теперь к самой работе с ассетами! Упомянутый выше пакет yalo, позволяет не только генерировать локализацию из .yaml файлов, но также, он позволяет генерировать код с именами всех ассетов, лежащих в вашей папке assets (или любой другой, если она корректно указана в pubspec.yaml). Сейчас структура папки assets данного проекта имеет следующий вид:

./assets
|-- i18
|   |-- en_intl.yaml
|   `-- ru_intl.yaml
`-- images
    |-- notFound_1.png
    |-- notFound_2.png
    |-- notFound_3.png
    `-- notFound_4.png

При условии, что у вас в проекте уже установлен данный пакет, вы можете запустить следующую команду:

flutter pub run yalo:asset

Результатом такой команды будет сгенерированный пакет .yalo_assets в корне вашего проекта, который, по аналогии с .yalo_locale нужно добавить в pubspec.yaml:

dependencies:
    //...
  yalo_locale:
    path: ./.yalo_locale
  yalo_assets:
    path: ./.yalo_assets

После этих манипуляций вы получаете доступ к классу со статическими и обычными геттерами:

class Assets {
  String get enIntl => enIntlS;
  static const String enIntlS = 'assets/i18/en_intl.yaml';

  String get ruIntl => ruIntlS;
  static const String ruIntlS = 'assets/i18/ru_intl.yaml';
  String get notFound1 => notFound1S;
  static const String notFound1S = 'assets/images/notFound_1.png';
  String get notFound2 => notFound2S;
  static const String notFound2S = 'assets/images/notFound_2.png';
  String get notFound3 => notFound3S;
  static const String notFound3S = 'assets/images/notFound_3.png';
  String get notFound4 => notFound4S;
  static const String notFound4S = 'assets/images/notFound_4.png';
}

Я опустил некоторые дополнительные методы, имеющиеся в данном классе, так как особой востребованностью они не пользовались.

Чем это может быть полезно? Главный плюс - автодополнение. Дополнительный - у вас появляется возможность отслеживать ассеты на уровне кода. Если какой-либо файл будет удален или изменено его имя - код на это отреагирует и вы получите статическую ошибку, вместо отлова её в рантайме (если не уследили за этим). Разрешение коллизий имен ассетов (например два файла в одинаковым именем, лежащих в разных папках) тоже есть, и выглядит вот так:

class Assets {
  String get enIntl => enIntlS;
  static const String enIntlS = 'assets/i18/en_intl.yaml';

  String get ruIntl => ruIntlS;
  static const String ruIntlS = 'assets/i18/ru_intl.yaml';
  String get notFound => notFoundS;
  static const String notFoundS = 'assets/images/blabla/notFound.png';
  String get notFound1 => notFound1S;
  static const String notFound1S = 'assets/images/notFound_1.png';
  String get notFound2 => notFound2S;
  static const String notFound2S = 'assets/images/notFound_2.png';
  String get notFound3 => notFound3S;
  static const String notFound3S = 'assets/images/notFound_3.png';
  String get notFound4 => notFound4S;
  static const String notFound4S = 'assets/images/notFound_4.png';
  String get notFoundCopy => notFoundCopyS;
  static const String notFoundCopyS = 'assets/images/old_content/notFound.png';
  String get notFoundCopyCopy => notFoundCopyCopyS;
  static const String notFoundCopyCopyS = 'assets/images/something_else/notFound.png';
  String get notFoundCopyCopyCopy => notFoundCopyCopyCopyS;
  static const String notFoundCopyCopyCopyS = 'assets/images/very_important_content/notFound.png';
  String get notFound3Copy => notFound3CopyS;
  static const String notFound3CopyS = 'assets/images/very_important_content/notFound_3.png';
}

Окончательное заключение

Надо было что-то оставить на самый финал - на этом действительно все.

Теги:
Хабы:
Всего голосов 9: ↑9 и ↓0 +9
Просмотры 7.5K
Комментарии Комментарии 7