Секреты запуска Flutter в production. Создаем IT-верфи
Привет! Мы Даниил Левицкий и Дмитрий Дронов, мобильные разработчики компании ATI.SU — крупнейшей в России и СНГ Бирже грузоперевозок. Хотим поделиться с вами своим видением разработки приложений на Flutter.
У нас несколько команд мобильной разработки, и раньше мы писали только нативные приложения. Но мир не стоит на месте, и мы решили попробовать кроссплатформенную разработку. В качестве технологии мы выбрали Flutter. У нас, как у разработчиков, был небольшой опыт в этой технологии. Но при разработке крупного решения для бизнеса с прицелом на длительную поддержку стали появляться сложности, требующие выработки решений и стандартизации. Решения мы скомпоновали в шаблон-пример, который будет использоваться в дальнейшем для всех новых Flutter-проектов в рамках нашей компании:
FLUTTER-ШАБЛОН-ПРИМЕР
Наши решения не претендуют на непоколебимую истину, и мы всегда открыты к конструктивной дискуссии. Но они отражают наш накопленный опыт и набитые шишки. В этой статье мы постарались сжать информацию о каких-то разделах, чтобы рассказать обо всех подходах сразу. Но, если что-либо вызвало вопросы, то посмотрите наш шаблон на GitHub или пишите в комментариях. Надеемся, что кому-нибудь из Flutter-сообщества наш опыт будет полезен.
1. Структура проекта
Важно сразу предусмотреть расширяемую и наглядно понятную структуру проекта. Возможно, ваш проект будет пополняться большим количеством механик, а команда — разработчиками. Непротиворечивая структура облегчит понимание и дальнейшую легкость поддержки.
В нашем случае мы пришли к структуре вида:
Рисунок 1. Структура дирректорий проекта
app — содержит в себе сущности, относящиеся непосредственно к Application Flutter-приложения: к теме приложения, навигации приложения, окружению запуска и локализации.
Рисунок 2. Структура дирректории app
arch — различные изолированные от проекта библиотеки/утилиты, которые можно было бы выделить во внешние pub-пакеты, но они пока не готовы к такой публикации. Например: расширение BLoC, функциональные модели.
Рисунок 3. Структура дирректории arch
const — общие константы приложения.
Рисунок 4. Структура дирректории const
core — ядро приложения, которое относится ко всем фичам, без него они не смогут функционировать. Например: общие модели данных, объекты для работы с сетью или хранилищем, общие виджеты.
Рисунок 5. Структура дирректории core
features — реализация конкретных фич, которые входят в ваше приложение. Фичи должны быть максимально изолированными друг от друга и содержать все бизнес-слои внутри себя. Благодаря такой структуре, в будущем каждую фичу можно будет выделить в два отдельных package: api package — содержащий интерфейсы фичи; impl package — содержащий реализацию. Таким образом все фичи будут зависеть только от core-api packge и при необходимости других feature-api package.
Рисунок 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 слоя
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 слоя
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-архитектуры
В рамках концепции 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
}
}
Внедрение зависимостей
В качестве инструмента для работы с зависимостями мы используем связку 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, но при этом колоссально сокращает время разработки и делает процесс комфортным.
В сердце приложения лежит его стиль и дизайн, реализация которого очень часто съедает существенное количество времени. Правильный подход к разработке 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),
),
],
),
);
}
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
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;
},
);
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. В нем есть также вложенные навигации, навигации по табам с сохранением состояния, возврат результатов роутинга и многое другое. Рекомендуем ознакомиться с документацией.
Наш проект предполагал работу в оффлайн-режиме и не мог существовать без хранения данных на устройстве.
На данный момент можно выделить пять наиболее популярных решений для хранения каких-либо данных: 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