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

Это вторая статья из цикла о разработке приложения на 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: () =&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. И первый же запрос в гугл предоставляет мне прекрасную, полностью бесплатную, без авторизаций (и с очень небольшими ограничениями), апишку.

С одной стороны -- я безмерно рад тому, что не придется пилить никакие прокси, и также рад тому, что смогу показать вам как оно работает в вебе без всяких “но”, но с другой -- если бы я сначала подумал, поискал, а не бросился пилить код, сэкономил бы часов 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';
}

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

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

Больше материалов смотрите в моем блоге на Хабре.