Про нас
Привет! Мы Даниил Левицкий и Дмитрий Дронов, мобильные разработчики компании 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. Бизнес-слои
Договоритесь с командой в самом начале: какой именно архитектурный подход вы будете использовать. Желательно вживую «пощупайте» все обсуждаемые решения.Качества хорошего архитектурного решения:
- низкая связность кода (позволяет проще вносить изменения);
- разделение зон ответственности кода;
- логическая однозначность и низкий порог входа;
- тестируемость (можно проверить корректность работы отдельных частей).
Принято выделять следующие слои: 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<Event, State, SR> extends Bloc<Event, State> with SingleResultMixin<Event, State, SR> {
SrBloc(State state) : super(state);
}
В рамках SingleResultMixin SrBloc реализует два протокола:
/// Протокол для предоставления потока событий [SingleResult]
abstract class SingleResultProvider<SingleResult> {
Stream<SingleResult> get singleResults;
}
/// Протокол для приема событий [SingleResult]
abstract class SingleResultEmmiter<SingleResult> {
void addSr(SingleResult sr);
}
В результате при реализации конкретного BLoC на уровне Generic определяется дополнительный поток объектов SingleResult, который может быть обработан:
class MainPageBloc extends SrBloc<MainPageEvent, MainPageState, MainPageSR>
@freezed
class MainPageSR with _$MainPageSR {
const factory MainPageSR.showSnackbar({required String text}) = _ShowSnackbar;
}
При обработке Event внутри BLoC можно передавать в Widget-подписант SingleResult объекты при помощи функции addSr. Например, так будет выглядеть показ Snackbar об ошибке:
FutureOr<void> _chekTime(MainPageEventCheckTime event, Emitter<MainPageState> 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<SR> = void Function(BuildContext context, SR singleResult);
/// Виджет-прослойка над bloc-builder для работы с SrBloc
class SrBlocBuilder<B extends SrBloc<Object?, S, SR>, S, SR> extends StatelessWidget {
final B? bloc;
final SingleResultListener<SR> onSR;
final BlocWidgetBuilder<S> builder;
final BlocBuilderCondition<S>? buildWhen;
...
@override
Widget build(BuildContext context) {
return StreamListener<SR>(
stream: (bloc ?? context.read<B>()).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<MainPageBloc>(
create: (_) => GetIt.I.get()..add(const MainPageEvent.init()),
child: SrBlocBuilder<MainPageBloc, MainPageState, MainPageSR>(
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<MainPageBloc>();
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<String, dynamic>? serviceResponse,
}) = _TimeResponse;
factory TimeResponse.fromJson(Map<String, dynamic> 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. Возможно, это задел на будущую статью.
...
// 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-дизайнерами. Поможет в этом общая система дизайн-токенов, пронизывающих все макеты и все приложения, из которой далее будет вытекать ваша библиотека графических компонентов.
В вашу договоренность будут входить следующие артефакты:
- палитра приложения;
- кодировка цветовых токенов — отображение дизайн-токенов цветов на элементы палитры в заданной теме (светлой/темной/контрастной);
- типографика — связь дизайн-токенов текста с конкретными настройками шрифта (размер, толщина, межбуквенный интервал и т.д.).
/// Абстракция для поставки базовых цветовых токенов в приложении
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<AppThemeEvent, AppTheme> {
AppThemeBloc()
: super(AppTheme(
colorTheme: const LightColorTheme(),
textTheme: BaseTextTheme(),
)) {
on<AppThemeEventSetDarkTheme>(_setDarkTheme);
on<AppThemeEventSetLightTheme>(_setLightTheme);
}
...
}
Далее тема поставляется в UI:
Widget build(BuildContext context) {
final appTheme = AppTheme.of(context);
final bloc = context.read<MainPageBloc>();
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<Dio> makeDioClient(DioClientCreator dioClientCreator) => dioClientCreator.makeTimeDioClient();
@lazySingleton
DioErrorHandler<DefaultApiError> makeDioErrorHandler(Logger logger) => DioErrorHandlerImpl<DefaultApiError>(
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<Dio> makeTimeDioClient() => _baseDio(linkProvider.timeHost);
/// Метод подставляющий базовую настроенную версию Dio
Future<Dio> _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<DefaultApiError> _dioErrorHandler;
TimeApiServiceImpl(
@Named(InjectableNames.timeHttpClient) this._client,
this._dioErrorHandler,
);
@override
Future<Either<CommonResponseError<DefaultApiError>, TimeResponse>> getTime() async {
final result = await _dioErrorHandler.processRequest(() => _client.get<Map<String, dynamic>>(_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<L, R> with _$Either<L, R> {
bool get isLeft => this is _EitherLeft<L, R>;
bool get isRight => this is _EitherRight<L, R>;
/// Представляет левую часть класса [Either], которая по соглашению является "Ошибкой"
L get left => (this as _EitherLeft<L, R>).left;
/// Представляет правую часть класса [Either], которая по соглашению является "Успехом"
R get right => (this as _EitherRight<L, R>).right;
const Either._();
const factory Either.left(L left) = _EitherLeft;
const factory Either.right(R right) = _EitherRight;
}
Для представления общего вида сетевых ошибок мы выделили модель CommonResponseError, Custom представляет из себя Generic, определяющий специфическую ошибку, обрабатываемую из json-объекта в теле http-ошибки:
class CommonResponseError<Custom> 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<DE> {
Future<Either<CommonResponseError<DE>, T>> processRequest<T>(MakeRequest<T> makeRequest);
}
Базовая реализация включает в себя retry-политику для повторения запросов, настройку правила преобразования json в CustomError и определения типов ошибок. Основным логическим блоком является выбор ветки ошибки:
Future<CommonResponseError<DE>> _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<String> dio не будет парсить возвращенную json-ошибку
//и нам это нужно сделать вручную
try {
errorJson = jsonDecode(responseData);
} on FormatException {
//Возможно был нарушен контракт/с сервером случилась беда, тогда мы вернем [CommonResponseError.undefinedError]
logger.w('Получили ответ: \n "$responseData" \n что не является JSON');
}
} else if (responseData is Map<String, dynamic>) {
//Если запрос ожидал JSON, то и json-ответ ошибки будет приведен к нужному виду
errorJson = responseData;
}
if (errorJson is Map<String, dynamic>) {
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<String, dynamic> json) => _$DefaultApiErrorFromJson(json);
}
@lazySingleton
DioErrorHandler<DefaultApiError> makeDioErrorHandler(Logger logger) => DioErrorHandlerImpl<DefaultApiError>(
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>[
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<AppRouter>();
return MaterialApp.router(
...
routeInformationParser: appRouter.defaultRouteParser(),
routerDelegate: AutoRouterDelegate(
appRouter,
navigatorObservers: () => [
GetIt.I.get<RouterLoggingObserver>(),
],
),
);
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<bool> contains(TypeStoreKey typedStoreKey);
/// Метод для инициализации хранилища
Future<void> init();
/// Метод для чтения значения по ключу [typedStoreKey], в случае если значение отсутсвует возращается null
/// Если значение находится в хранилище, его тип приводится к [T]
Future<T?> read<T>(TypeStoreKey<T> typedStoreKey);
/// Метод для записи значения по ключу [typedStoreKey], при необходимости удалить значение необходимо передать null
Future<void> write<T>(TypeStoreKey<T> typedStoreKey, T? value);
}
/// Обьект типизированный ключ используемый в key-value хранилищах для более удобной работы с ними
/// [T] - тип хранимого значения
/// [key] - строковый ключ
///
/// Хранилище может ограничивать типизацию [T], обычно оно ограничивается стандартными типами: [int], [double], [String], [bool].
class TypeStoreKey<T> {
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<void> init() async {
_sharedPreferences = await SharedPreferences.getInstance();
}
@override
Future<T?> read<T>(TypeStoreKey<T> typedStoreKey) async => _sharedPreferences.get(typedStoreKey.key) as T?;
@override
Future<bool> contains(TypeStoreKey typedStoreKey) async => _sharedPreferences.containsKey(typedStoreKey.key);
@override
Future<void> write<T>(TypeStoreKey<T> 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<String>);
break;
}
}
}
Далее в коде вам достаточно определить свои ключи и вызывать методы KeyValueStore. Вот пример использования ключа, хранящего версию:
class StoreKeys {
static final prefsVersionKey = TypeStoreKey<int>('prefs_version_key');
}
Future<int?> _readCurrentVersion() => keyValueStore.read(prefsVersionKey);
Future<void> _writeNewVersion(int newVersion) => keyValueStore.write(prefsVersionKey, newVersion);
Для KeyValue-хранилищ обычно не затрагивается тема миграций, но нам несколько раз понадобилась специфичная логика миграции данных, из чего мы разработали общее решение на базе KeyValueStore — KeyValueStoreMigrator. Логики миграций при поднятии версии на версию schemeVersion изолируются в отдельных классах, реализующих протокол:
/// Протокол выполнения логики миграции [KeyValueStore] при переходе на версию [schemeVersion]
abstract class KeyValueStoreMigrationLogic {
int get schemeVersion;
Future<void> migrate();
}
Мигратору в конструкторе поставляется набор миграций Set migrations на основании которых он выполняет две основные функции:
/// Метод создания key-value store
Future<void> onCreate(int createdVersion) async {
await onCreateFunc?.call(createdVersion);
await observer?.onCreate(createdVersion);
}
/// Метод миграции с версии [fromVersion] на [toVersion]
/// Метод последовательно выполняет миграцию через набор [_migrations]
Future<void> 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<void> onCreate(int createdVersion);
Future<void> onMissedMigration(int version);
Future<void> onUpgrade(int fromVersion, int toVersion);
}
2. Основное хранилище данных
В качестве основного хранилища данных мы использовали drift. Он нам подходил. Drift построен на привычной sqlite, работает быстро и надёжно. Drift использует кодогенерацию через build_runner, за счёт чего скорость написания кода возрастает. Его можно использовать в модульной архитектуре при помощи генерации Dao-сущностей. Drift позволяет работать в параллельном режиме при помощи использования команды RAGMA journal_mode=WAL в момент настройки БД- Почему не ObjectBox?
На момент старта проекта ObjectBox еще не вышел в релиз, поэтому мы не рассматривали его, но возможно на текущий момент решение могло изменится (в нативных Android приложениях мы переходим на ObjectBox), это исследование мы проведем в будущем и если ObjectBox покажет себя лучше чем выбранное нами решение, то мы дополнил шаблон.
class AppDatabase extends _$AppDatabase {
@protected
final DriftMigrator<AppDatabase> migrator;
@override
MigrationStrategy get migration => migrator.delegateStrategy(this);
...
}
/// Протокол над реализацией логики миграции Drift
abstract class DriftMigrationLogic<Db> {
/// Версия на которую мы мигрируем
int get schemeVersion;
/// Метод миграции Moor на версию [schemeVersion]
Future<void> migrate(Db database, Migrator m);
}
/// Сущность производяющая миграции Drift
class DriftMigrator<Db> {
/// Набор миграций Drift
@protected
final Set<DriftMigrationLogic<Db>> migrationLogics;
...
/// Листенер миграций, для логирования или внедрения промежуточных операций, после выполнения миграции
@protected
final MigrationObserver? observer;
...
/// Метод создающий делегируемую [DriftMigrator] стратегию миграции
MigrationStrategy delegateStrategy(Db db) => MigrationStrategy(
onCreate: (m) => onCreate(m, db),
onUpgrade: (m, from, to) => onUpgrade(m, from, to, db),
beforeOpen: beforeOpen,
);
...
/// Метод первичного создания БД
Future<void> onCreate(Migrator m, Db db) async {
await onCreateFunc(m);
await observer?.onCreate(schemaVersion);
}
/// Метод миграции БД с версии from на версию to
/// Последовательно выполняем миграцию, вызывая метод [_migrate]
Future<void> onUpgrade(Migrator m, int from, int to, Db db) async {
var version = from;
while (version < to) {
version++;
await _migrate(db, version, m);
}
await observer?.onUpgrade(from, to);
}
/// Метод миграции [database]
Future<int?> _migrate(Db database, int schemaVersion, Migrator m) async {
final migrationLogic = migrationLogics.firstWhereOrNull((migrator) => migrator.schemeVersion == schemaVersion);
if (migrationLogic == null) {
await observer?.onMissedMigration(schemaVersion);
} else {
//Если нашли логику миграции, и она правда нужна для этой версии схемы
// (тут применяется двойная проверка версии, как на уровне ключа мапы, так и из внутренней константы)
await migrationLogic.migrate(database, m);
}
}
Подобный подход позволил нам хранить каждую миграцию БД в отдельном классе, за счёт чего они стали более изолированные и тестировать стало легче.
Окружение запуска приложения
Полезно уметь конфигурировать параметры приложения при сборке и последующих запусках. Например, вы хотите собрать проект с определённой конфигурацией. Допустим, с включенными логами или выключенными инструментами отладки. Для этого мы используем централизованный объект, отображающий окружение запуска приложения:
/// Базовые настройки конфигруации при запуске приложения
@freezed
class AppEnvironment with _$AppEnvironment {
/// [buildType] - вид билда приложения
/// [debugOptions] - набор debug-flutter настроек приложения
/// [debugPaintOptions] - набор debug настроек отрисовки Flutter движка, позволяющие отлаживать различные моменты
/// [logLevel] - минимальный логируемый уровень лог-системы приложения
/// [enableEasyLocalizationLogs] - параметр управляющий включением/выключением логов слоя локализации
/// [enableBlocLogs] - параметр управляющий включением/выключением логов BLoC слоя
/// [enableRoutingLogs] - параметр управляющий включением/выключением логов Routing слоя
/// [enableDioLogs] - параметр управляющий включением/выключением логов http слоя
const factory AppEnvironment({
required BuildType buildType,
required DebugOptions debugOptions,
required DebugPaintOptions debugPaintOptions,
required AppLogLevel logLevel,
required bool enableEasyLocalizationLogs,
required bool enableBlocLogs,
required bool enableRoutingLogs,
required bool enableDioLogs,
}) = _AppEnvironment;
factory AppEnvironment.fromJson(Map<String, dynamic> json) => _$AppEnvironmentFromJson(json);
}
Далее в main-методе обрабатываются параметры окружения, задаваемые через параметр —dartdefine, ****а потом преобразуются в AppEnvironment. На его основании Runner запускает приложение:
/// Точка запуска основного приложения
void main() {
// Получаем параметры окружения переданные при сборке/запуске проекта
// Здесь можно вводить необходимые конфигурируемые параметры для различных видов сборок приложения
const logLevelEnv = String.fromEnvironment('logLevel');
const debugInstrumentsEnv = bool.fromEnvironment('debugInstruments');
const buildType = !kReleaseMode || debugInstrumentsEnv ? BuildType.debug : BuildType.release;
final appLogLevel = AppLogLevels.getFromString(logLevelEnv);
final enableLogs = appLogLevel != AppLogLevel.nothing;
Runner.run(
AppEnvironment(
buildType: buildType,
debugOptions: DebugOptions(
debugShowCheckedModeBanner: buildType == BuildType.debug,
),
debugPaintOptions: const DebugPaintOptions(),
logLevel: appLogLevel ?? AppLogLevel.verbose,
enableBlocLogs: enableLogs,
enableRoutingLogs: enableLogs,
enableDioLogs: enableLogs,
enableEasyLocalizationLogs: false,
),
);
}
AppEnvironment регистрируется как singleton в DI-системе и может быть использован любым классом. Таким образом, параметры, указанные через --dart_define каждый раз будут выставляться при старте приложения. Иногда это действительно необходимо. Например, такой подход помог нам распространять iOS-сборки среди QA с флагом debug, хотя канал распространения требовал именно релизной сборки.
Если задуматься, области применения конфигурируемых сборок очень широки. Это позволяет почти полностью избавиться от static const конфигурационных параметров приложения. Также хотим отметить, что AppEnvironment может быть преобразован в json и передан по платформенному каналу в нативные части приложения.
Локализация
Стоит сразу продумать про локализацию приложения и заложить время на поддержку разных языков на самых ранних этапах. Мы использовали довольно популярное решение easy_localization. Работа с ним прошла гладко, затруднений библиотека не вызвала.
Это классическая библиотека локализации с удобным API. Единственная особенность, которую хотим отметить, это использование локализованных строк в background-сервисе (или изоляте), в рамках которого не инициализируется корневой виджет EasyLocalization. Вам придется вручную в рамках изолята вызывать набор внутренних методов easy_localization:
if (fromBackground) {
final easyLocalizationController = EasyLocalizationController(
supportedLocales: const [SupportedLocales.russianLocale],
fallbackLocale: SupportedLocales.russianLocale,
path: 'assets/translations',
useOnlyLangCode: true,
saveLocale: true,
assetLoader: const RootBundleAssetLoader(),
useFallbackTranslations: false,
onLoadError: (e) {
GetIt.I.get<Logger>().w('EasyLocalization background error', e);
},
);
await easyLocalizationController.loadTranslations();
Localization.load(
easyLocalizationController.locale,
translations: easyLocalizationController.translations,
fallbackTranslations: easyLocalizationController.fallbackTranslations,
);
easyLocalizationController.locale;
}
easy_location поддерживает логирование, но для использования собственного Logger придется использовать обертку:
@singleton
class EasyLoggerWrapper {
final Logger _logger;
EasyLoggerWrapper(this._logger);
void log(
Object object, {
String? name,
LevelMessages? level,
StackTrace? stackTrace,
}) {
switch (level) {
case LevelMessages.info:
_logger.i('[$name] $object', null, stackTrace);
break;
case LevelMessages.warning:
_logger.w('[$name] $object', null, stackTrace);
break;
case LevelMessages.error:
_logger.e('[$name] $object', null, stackTrace);
break;
default:
_logger.d('[$name] $object', null, stackTrace);
break;
}
}
}
Для работы с ключами easy_location поддерживает генерацию ключей при помощи команды:
flutter pub run easy_localization:generate --source-dir assets/translations -f keys -o locale_keys.g.dart
Однако такие ключи исключаются из анализатора и в итоге IDE их «не видит» при попытке автоимпорта. Для этого мы воспользовались «костылём» и добавили дополнительный файл, индексируемый в анализаторе, который экспортирует сгенерированные файлы:
export 'package:ati_template/generated/locale_keys.g.dart';
export 'package:easy_localization/easy_localization.dart';
Аналогичное решение мы используем при использовании генератора ресурсов — flutter_gen.
Наша цель
Мы написали этот материал не просто как публичный проект на Хабр. Приведенный шаблон-пример также будет использоваться в качестве вводной статьи для новичков в Flutter-проектах в ATI.SU. Какие-то вещи могут показаться для Вас банальными, но мы надеемся, что каждый сможет найти для себя что-то ценное в данной статье. Наша цель — популяризация Flutter-направления в русскоговорящем сообществе. Вместе мы сделаем еще больше крутых вещей!В качестве заключения
С развитием мобильного рынка и мобильных технологий острее стал вопрос о простом, но эффективном инструменте для кроссплатформенных приложений. По опыту можем сказать, что выбор Flutter в качестве такого инструмента полностью себя оправдывает.Flutter позволяет не только описывать общую бизнес-логику, но и реализовывать общий UI. Ещё это экономит ресурсы команд проектирования UX, разработки и тестирования.
FLUTTER is FUN !
https://habr.com/ru/company/atisu/blog/597709/