Секреты запуска Flutter в production. Создаем IT-верфи

6-yhbncdpelvfswcscqzzzkhxp8.jpeg

Привет! Мы Даниил Левицкий и Дмитрий Дронов, мобильные разработчики компании ATI.SU — крупнейшей в России и СНГ Бирже грузоперевозок. Хотим поделиться с вами своим видением разработки приложений на Flutter.

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

FLUTTER-ШАБЛОН-ПРИМЕР

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

ddn5h51fhfzrme6fkn4tzuk5zas.jpeg


1. Структура проекта

Важно сразу предусмотреть расширяемую и наглядно понятную структуру проекта. Возможно, ваш проект будет пополняться большим количеством механик, а команда — разработчиками. Непротиворечивая структура облегчит понимание и дальнейшую легкость поддержки.

В нашем случае мы пришли к структуре вида:

Рисунок 1. Структура дирректорий проекта
Рисунок 1. Структура дирректорий проекта

app — содержит в себе сущности, относящиеся непосредственно к Application Flutter-приложения: к теме приложения, навигации приложения, окружению запуска и локализации.

Рисунок 2. Структура дирректории app
Рисунок 2. Структура дирректории app

arch — различные изолированные от проекта библиотеки/утилиты, которые можно было бы выделить во внешние pub-пакеты, но они пока не готовы к такой публикации. Например: расширение BLoC, функциональные модели.

Рисунок 3. Структура дирректории arch
Рисунок 3. Структура дирректории arch

const — общие константы приложения.

Рисунок 4. Структура дирректории const
Рисунок 4. Структура дирректории const

core — ядро приложения, которое относится ко всем фичам, без него они не смогут функционировать. Например: общие модели данных, объекты для работы с сетью или хранилищем, общие виджеты.

Рисунок 5. Структура дирректории core
Рисунок 5. Структура дирректории core

features — реализация конкретных фич, которые входят в ваше приложение. Фичи должны быть максимально изолированными друг от друга и содержать все бизнес-слои внутри себя. Благодаря такой структуре, в будущем каждую фичу можно будет выделить в два отдельных package: api package — содержащий интерфейсы фичи; impl package — содержащий реализацию. Таким образом все фичи будут зависеть только от core-api packge и при необходимости других feature-api package.

Рисунок 6. Структура дирректории features
Рисунок 6. Структура дирректории features


2. Бизнес-слои

Договоритесь с командой в самом начале: какой именно архитектурный подход вы будете использовать. Желательно вживую «пощупайте» все обсуждаемые решения.

Качества хорошего архитектурного решения:


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

В нашей практике мы используем Clean Architecture подход. Это подход, когда приложение делится на логические слои, у каждого слоя своя зона ответственности.

Принято выделять следующие слои: Presentation, Domain, Data. Подробнее про clean-подход в мобильных приложениях можно почитать в статье Заблуждения Clean Architecture или в **гайде от Google.**

Расшифруем, как данные слои влияют на архитектуру нашего шаблона Flutter-проекта.


Presentation

Слой, отвечающий за отображение данных и взаимодействие с пользователем. Данный слой внутри себя разделяется на две части:


  • UI-объекты — набор объектов, относящихся непосредственно к пользовательскому интерфейсу. Сюда можно отнести Page-объекты, Widget-объекты, сущности, взаимодействующие со стилями, вспомогательные сущности для выполнения сложных анимаций, менеджеры диалогов и так далее. UI-объекты могут взаимодействовать только с объектами из Presentation-слоя.
  • State Managment объекты — набор объектов, отвечающих за хранение состояния одного или нескольких UI-объектов. Пользовательский интерфейс изменяется при каждом взаимодействии с пользователем и может находиться в разных состояниях, поэтому такие объекты помогают решать проблему разделения ответсвенности между отрисовкой интерфейса и хранением его состояния. Примером таких объектов могут быть: Presenter, ViewModel, BLoC и т.д. В нашем варианте используется BLoC. Также ещё одна функция таких объектов — бизнес-логика приложения. State Managment-объекты ничего не знают об UI-объектах, но могут взаимодействовать с объектами из Domain слоя или другими State Managment-объектами.

Рисунок 7. Пример, структура дирректории presentation слоя
Рисунок 7. Пример, структура дирректории presentation слоя


Domain

Слой, который содержит в себе изолированную от UI бизнес-логику приложения. Бизнес-объекты, которые относятся к данному слою, должны быть максимально изолированы от платформенных зависимостей, в рамках которых они работают. Например, если вынести кусок данного слоя в package из Flutter-приложения, то он должен быть совместим с Dart-приложением без Flutter-зависимостей. Например, для поддержания общей логики с бекендом (Shelf-сервером) или CLI.

Interactor — наиболее популярный объект в данном слое. Он выполняет бизнес-логику для поддержания пользовательского сценария или набора пользовательских сценариев.

Есть негласное правило, что для одного сценария выделяется один Interactor, но для упрощения структуры проекта мы допускаем объединения ряда общих сценариев в один Intreactor.

Но помимо Interactor на этом слое могут возникать и объекты с другим наименованием. Например, различные Builder-объекты, CommandProcessor-объекты и так далее.

Объекты с данного слоя могут взаимодействовать с объектами из Data слоя и другими объектами из Domain слоя.


Data

Слой, который отвечает за взаимодействие с данными. К данному слою относятся три основных типа объектов.


  • Поставщики данных — объекты, взаимодействующие с конкретным источником. Например, базой данных или сетью. В шаблоне проекта мы именуем их Service-объектами и они могут делится по подтипам в зависимости от источника: ApiService, DbService, CacheService и прочие. Каждый сервис может работать с моделями данных своего типа, для этого в этом слое создаются DTO-модели или Request/Response-модели.
  • Repository-объекты — объекты, которые изолируют работу с конкретными поставщиками данных от приложения и оркестрируют их взаимодействие внутри себя. Наружу (в Domain слой) Repository-объекты должны отдавать бизнес-модели объектов. Мы стараемся делать Repository-объекты максимально реактивными (наполняем Stream, предоставляемый этими объектами, событиями, уведомляющими всех своих подписчиков об изменениях данных в репозитории).
  • Вспомогательные объекты — объекты, которые используются внутри Repository/Service для выполнения их функций. Популярный пример — mapper-объекты, отвечающие за преобразование DTO-моделей в бизнес-модели.

Рисунок 8. Пример, структуры дирректории data слоя.
Рисунок 8. Пример, структуры дирректории data слоя


3. State Managment

В Flutter-среде есть ряд популярных State Managment решений: bloc, MVVM-решения (stacked, elementary), redux, mobx. Подробнее об этих и других решениях можно ознакомится в подборке на flutter.dev.

Для нашей команды в рамках длительной работы над нативными Android-проектами наиболее близким решением был MVI (отличный доклад от Сергея Рябова), возможно, поэтому мы остановились на BLoC c использованием расширения для Flutter — flutter_bloc. BLoC-подход характеризуют четкое разделение ответственности, предсказуемые преобразования Event в State,  обязательные состояния, реактивность. Отдельно удивила обширная документация BLoC. Рекомендуем ознакомиться перед его использованием.

Рисунок 9. Структура BLoC-архитектуры
Рисунок 9. Структура BLoC-архитектуры

В рамках концепции MVI помимо потока объектов State существовал поток объектов SingleResult (Effect/SingleLiveEvent). Их отличие в том, что такие объекты не влияют друг на друга, и при обработке на уровне UI они должны быть обработаны только один раз, соответсвенно, при переподписке на поток SingleResult подписчику не нужно знать о последнем полученном SingleResult. Нам показалось, что такой поток был бы полезен для BLoC, например, для операций навигации, показа Snackbar/Toast, управления диалогами и запуска анимаций.

Поэтому мы создали собственное расширение SrBloc:

abstract class SrBloc extends Bloc with SingleResultMixin {
  SrBloc(State state) : super(state);
}

В рамках SingleResultMixin SrBloc реализует два протокола:

/// Протокол для предоставления потока событий [SingleResult]
abstract class SingleResultProvider {
  Stream get singleResults;
}

/// Протокол для приема событий [SingleResult]
abstract class SingleResultEmmiter {
  void addSr(SingleResult sr);
}

В результате при реализации конкретного BLoC на уровне Generic определяется дополнительный поток объектов SingleResult, который может быть обработан:

class MainPageBloc extends SrBloc

@freezed
class MainPageSR with _$MainPageSR {
  const factory MainPageSR.showSnackbar({required String text}) = _ShowSnackbar;
}

При обработке Event внутри BLoC можно передавать в Widget-подписант SingleResult объекты при помощи функции addSr. Например, так будет выглядеть показ Snackbar об ошибке:

FutureOr _chekTime(MainPageEventCheckTime event, Emitter emit) async {
    final timeResult = await greatestTimeInteractor.getGreatestServerOrPhoneTime();

    if (timeResult.isRight) {
      emit(state.data.copyWith(timeText: timeResult.right.toString()));
    } else {
      addSr(MainPageSR.showSnackbar(text: LocaleKeys.time_unknown.tr()));
    }
  }

Далее SingleResult обрабатываются при помощи Page-объекта фичи:

class MainPage extends StatelessWidget {
    ...
    void _onSingleResult(BuildContext context, MainPageSR sr) {
    sr.when(
      showSnackbar: (text) => BaseSnackbar.show(context: context, text: text),
    );
  }
}

Для использования SrBloc мы расширили также и BlocBuilder нашей реализацией — SrBlocBuilder. Она позволяет управлять подпиской на singleResults:

typedef SingleResultListener = void Function(BuildContext context, SR singleResult);

/// Виджет-прослойка над bloc-builder для работы с SrBloc
class SrBlocBuilder, S, SR> extends StatelessWidget {
  final B? bloc;
  final SingleResultListener onSR;
  final BlocWidgetBuilder builder;
  final BlocBuilderCondition? buildWhen;

    ...

  @override
  Widget build(BuildContext context) {
    return StreamListener(
      stream: (bloc ?? context.read()).singleResults,
      onData: (data) => onSR(context, data),
      child: BlocBuilder(
        bloc: bloc,
        builder: builder,
        buildWhen: buildWhen,
      ),
    );
  }
}

Таким образом, использование SrBloc в Widget-объектах сводится к следующему виду:

class MainPage extends StatelessWidget {
    ...
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => GetIt.I.get()..add(const MainPageEvent.init()),
      child: SrBlocBuilder(
        onSR: _onSingleResult,
        builder: (_, blocState) {
          return Scaffold(
            body: SafeArea(
              child: blocState.map(
                empty: (state) => const _MainPageEmpty(),
                data: (state) => _MainPageContent(state: state),
              ),
            ),
          );
        },
      ),
    );
  }
    ...
}

Для достижения максимального разделения зон ответственностей рекомендуем не смешивать виджет, который интегрируется с BLoC, и виджет, который отвечает за пользовательский интерфейс:

class _MainPageContent extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final appTheme = AppTheme.of(context);
    final bloc = context.read();

    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            state.descriptionText,
            style: appTheme.textTheme.body1Medium,
          ),
          const SizedBox(height: 8),
          if (state.timeText.isNotEmpty) Text(state.timeText),
          ElevatedButton(
            onPressed: () {
              bloc.add(const MainPageEvent.checkTime());
            },
            child: Text(state.timeButtonText),
          ),
          ElevatedButton(
            onPressed: () {
              bloc.add(const MainPageEvent.unauthorize());
            },
            child: Text(state.logoutButtonText),
          ),
        ],
      ),
    );
  }
}


4. Классы-модели

Внимательный читатель может обратить внимание, что в шаблоне проекта мы используем реализацию классов-моделей данных, аналогичную Data-классам в других языках (например, в Kotlin). Так как в Dart нет такого из коробки, мы использовали библиотеку freezed, основанную на кодогенерации (используя build_runner).

/// DTO класс, возвращающийся от сервера, в ответ на запрос текущего времени
@freezed
class TimeResponse with _$TimeResponse {
  const factory TimeResponse({
    @JsonKey(name: 'currentDateTime') required DateTime currentDateTime,
    @JsonKey(name: 'serviceResponse') required Map? serviceResponse,
  }) = _TimeResponse;

  factory TimeResponse.fromJson(Map json) => _$TimeResponseFromJson(json);
}

Эта библиотека генерирует довольно много удобного и привычного функционала работы с классами-моделями, помимо самих data-классов (генерации конструкторов, toString, equals, copyWith методов) появляется возможность удобно работать с Sealed/Union классами, интегрироваться с json_serializable, а также создавать более сложные модели. Рекомендуем внимательнее ознакомиться с возможностями этого решения, оно действительно упрощает работу с кодом.

@freezed
class LoginSR with _$LoginSR {
  const factory LoginSR.success() = _Success;

  const factory LoginSR.showSnackbar({required String text}) = _ShowSnackbar;
}

При работе с freezed рекомендуем скрывать .freezed.dart, .g.dart файлы в вашей IDE. Например, для VsCode это можно сделать следующей настройкой:

{
    ...
    "files.exclude": {
        "**/*.freezed.dart": true,
        "**/*.g.dart": true
    }
}


Внедрение зависимостей

image-loader.svg

В качестве инструмента для работы с зависимостями мы используем связку GetIt и Injectable.

GetIt — сервис-локатор, который позволяет получить доступ ко всем зарегистрированным в нем объектам. GetIt крайне быстрый, не привязан к контексту и поддерживает все необходимые функции регистрации зависимостей (singleton, lazySingleton, fabric, async*), умеет работать со Scope-ами зависимостей и различными Environment.


  • Про Scopes

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


Injectable — расширение над GetIt, которое позволяет автоматизировать регистрацию объектов в GetIt с помощью различных аннотаций (@Injectable, Singleton и т д). В итоге генератор обрабатывает аннотации на стадии сборки проекта (используя build_runner) и генерирует код на основе представления зависимостей через регистрацию в GetIt:

...
// ignore_for_file: lines_longer_than_80_chars
/// initializes the registration of provided dependencies inside of [GetIt]
Future<_i1.GetIt> $initGetIt(_i1.GetIt get,
    {String? environment, _i2.EnvironmentFilter? environmentFilter}) async {
  final gh = _i2.GetItHelper(get, environment, environmentFilter);
  final infrastructureModule = _$InfrastructureModule();
  final dbModule = _$DbModule();
  final routerModule = _$RouterModule();
  final dioClientModule = _$DioClientModule();
  gh.singleton<_i3.AppThemeBloc>(_i3.AppThemeBloc());
  gh.singleton<_i4.Connectivity>(infrastructureModule.connectivity);
  gh.lazySingleton<_i5.DioLoggerWrapper>(
      () => infrastructureModule.dioLoggerWrapper(get<_i6.AppEnvironment>()));
  gh.singleton<_i7.KeyValueStore>(_i8.SharedPrefsKeyValueStore());
  gh.singleton<_i9.LinkProvider>(_i9.LinkProvider());
  gh.lazySingleton<_i10.Logger>(
      () => infrastructureModule.logger(get<_i6.AppEnvironment>()));
...

Injectable позволяет раскрывать весь функционал GetIt, но при этом колоссально сокращает время разработки и делает процесс комфортным.

image-loader.svg

В сердце приложения лежит его стиль и дизайн, реализация которого очень часто съедает существенное количество времени. Правильный подход к разработке UI помогает очень сильно сократить время вёрстки и уменьшить количество возвратов из стадии тестирования.

Первым этапом оптимизации нужно наладить общий язык с вашими UI/UX-дизайнерами. Поможет в этом общая система дизайн-токенов, пронизывающих все макеты и все приложения, из которой далее будет вытекать ваша библиотека графических компонентов.

В вашу договоренность будут входить следующие артефакты:


  • палитра приложения;
  • кодировка цветовых токенов — отображение дизайн-токенов цветов на элементы палитры в заданной теме (светлой/темной/контрастной);
  • типографика — связь дизайн-токенов текста с конкретными настройками шрифта (размер, толщина, межбуквенный интервал и т.д.).

Далее эту договоренность вы отразите в коде, в Flutter уже существует обвязка для управления темами ThemeData + ColorScheme + TextTheme. Однако, дизайн-токены Flutter могут отличаться от дизайн видения вашей компании. Для реализации договорённости мы используем аналогичное собственное решение:

/// Абстракция для поставки базовых цветовых токенов в приложении
abstract class AppColorTheme {
  //============================== Main Colors ==============================
  Brightness get brightness;

  Color get accent;

  Color get accentVariant;

  Color get onAccent;

  Color get secondaryAccent;

  Color get secondaryAccentVariant;

  Color get onSecondary;

  //============================== Typography Colors ==============================
  Color get textPrimary;

  Color get textSecondary;
    ...
}

/// Цветовая палитра приложения
class AppPallete {
  static const Color blackA100 = Color(0xFF000000);
  static const Color blackA85 = Color(0xD9000000);
  ...
  static const Color red500 = Color(0xFFF44336);
  static const Color green500 = Color(0xFF4CAF50);
  static const Color yellow500 = Color(0xFFFFEB3B);
}

/// Реализация светлой цветовой темы, связывающей цветовые псевдонимы с установленной палитрой
class LightColorTheme implements AppColorTheme {
  @override
  Brightness get brightness => Brightness.light;

  //============================== Customization color tokens ==============================
  @override
  Color get accent => AppPallete.lightBlu500;
  @override
  Color get accentVariant => AppPallete.lightBlue900;
  @override
  Color get onAccent => AppPallete.white;

  @override
  Color get secondaryAccent => accent;
  @override
  Color get secondaryAccentVariant => accentVariant;
}

В результате сформированная тема аккумулируются в единое состояние, которое поставляется через InheritedWidget AppThemeProvider:

/// Состояние отображающее текущее состояние темы в приложении
@freezed
class AppTheme with _$AppTheme {
  /// [colorTheme] - цветовая тема в приложении
  /// [textTheme] - типографическая тема в приложении
  const factory AppTheme({
    required AppColorTheme colorTheme,
    required AppTextTheme textTheme,
  }) = _AppTheme;

  static AppTheme of(BuildContext context) => AppThemeProvider.of(context).theme;
}

Это состояние управляется singleton BLoC-объектом:

/// Логический компонент, отвечающий за переключение тем в приложении
///
/// Является singleton в связи с тем, что переключение темы происходит через отправку событий в текущий инстанс,
/// после чего реактивно актаульная тема будет доставлена во все компоненты приложения
@singleton
class AppThemeBloc extends Bloc {
  AppThemeBloc()
      : super(AppTheme(
          colorTheme: const LightColorTheme(),
          textTheme: BaseTextTheme(),
        )) {
    on(_setDarkTheme);
    on(_setLightTheme);
  }
    ...
}

Далее тема поставляется в UI:

Widget build(BuildContext context) {
    final appTheme = AppTheme.of(context);
    final bloc = context.read();

    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            state.descriptionText,
            style: appTheme.textTheme.body1Medium,
          ),
          const SizedBox(height: 8),
          if (state.timeText.isNotEmpty) Text(state.timeText),
          ElevatedButton(
            onPressed: () {
              bloc.add(const MainPageEvent.checkTime());
            },
            child: Text(state.timeButtonText),
          ),
          ElevatedButton(
            onPressed: () {
              bloc.add(const MainPageEvent.unauthorize());
            },
            child: Text(state.logoutButtonText),
          ),
        ],
      ),
    );
  }

k6_gl3mlfi72f91vamq6idymoqq.jpeg


1. HTTP-клиент

Практически любая фича не обходится без работы с сетью, для нашей компании наиболее актуален REST. Для работы с REST мы выбрали HTTP-клиент Dio. Он полностью покрывает все наши потребности: перехват запросов, работа с cookies и headers, работа с proxy, поддержка различных content-type и обработка ошибок.

Для каждого домена мы создаем свой DIO-клиент и поставляем его при помощи аннотации Named ключей GetIt:

/// Модуль поставляющий зависимости, связанные с [Dio]
@module
abstract class DioClientModule {
  @Named(InjectableNames.timeHttpClient)
  @preResolve
  @singleton
  Future makeDioClient(DioClientCreator dioClientCreator) => dioClientCreator.makeTimeDioClient();

  @lazySingleton
  DioErrorHandler makeDioErrorHandler(Logger logger) => DioErrorHandlerImpl(
        connectivity: Connectivity(),
        logger: logger,
        parseJsonApiError: (json) async {
          //метод, парсящий ошибку от сервера
          return (json != null) ? DefaultApiError.fromJson(json) : null;
        },
      );
}

Клиент настраивается при помощи вспомогательного класса DioClientCreator:

@Singleton(as: DioClientCreator)
class DioClientCreatorImpl implements DioClientCreator {
  static const defaultConnectTimeout = 5000;
  static const defaultReceiveTimeout = 25000;

  @protected
  final LinkProvider linkProvider;
  @protected
  final AppEnvironment appEnvironment;
  @protected
  final DioLoggerWrapper logger;
    ...

  @override
  Future makeTimeDioClient() => _baseDio(linkProvider.timeHost);

  /// Метод подставляющий базовую настроенную версию Dio
  Future _baseDio(final String url) async {
    final startDio = Dio();

    startDio.options.baseUrl = url;
    startDio.options.connectTimeout = defaultConnectTimeout;
    startDio.options.receiveTimeout = defaultReceiveTimeout;

    if (appEnvironment.enableDioLogs) {
      startDio.interceptors.add(
        PrettyDioLogger(
          requestBody: true,
          logPrint: logger.logPrint,
        ),
      );
    }

    startDio.transformer = FlutterTransformer();
    return startDio;
  }
}

Далее http-клиент поставляется в ApiSerivce-объект, изолирующий работу с http-клиентом и скрывающий обработку ошибок:

@Singleton(as: TimeApiService)
class TimeApiServiceImpl implements TimeApiService {
  static const _nowTimeApi = '/api/json/utc/now';

  final Dio _client;
  final DioErrorHandler _dioErrorHandler;

  TimeApiServiceImpl(
    @Named(InjectableNames.timeHttpClient) this._client,
    this._dioErrorHandler,
  );

  @override
  Future, TimeResponse>> getTime() async {
    final result = await _dioErrorHandler.processRequest(() => _client.get>(_nowTimeApi));
    if (result.isLeft) return Either.left(result.left);
    return Either.right(TimeResponse.fromJson(result.right.data!));
  }
}


2. Обработка ошибок

Для обработок сетевых ошибок мы используем монаду Either, которая объединяет два возможных решения: Left или Right. Обычно Left используется в качестве решения с ошибкой, а Right в качестве успешного решения. Реализация Either выглядит следующим образом:

/// Сущность для описания вычислений, которые могут идти двумя путями [L] или [R]
/// Классически используется для обработки ошибок, обычная левая часть выступает в качестве ошибки, а правая в качестве результата
@freezed
class Either with _$Either {
  bool get isLeft => this is _EitherLeft;

  bool get isRight => this is _EitherRight;

  /// Представляет левую часть класса [Either], которая по соглашению является "Ошибкой"
  L get left => (this as _EitherLeft).left;

  /// Представляет правую часть класса [Either], которая по соглашению является "Успехом"
  R get right => (this as _EitherRight).right;

  const Either._();

  const factory Either.left(L left) = _EitherLeft;

  const factory Either.right(R right) = _EitherRight;
}

Для представления общего вида сетевых ошибок мы выделили модель CommonResponseError, Custom представляет из себя Generic, определяющий специфическую ошибку, обрабатываемую из json-объекта в теле http-ошибки:

class CommonResponseError with _$CommonResponseError {
    ...

  /// Во время запроса отсутствовал интернет
  const factory CommonResponseError.noNetwork() = _NoNetwork;

  /// Сервер требует более высокий уровень доступа к методу
  const factory CommonResponseError.unAuthorized() = _UnAuthorized;

  /// Сервер вернул ошибку, показывающую, что мы превысили количество запросов
  const factory CommonResponseError.tooManyRequests() = _TooManyRequests;

  /// Обработана специфичная ошибка [CustomError]
  const factory CommonResponseError.customError(Custom customError) = _CustomError;

  /// Неизвестная ошибка
  const factory CommonResponseError.undefinedError(Object? errorObject) = _UndefinedError;
}

Центральным элементом обработки ошибок является сущность DioErrorHandler:

/// Протокол для обработки запросов [MakeRequest] от [Dio] в результате возвращает [Either]
/// Левая часть отвечает за ошибки вида [CommonResponseError]
/// Правая часть возвращает результат запроса Dio [Response]
abstract class DioErrorHandler {
  Future, T>> processRequest(MakeRequest makeRequest);
}

Базовая реализация включает в себя retry-политику для повторения запросов, настройку правила преобразования json в CustomError и определения типов ошибок. Основным логическим блоком является выбор ветки ошибки:

Future> _processDioError(DioError e) async {
    final responseData = e.response?.data;
    final statusCode = e.response?.statusCode;

    if (e.type == DioErrorType.connectTimeout ||
        e.type == DioErrorType.sendTimeout ||
        statusCode == _HttpStatus.networkConnectTimeoutError) {
      return const CommonResponseError.noNetwork();
    }

    if (statusCode == _HttpStatus.unauthorized) {
      return const CommonResponseError.unAuthorized();
    }

    if (statusCode == _HttpStatus.tooManyRequests) {
      return const CommonResponseError.tooManyRequests();
    }

    if (undefinedErrorCodes.contains(statusCode)) {
      return CommonResponseError.undefinedError(e);
    }

    Object? errorJson;
    if (responseData is String) {
      //В случае если ожидался Response dio не будет парсить возвращенную json-ошибку
      //и нам это нужно сделать вручную
      try {
        errorJson = jsonDecode(responseData);
      } on FormatException {
        //Возможно был нарушен контракт/с сервером случилась беда, тогда мы вернем [CommonResponseError.undefinedError]
        logger.w('Получили ответ: \n "$responseData" \n что не является JSON');
      }
    } else if (responseData is Map) {
      //Если запрос ожидал JSON, то и json-ответ ошибки будет приведен к нужному виду
      errorJson = responseData;
    }

    if (errorJson is Map) {
      try {
        final apiError = await parseJsonApiError(errorJson);
        if (apiError != null) {
          return CommonResponseError.customError(apiError);
        }
        // ignore: avoid_catching_errors
      } on TypeError catch (e) {
        logger.w('Ответ c ошибкой от сервера \n $responseData \n не соответсвует контракту ApiError', e);
      }
    }

    return CommonResponseError.undefinedError(e);
  }

Таким образом, при использовании нашего решения обработки ошибок в большинстве проектов достаточно будет реализовать базовую сущность:

@freezed
class DefaultApiError with _$DefaultApiError {
  const factory DefaultApiError({
    required String name,
    required String code,
  }) = _DefaultApiError;

  factory DefaultApiError.fromJson(Map json) => _$DefaultApiErrorFromJson(json);
}

@lazySingleton
  DioErrorHandler makeDioErrorHandler(Logger logger) => DioErrorHandlerImpl(
        connectivity: Connectivity(),
        logger: logger,
        parseJsonApiError: (json) async {
          //метод, парсящий ошибку от сервера
          return (json != null) ? DefaultApiError.fromJson(json) : null;
        },
      );

image-loader.svg


1. Ядро навигации

Fltuter на текущий момент имеет два API для навигации: Navigator и Router.

Router — современное решение (часто можно встретить название Navigator 2.0) и более эффективное для крупных приложений. Также он имеет больше возможностей по работе с Web-URI. Чтобы подробнее разобраться с API-навигацией и существующими решениями, которые упрощают работу с навигацией, рекомендуем почитать: Flutter: как мы выбирали навигацию для мобильного приложения?

Если говорить про Router, то его основным минусом является сложность API, которая ведёт к желанию создать собственную прослойку, упрощающую работу с ним. Мы пошли по этому пути и создали своего «монстра навигации» со своими стратегиями навигации и клиентами. В итоге он плотно закрепился в нашем основном проекте, но на данный момент решили отказаться от него и использовать популярный пакет навигации с pub.dev. По итогу остановились на auto_route.

auto_route — пакет навигации Flutter. Он позволяет передавать строго типизированные аргументы, легко создавать deepLinks и использует генерацию кода для упрощения настройки маршрутов. При этом, это решение требует довольно мало кода, отличается простотой и лаконичностью.

Инициализацию роутинга в примере можно посмотреть в app_router.dart. Именно в нем мы регистрируем все наши роут-объекты, тут же прописываем их параметры, устанавливаем им роут-наблюдателей.

/// Роутер приложения
@AdaptiveAutoRouter(
  replaceInRouteName: 'Page,Route',
  routes: [
    AutoRoute(page: SplashPage),
    AutoRoute(
      path: '/main',
      page: MainPage,
      initial: true,
      guards: [AuthGuard, InitGuard],
    ),
    AutoRoute(
      path: '/login',
      page: LoginPage,
      guards: [InitGuard],
    ),
    AutoRoute(
      path: '*',
      page: NotFoundPage,
      guards: [InitGuard],
    ),
  ],
)
class $AppRouter {}

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

/// Модуль, формирующий сущности для роутинга
@module
abstract class RouterModule {
  @singleton
  AppRouter appRouter(
    AuthGuard authGuard,
    InitGuard initGuard,
  ) {
    return AppRouter(
      authGuard: authGuard,
      initGuard: initGuard,
    );
  }

  @singleton
  AuthGuard authGuard(UserRepository userRepository) => AuthGuard(isAuthorized: userRepository.isAuthorized);

  @singleton
  InitGuard initGuard(StartupRepository startupRepository) => InitGuard(isInited: startupRepository.isInited);

  @injectable
  RouterLoggingObserver routerLoggingObserver(
    Logger logger,
    AppRouter appRouter,
  ) {
    return RouterLoggingObserver(
      logger: logger,
      appRouter: appRouter,
    );
  }
}

И передать его в наш MaterialApp:

final appRouter = GetIt.I.get();

    return MaterialApp.router(
      ...
      routeInformationParser: appRouter.defaultRouteParser(),
      routerDelegate: AutoRouterDelegate(
        appRouter,
        navigatorObservers: () => [
          GetIt.I.get(),
        ],
      ),
    );

RouterLoggingObserver — вспомогательный объект, осуществляющий логирование роутинга, реализующий AutoRouterObserver.

Далее мы сможем получить объект AutoRouter и осуществить навигацию:

AutoRouter.of(context).replace(const MainRoute());
AutoRouter.of(context).push(const LoginRoute());

И система навигации сама направит нас на необходимую страницу, если мы зарегистрировали необходимый роутинг в AppRouter. 


2. Защита Route

Вам наверняка доводилось делать проверку авторизации при переходе на экран или не пускать на какой-либо экран без выполнения определенного условия. В нашем примере это легко делается с помощью стражей навигации (routing guards). Это некие сущности, которые вызываются перед тем, как состоится навигация, за которой они «присматривают».  В этих стражах мы можем проверить различные условия, влияющие на доступность навигации. Например, именно через AuthGuard мы добиваемся того, что переместиться на главный экран может только авторизованный пользователь. В случае, если пользователь не авторизован, мы насильно навигируем на экран логина.

typedef IsAuthorized = bool Function();

class AuthGuard extends AutoRedirectGuard {
  @protected
  final IsAuthorized isAuthorized;

     ...

  @override
  void onNavigation(NavigationResolver resolver, StackRouter router) {
    if (!isAuthorized()) {
      router.push(LoginRoute(onAuthSuccess: () => resolver.next()));
    } else {
      resolver.next();
    }
  }
}

Ещё благодаря тому, что AuthGuard реализует AutoRedirectGuard, мы можем запрашивать централизованный пересчёт роутов при смене состояния аутентификации:

class StartupInteractorImpl implements StartupInteractor {
  ...

  void _listenGlobalBroadcasts() {
    _compositeSubscription.add(
      userRepository.authStream().listen((_) => authGuard.reevaluate()),
    );
  }
}

Приведенные примеры лишь небольшая часть возможностей пакета auto_route. В нем есть также вложенные навигации, навигации по табам с сохранением состояния, возврат результатов роутинга и многое другое. Рекомендуем ознакомиться с документацией.

azzsonrotrybpf4s8aqmz3bqxzy.jpeg

Наш проект предполагал работу в оффлайн-режиме и не мог существовать без хранения данных на устройстве.

На данный момент можно выделить пять наиболее популярных решений для хранения каких-либо данных: hive, ObjectBox, sqflite, Moor (на данный момент переименован в drift) и shared_preferences.

Наши требования при выборе решения:


  • хранение большого количества объектов измеряемого в тысячах;
  • сложная логика выбора обработки объектов (необходима поддержка логики операций выборки объектов: where, join, limit, offset);
  • параллельная работа с БД из разных изолятов (из foreground приложения и background jobs, которые не могут синхронизироваться друг с другом).


1. Хранилище неструктурированных данных

Сначала мы выбрали hive. Его плюсы: не имеет платформенных зависимостей, крайне быстрый и поддерживает шифрование. Из минусов — не поддерживает query, что усложнило бы написание нашей бизнес-логики. Однако, Hive все еще подходил в качестве KeyValue-хранилища неструктурированных данных (токены, настройки и прочие метаданные), чем мы и воспользовались. Спустя несколько месяцев мы обнаружили, что hive не может работать в параллельном режиме (одновременно записывая данные из background task/service и из foreground приложения). Это разрушало важный для нас бизнес-процесс.

В итоге, в качестве KeyValue-хранилища в нашем проекте стали выступать shared_preferences. Переход с hive на shared_prefs оказался безболезненным, плюс, появились наши мини-решения «сокрытия»‎ реализации key-value хранилища:

/// Протокол для типизированное хранилища данных вида ключ-значение, работающее с [TypeStoreKey]
abstract class KeyValueStore {
  /// Метод проверяющий, что по ключу [typedStoreKey], хранится какое-либо значение
  Future contains(TypeStoreKey typedStoreKey);

  /// Метод для инициализации хранилища
  Future init();

  /// Метод для чтения значения по ключу [typedStoreKey], в случае если значение отсутсвует возращается null
  /// Если значение находится в хранилище, его тип приводится к [T]
  Future read(TypeStoreKey typedStoreKey);

  /// Метод для записи значения по ключу [typedStoreKey], при необходимости удалить значение необходимо передать null
  Future write(TypeStoreKey typedStoreKey, T? value);
}

/// Обьект типизированный ключ используемый в key-value хранилищах для более удобной работы с ними
/// [T] - тип хранимого значения
/// [key] - строковый ключ
///
/// Хранилище может ограничивать типизацию [T], обычно оно ограничивается стандартными типами: [int], [double], [String], [bool].
class TypeStoreKey {
  final type = T;

  final String key;
  TypeStoreKey(
    this.key,
  );

  @override
  String toString() => 'TypeStoreKey(key: $key)';
}

Соответственно, для использования shared_prefs была разработана реализация:

/// Базовая реализация над [KeyValueStore] для [SharedPreferences]
///
/// Перед использованием необходимо вызывать [init]
@Singleton(as: KeyValueStore)
class SharedPrefsKeyValueStore implements KeyValueStore {
  late SharedPreferences _sharedPreferences;

  @override
  Future init() async {
    _sharedPreferences = await SharedPreferences.getInstance();
  }

  @override
  Future read(TypeStoreKey typedStoreKey) async => _sharedPreferences.get(typedStoreKey.key) as T?;

  @override
  Future contains(TypeStoreKey typedStoreKey) async => _sharedPreferences.containsKey(typedStoreKey.key);

  @override
  Future write(TypeStoreKey typedStoreKey, T? value) async {
    if (value == null) {
      await _sharedPreferences.remove(typedStoreKey.key);

      return;
    }
    switch (T) {
      case int:
        await _sharedPreferences.setInt(typedStoreKey.key, value as int);
        break;
      case String:
        await _sharedPreferences.setString(typedStoreKey.key, value as String);
        break;
      case double:
        await _sharedPreferences.setDouble(typedStoreKey.key, value as double);
        break;
      case bool:
        await _sharedPreferences.setBool(typedStoreKey.key, value as bool);
        break;
      case List:
        await _sharedPreferences.setStringList(typedStoreKey.key, value as List);
        break;
    }
  }
}

Далее в коде вам достаточно определить свои ключи и вызывать методы KeyValueStore. Вот пример использования ключа, хранящего версию:

class StoreKeys {
  static final prefsVersionKey = TypeStoreKey('prefs_version_key');
}

Future _readCurrentVersion() => keyValueStore.read(prefsVersionKey);

Future _writeNewVersion(int newVersion) => keyValueStore.write(prefsVersionKey, newVersion);

Для KeyValue-хранилищ обычно не затрагивается тема миграций, но нам несколько раз понадобилась специфичная логика миграции данных, из чего мы разработали общее решение на базе KeyValueStore — KeyValueStoreMigrator. Логики миграций при поднятии версии на версию schemeVersion изолируются в отдельных классах, реализующих протокол:

/// Протокол выполнения логики миграции [KeyValueStore] при переходе на версию [schemeVersion]
abstract class KeyValueStoreMigrationLogic {
  int get schemeVersion;

  Future migrate();
}

Мигратору в конструкторе поставляется набор миграций Set migrations на основании которых он выполняет две основные функции:

/// Метод создания key-value store
  Future onCreate(int createdVersion) async {
    await onCreateFunc?.call(createdVersion);
    await observer?.onCreate(createdVersion);
  }

  /// Метод миграции с версии [fromVersion] на [toVersion]
  /// Метод последовательно выполняет миграцию через набор [_migrations]
  Future onUpgrade(int fromVersion, int toVersion) async {
    var prefsVersion = fromVersion;
    while (prefsVersion < toVersion) {
      prefsVersion++;
      final migartionLogic = migrations.firstWhereOrNull((migrator) => migrator.schemeVersion == prefsVersion);
      if (migartionLogic == null) {
        await observer?.onMissedMigration(prefsVersion);
        continue;
      } else {
        await migartionLogic.migrate();
      }
    }

    await observer?.onUpgrade(fromVersion, toVersion);
  }

Логирование миграций поддерживается через протокол MigrationObserver:

/// Обозреватель событий-миграцийй, используется в реализациях миграторов для логирования миграций
abstract class MigrationObserver {
  Future onCreate(int
    
            

© Habrahabr.ru