
Задумываетесь ли вы при покупке новой вещи о том, какой путь она прошла, прежде чем попасть к вам в руки? А ведь телефон, книга или тарелка могли посетить аж четыре дополнительных точки в Москве, если продавец далеко от склада.
Но что будет, если продавец находится в другом городе? Или заказ, например, будет храниться на складе самого Маркета? Тогда схема будет разрастаться, будет задействовано больше звеньев и систем.

Но мы, как разработка, должны все равно следовать одному из наших главных принципов — «Логистика это просто».
Меня зовут Саша, я уже шестой год помогаю упрощать логистические интерфейсы в Логистике Маркета. Сегодня я хочу поделиться, как с помощью грамотно подобранной архитектуры нам удалось упростить, стандартизировать и даже ускорить разработку мобильных приложений логистики, которых у нас очень много: приложение для курьеров, приложение для пунктов выдачи заказов, приложения для сортировочных центров и так далее.
Как мы жили на старой архитектуре
В логистике есть много разных приложений. Сегодня, пожалуй, остановимся на приложении, с помощью которого курьеры доставляют заказы до пунктов выдачи или непосредственно пользователей — его и возьмем в качестве примера.

Вот так примерно выглядит часть курьерских заданий: у каждого есть рабочая смена, в рамках которой нужно приезжать на разные точки и выполнять задания, которые там есть. Звучит как игровая механика :)
Пару лет назад я рассказывал о том, как мы переписывали это приложение на третий стек. Не буду заново описывать все подробности страданий по технологиям, но расскажу проблематику, с которой мы жили (и лишь упомяну, что раньше приложение было написано на React Native и постепенно переписывалось внутри себя на Kotlin).
Самый частый запрос фичи в разработке — сделать новое задание для курьеров (или расширить существующее). Под этой формулировкой мы понимаем следующее: написать набор экранов с разными состояниями, например, список заказов или посылок, загрузка фото, сканирование заказов, экран примерки, набор API-методов и моделек, а также бизнес-логику, которая связывает эти экраны и состояния в них.
И так исторически сложилось, что при разработке любой фичи было очень тяжело параллелить разработку, так как файлы, которые приходилось затрагивать, часто пересекались, и накладывалось, что в приложении было два стека: React Native и Kotlin.
Параллельно с тем, что нужно было придумать, как ускорять разработку внутри команды, нам предстоял заезд на платформу Яндекс Про с новым для нас фреймворком — Flutter.

Для контекста: Яндекс Про — это не только приложение для исполнителей Яндекса, но большая платформа, в которой очень много фичей и профессий. Каждый сервис пишет внутри Яндекс Про так называемый модуль исполнителя (далее — МИ), который, по сути, является отдельным приложением внутри общего приложения, но при этом он может использовать функциональность платформы.
У нас не было жестких регламентов, как писать свой МИ — были лишь рекомендации, поэтому был простор для творчества. А так как мы ещё и переписывали свое приложение, по сути, третий раз, да еще на фоне определенных сложностей с ускорением разработки, идеи у нас уже были.
Казалось, можно было пойти по понятному пути: даешь одному разработчику первую фичу, второму разработчику — вторую фичу, и пишешь все, что нужно. Но в этом случае мы бы получили очень плохой TTM (time-to-market), так как сами фичи очень большие. К тому же всегда есть риск, когда выкатываешь сколько-то фичей параллельно, что пойдёт что-то не так, и придётся одновременно тушить несколько пожаров.
Поэтому было принято решение все же сделать мини-команды, каждая из которых будут делать одну большую фичу.
Решаем проблему: переход на новую архитектуру

Как я уже писал, у среднестатистического курьера есть рабочая смена (Shift), в течение которой он работает. Это самая главная сущность с точки зрения данных. Ещё у курьера есть адреса или точки (RoutePoint), по которым он разъезжает в течение дня. Также отмечу, что внутри смены всегда есть поле PointId, потому что мы всегда должны знать, на какую точку сейчас едет курьер или на какой точке выполняет задания. В свою очередь на каждой точке есть какие-то дела или задания (Task). Это либо доставка клиенту, либо заказ с примеркой, либо передача посылок в ПВЗ и так далее. На каждой точке у курьера явный список задач, который нужно сделать.
У смены есть своя статусная модель — ShiftStatus. Состояние этой смены может меняться извне, а не по действиям самого курьера. Например, в процессе выяснилось, что курьеру внепланово нужно забрать посылки от другого курьера (потому что у того сломалась машина), или же часть заданий, которые он должен был выполнить, отменилась клиентом. В этом случае, мы должны переключить статус смены на финальные этапы: чтобы курьер поехал на сортировочный центр и вернул посылки, которые физически находятся у него в машине.
Отсюда следует связь, что если обновляется шифт – нужно обновлять данные по точке, так как текущая точка может быть уже другая, а если обновляются данные по точке – нужно обновлять данные задания. Всегда нужно следить за источником данных, и первый самый главный для нас — смена. При этом, за данные всегда отвечает бэкенд, но мобильное приложение тоже может хранить в себе часть данных, например, отсканированные штрихкоды, заметки или что-то подобное.
На основе этой логики и опыта написания кода внутри нашего контура, мы пришли к выводу, что нам идеально подходит архитектура MVVM (Model-View-ViewModel). В такой парадигме мы очень легко можем параллелить разработку: разделить часть бизнес-логики (доменная модель и логика ее изменения) и UI-часть (верстка, навигация). При этом, между этими двумя частями всегда есть контракт — view_state, который описывает, во что должны преобразоваться данные из домена, чтобы UI-часть вообще про это ничего не знала.

Такая идея концептуально зашла команде, так что мы начали думать, как это реализовать. Сначала мы поискали, есть ли во Flutter что-то уже реализованное для этой архитектуры — не нашли. А если не нашли, то что надо делать? Конечно же писать свое красивое и правильное решение!
Бизнес-логика
Начнём с организации данных бизнес-логики в Модели в схеме MVVM.

Как я уже писал, у Яндекс Про тогда не было жёстких требований к тому, как писать свой МИ, но были определенные рекомендации и ограничения по использованию библиотек. Например, для хранение состояния приложения исторически использовалась библиотека riverpod, поэтому нам нельзя было использовать что-то другое, например, Bloc или Redux.
В riverpod есть базовый класс State Notifier. Он умеет довольно элементарные вещи — хранить данные, предоставлять API для изменения этих данных, а также оповещать подписчиков, что данные изменились.

Звучит хорошо, но в нашем случае это не решает одну проблему: у нас почти все данные асинхронные.
Например, пока не загрузились данные по смене, мы не можем узнать, на какую точку отправляться курьеру, а пока не знаем точку, соответственно, не знаем список заданий на ней и так далее. Именно поэтому нам в каждом хранилище нужно иметь три состояния: состояние загрузки (флажок isLoading), состояние ошибки (isError), и состояние, когда данные пришли и с ними все хорошо. Но если писать это кастомно в каждом классе, который отвечает за конкретную сущность, получится слишком объёмная задача.
Поэтому мы написали свои обертки для StateNotifier. Итак, самая первая — AsyncStateNotifierBase.
abstract class AsyncStateNotifierBase<T> extends StateNotifier<AsyncValue<T>>
with StateNotifierSubscriptions {
T? _data;
AsyncStateNotifierBase(AsyncValue<T> initialValue) : super(initialValue) {
_data = initialValue.data?.value;
init();
}
T? get dataOrNull => state.maybeWhen(
data: (data) => data,
orElse: () => _data,
);
bool get isDone => state is AsyncData;
bool get isError => state is AsyncError;
bool get isLoading => state is AsyncLoading;
Stream<AsyncValue<T>> get streamWithInitialValue => stream.startWith(state);
Stream<T> get streamData =>
stream.where((e) => e is AsyncData).map((e) => e.data!.value).distinct();
@protected
Future<void> updateState(Future<T> Function() updateStateCallback) async {
state = AsyncLoading<T>();
final newState = await AsyncValue.guard(updateStateCallback);
if (mounted) {
state = newState;
}
}
// другие методы
}
Ключевое здесь — наличие асинхронного стейта. Оборачиваем стейт в AsyncValue и пишем три флажочка. А также говорим, что коллбэк по обновлению данных — асинхронный. Два ключевых изменения над обычным StateNotifier.
А затем берём, делаем класс AsyncStateNotifier и наследуем его от AsyncStateNotifierBase. При его реализации вы, по сути, и будете писать асинхронное получение данных в методе source, а всё остальное будет уже из коробки.
abstract class AsyncStateNotifier<T> extends AsyncStateNotifierBase<T> {
AsyncStateNotifier() : super(AsyncLoading<T>());
@protected
Future<T> source();
@override
void init() {
updateState(source);
super.init();
}
@override
void refresh() {
updateState(source);
}
}
Это всё здорово, но пока не решает всех проблем. Представим, что в смене курьера есть точка, которую нужно запросить по id, который лежит внутри модели данных смены. Чтобы узнать id точки, его нужно получить откуда-то сверху, а именно из данных смены, поэтому просто AsyncStateNotifier с асинхронным получением данных не подходит.
Именно так родилась новая абстракция – SelectAsyncStateNotifier. В ней есть два основных метода source и select, а также зависимость от другого источника данных (Notifier). В нашем классе мы подписываемся на источник и получаем его стейт в методе select. Внутри этого метода мы можем достать только ту часть данных, которая нам нужна, и прокинуть её уже в метод source, который, аналогично методу source AsyncStateNotifier, и сходит асинхронно за новыми данными.
abstract class SelectAsyncStateNotifier<T, M, S>
extends AsyncStateNotifierBase<T> {
final AsyncStateNotifierBase<M> _notifier;
SelectAsyncStateNotifier(
AsyncStateNotifierBase<M> notifier,
) : _notifier = notifier,
super(AsyncLoading<T>());
AsyncValue<S> get _currentSelectValue => _notifier.state.whenData(select);
@protected
Future<T> source(S selectValue);
@protected
S select(M value);
@override
void init() {
_collapseStatusAndUpdateState(_currentSelectValue);
addSubscription(
_notifier.stream
.map((state) => state.whenData(select))
.distinct()
.listen(_collapseStatusAndUpdateState),
);
super.init();
}
@override
void refresh() => _collapseStatusAndUpdateState(_currentSelectValue);
}
А ещё, мы сразу сделали класс DelegateAsyncStateNotifier. Пока смотрите код, попробуйте угадать, для чего он нам пригодился.
abstract class DelegateAsyncStateNotifier<T, M>
extends AsyncStateNotifierBase<T> {
final AsyncStateNotifierBase<M> _notifier;
DelegateAsyncStateNotifier(
AsyncStateNotifierBase<M> notifier,
) : _notifier = notifier,
super(AsyncLoading<T>());
@override
void init() {
addSubscription(
_notifier.streamWithInitialValue
.map((s) => s.whenData(select))
.distinct()
.listen((s) => state = s),
);
super.init();
}
@override
void refresh() => _notifier.refresh();
@protected
T select(M value);
}
Его задача — делегировать получение данных другому классу, который вы в него передаете, а сам он просто приведёт данные к нужному формату или возьмёт только их часть. Кто догадался — тот молодец. Целевое назначение этого класса я упомяну чуть-чуть дальше.
И по аналогии, есть интерфейсы Delegate2AsyncStateNotifier:
abstract class Delegate2AsyncStateNotifier<T, S1, S2>
extends AsyncStateNotifierBase<T> {
final AsyncStateNotifierBase<S1> _notifier1;
final AsyncStateNotifierBase<S2> _notifier2;
Delegate2AsyncStateNotifier(
AsyncStateNotifierBase<S1> notifier1,
AsyncStateNotifierBase<S2> notifier2,
) : _notifier1 = notifier1,
_notifier2 = notifier2,
super(AsyncLoading<T>());
@override
void init() {
addSubscription(
AsyncStateNotifierCombiner.combine2<S1, S2, T>(
_notifier1,
_notifier2,
select,
).distinct().listen((s) => state = s),
);
super.init();
}
@override
void refresh() {
_notifier1.refresh();
_notifier2.refresh();
}
@protected
T select(S1 state1, S2 state2);
}
Он нужен, чтобы собирать данные из двух источников. Ещё есть Delegate3AsyncStateNotifier и так далее. Думаю, аналогию вы поняли — цифра равна количеству источников, откуда нужно получить данные и получить новую модель. В продакшен-коде эта цифра изначально доходила до 5, но сейчас, после разных рефакторингов, максимальное число источников данных — всё же максимум 3.
Абстракции это хорошо, давайте посмотрим на реальный пример из кода. Рассмотрим класс CurrentRoutePointModelHolder.
ModelHolder в названии говорит о том, что этот класс отвечает за какую-то бизнес-сущность и операции над ней. Конкретно этот — за данные текущей точки на маршруте и любые операции, затрагивающих данные точки. Например, в приложении есть кнопка «Я на месте», которая подтверждает, что курьер прибыл на место назначения и меняет текущий статус на точке с «Еду на адрес» на «Выполняю задание».
class CurrentRoutePointModelHolder
extends SelectAsyncStateNotifier<RoutePoint?, Shift?, IdOfRoutePoint?> {
final RoutePointRepository _routePointRepository;
CurrentRoutePointModelHolder({
required RoutePointRepository routePointRepository,
required ShiftModelHolder shiftModelHolder,
}) : _routePointRepository = routePointRepository,
super(shiftModelHolder);
@override
void init() {
addSubscriptions({
// add some subscriptions here
});
super.init();
}
@override
Future<RoutePoint?> source(IdOfRoutePoint? selectValue) async {
if (selectValue == null) {
return null;
}
return _routePointRepository.getCourierRoutePoint(selectValue);
}
@override
IdOfRoutePoint? select(Shift? value) => value?.currentRoutePointId;
// Other methods
Future<void> arrive({
String? scannedQrString,
}) async {}
void updateCallTasks(List<CallTaskModel> callTasks) {}
Future<void> saveNote(String note) async {}
}
Что мы в нём видим? Он наследуется от SelectAsyncStateNotifier, потому что ему откуда-то нужно получить ID для запроса данных, и получает в зависимость ShiftModelHolder, который отвечает за операции над сменой. То есть, когда ShiftModelHolder получит данные о смене, мы в методе select получим эти данные, вытащим оттуда ID точки и прокинем его дальше в source. А source уже сходит за данными точки по ID на бэкенд. Также никто не мешает писать другие методы над данными, которые написаны для примера снизу.
Модель данных смены будет реализована похожим образом, но попроще: cам класс наследуется от AsyncStateNotifier, так как в этой модели нужно уметь просто сходить за данными на бэкенд.
class ShiftsModelHolder extends AsyncStateNotifier<List<Shift>> {
final ShiftRepository _shiftRepository;
ShiftsModelHolder({
required ShiftRepository shiftRepository,
}) : _shiftRepository = shiftRepository;
@override
Future<List<Shift>> source() => _shiftRepository.getCourierShifts();
Future<void> silentRefresh() async {
final newState = await AsyncValue.guard(source);
if (mounted && newState != state) {
state = newState;
}
}
Future<void> checkInShift(IdOfShift id) async {
await _shiftRepository.checkInShift(id);
refresh();
}
Future<void> startShift(IdOfShift id) async {
await _shiftRepository.startShift(id);
refresh();
}
Future<void> leaveShift(IdOfShift id) async {
await _shiftRepository.pauseShift(id);
refresh();
}
}
Примерно так реализуется слой Model в наших приложениях.
UI-часть
Перейдем теперь ко View-части —– она, как мне кажется, получилась самая простая.

Как я уже писал выше, почти все наши данные асинхронные. Именно поэтому мы описывали специальные классы, позволяющие получать данные асинхронно, при этом динамически меняя стейт на состояние загрузки, ошибки или же на сами данные.
Во View-части нам нужно было поддержать тоже самое: когда данные находятся в состоянии загрузки, мы должны показывать виджеты загрузки, если данные не удалось запросить — виджеты, рисующие ошибки, и, само собой, если данные пришли — рисовать интерфейсы на основе этих данных.
Поэтому для вью мы написали AsyncStateNotifierWidget, который принимает в себя асинхронный источник данных и билдеры для интерфейсов — на случай загрузки, ошибки и отрисовки данных.
class AsyncStateNotifierWidget<T> extends StatelessWidget {
final StateNotifier<AsyncValue<T>> notifier;
final StateBuilder<T> dataBuilder;
final LoadingBuilder? loadingBuilder;
final ErrorBuilder? errorBuilder;
const AsyncStateNotifierWidget({
required this.notifier,
required this.dataBuilder,
this.loadingBuilder,
this.errorBuilder,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) => MappedAsyncStateNotifierWidget<T, T>(
notifier: notifier,
dataBuilder: dataBuilder,
errorBuilder: errorBuilder,
loadingBuilder: loadingBuilder,
mapper: (state) => state,
);
}
При этом обязательный билдер есть только на отрисовку самих данных. В случае ошибки и загрузки мы написали дефолтные реализации интерфейсов со спиннером и просто текстом ошибки.
Под капотом используется MappedAsyncStateNotifierWidget (аналог MappedStateNotifierWidget в Riverpod). Он максимально простой и инкапсулирует в себе логику того, какой билдер нужно использовать в зависимости состояния данных: если стейт в нотифайере в состоянии загрузки нужен билдер загрузки, если ошибка — билдер ошибки, если есть данные — билдер данных.
class MappedAsyncStateNotifierWidget<T, R> extends StatelessWidget {
final StateNotifier<AsyncValue<T>> notifier;
final StateBuilder<R> dataBuilder;
final LoadingBuilder? loadingBuilder;
final ErrorBuilder? errorBuilder;
final Mapper<T, R> mapper;
const MappedAsyncStateNotifierWidget({
required this.notifier,
required this.dataBuilder,
required this.mapper,
this.loadingBuilder,
this.errorBuilder,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) =>
MappedStateNotifierWidget<AsyncValue<T>, AsyncValue<R>>(
notifier: notifier,
mapper: (state) => state.whenData(mapper),
builder: (context, state) => state.when(
data: (data) => dataBuilder(context, data),
loading: () =>
loadingBuilder?.call(context) ?? const _DefaultLoadingWidget(),
error: (e, t) =>
errorBuilder?.call(context, e, t) ?? _DefaultErrorWidget(e),
),
);
}
Итого, что есть в нашей реализации MVVM:
AsyncStateNotifierBase – просто база: обвязка, регламентирующая асинхронный стейт и асинхронное получение данных.
AsyncStateNotifier – пример простейшей контракта, где нужно запросить данные с бэкенда, чтобы был асинхронный стейт.
SelectAsyncStateNotifier – тоже ходит асинхронно за данными, но имеет зависимость от данных другого источника.
DelegateAsyncStateNotifier – чисто на чилле, ждет получения данных от асинхронного источника (или источников) и просто готовит из них стейт для интерфейсов.
MappedAsyncStateNotifierWidget – виджет, который хранит в себе логику, какие интерфейсы рисовать в зависимости от того, в каком состоянии находятся данные.
AsyncStateNotifierWidget – виджет, принимающий в себя асинхронный источник данных и билдеры для отрисовки состояний.
Кстати, я намеренно до текущего момента не писал, как в схеме MVVM будет реализована часть ViewModel. Я думаю, вы уже могли догадаться: эта часть легко реализуется через DelegateAsyncStateNotifier. Имплементация этого класса как раз и нужна для ViewModel, ведь по концепции MVVM эта часть и занимается сбором данных из одного или нескольких источников и подготовки стейта для View части.
class ReturnCashViewModel extends DelegateAsyncStateNotifier<
ReturnCashViewState,
Iterable<PaymentMetaModel>> with TplErrorManagerViewModelMixin {
final CurrentOrderReturnTaskModelHolder _orderReturnTaskModelHolder;
final DeliveryNavigationManager _navigationManager;
ReturnCashViewModel({
required CurrentOrderReturnTaskModelHolder orderReturnTaskModelHolder,
required PaymentMetaModelHolder paymentMetaModelHolder,
required DeliveryNavigationManager navigationManager,
}) : _orderReturnTaskModelHolder = orderReturnTaskModelHolder,
_navigationManager = navigationManager,
super(paymentMetaModelHolder);
@override
ReturnCashViewState select(Iterable<PaymentMetaModel> value) =>
ReturnCashViewState(
cash: value.cashMeta?.amount.formatted,
card: value.cardMeta?.amount.formatted,
);
void onToggleSelectCashReturn() {
if (dataOrNull != null) {
updateData(
dataOrNull!
.copyWith(isSelectedCashReturn: !dataOrNull!.isSelectedCashReturn),
);
}
}
Future<void> onComplete() async {
try {
if (dataOrNull?.cash != null) {
await _orderReturnTaskModelHolder.cashReturn();
_orderReturnTaskModelHolder.refresh();
}
_navigationManager.popToTop();
} on Exception catch (error, stackTrace) {
reportError(
error,
stackTrace,
);
}
}
}
Что получили, когда перешли на новую архитектуру
Во-первых, мы стандартизировали подход к написанию кода в мобильных клиентах. Теперь все приложения пишутся через MVVM-пакет, который позволяет разработчикам из разных контуров не тратить время на погружение в технический контекст. Саму стандартизацию мы придумали сами, но команде понравился подход к написанию кода.

Во-вторых, в связке с DI мы получили сильный инструмент для переиспользования экранов. View получают в качестве зависимости интерфейс ViewModel, у которой есть определенный контракт, а через DI подсовываем классы, которые реализуют этот контракт.
Как самый частый пример — экран добавления фотографии. По сути, это просто экран, в котором нужно сделать фотографию, или несколько и приложить это фото к контексту. Вот так может выглядеть виджет камеры:
class CameraView extends StatelessWidget {
final CameraViewModel viewModel;
const CameraView({required this.viewModel, super.key});
@override
Widget build(BuildContext context) =>
AsyncStateNotifierWidget<CameraViewState>(
notifier: viewModel,
dataBuilder: (context, state) => TXMCamera(
maxPhotoCount: 1,
title: state.title,
subtitle: state.subtitle,
uploadPhoto: viewModel.uploadPhoto,
// Other props
),
);
}
А так может выглядеть контракт вью модели для этого виджета:
abstract class CameraViewModel<T>
extends DelegateAsyncStateNotifier<CameraViewState, T> {
CameraViewModel(AsyncStateNotifierBase<T> notifier) : super(notifier);
void pop();
void resetPhotoPath() {
_setPhotoPath(null);
}
void setPhotoPath(String path) {
_setPhotoPath(path);
}
Future<void> uploadPhoto();
void _setPhotoPath(String? path) => updateDataWith(
(state) => state.copyWith(path: path),
);
Future<String?> onSelectFromGallery(List<String> images) async {
if (images.isNotEmpty) {
setPhotoPath(images.first);
await uploadPhoto();
return images.first;
}
return null;
}
}
Во вью модели есть набор методов, которые реализованы заранее, и методы, которые нужно реализовать самому при имплементации контракта (например, uploadPhoto). Ключевой момент состоит в том, что вью-модель должна готовить данные для вью по определенному контракту — CameraViewState.
class CameraViewState extends _$CameraViewState {
const factory CameraViewState({
required CameraViewStateTitle title,
required CameraViewStateSubtitle subtitle,
required CameraViewStateSubtitle previewSubtitle,
required int maxPhotoCount,
required int currentPhotoCount,
String? path,
}) = _CameraViewState;
}
Вот пример реализации контракта для экрана фотографии внутри процесса выдачи заказа клиенту:
class DeliveryDashboardCameraViewModel extends CameraViewModel<DeliveryFlowModel>{
static const _maxPhotoCount = 2;
static const _minRequiredPhotoCount = 1;
final RootNavigationManager _rootNavigationManager;
final ToastManager _toastManager;
final DeliveryFlowModelHolder _deliveryFlowModelHolder;
final ModuleStrings _strings;
DeliveryDashboardCameraViewModel({
required DeliveryFlowModelHolder deliveryFlowModelHolder,
required RootNavigationManager rootNavigationManager,
required ToastManager toastManager,
required ModuleStrings strings,
}) : _rootNavigationManager = rootNavigationManager,
_deliveryFlowModelHolder = deliveryFlowModelHolder,
_strings = strings,
_toastManager = toastManager,
super(deliveryFlowModelHolder);
@override
void pop() => _rootNavigationManager.pop();
@override
CameraViewState select(DeliveryFlowModel value) => CameraViewState(
title: const CameraViewStateTitle.delivery(),
subtitle: const CameraViewStateSubtitle.xFromY(),
previewSubtitle: const CameraViewStateSubtitle.xFromY(),
maxPhotoCount: _maxPhotoCount,
currentPhotoCount: value.photos.length,
);
@override
Future<void> uploadPhoto() async {
final path = dataOrNull?.path;
if (path != null) {
final currentPhotoCount = dataOrNull?.currentPhotoCount;
try {
await _deliveryFlowModelHolder.uploadPhotoBatch([path]);
if (currentPhotoCount != null) {
if (currentPhotoCount + 1 < _minRequiredPhotoCount) {
resetPhotoPath();
} else {
_rootNavigationManager.pop();
}
}
} on Exception catch (error, stackTrace) {
reportError(error, stackTrace);
}
}
}
}
Теперь через настройки DI мы можем легко подставить эту реализацию для экрана фотографии:
final deliveryDashboardCameraViewModelProvider = Provider.autoDispose(
(ref) => DeliveryDashboardCameraViewModel(
rootNavigationManager: ref.watch(rootNavigationManagerProvider),
toastManager: ref.watch(toastManagerProvider),
strings: ref.watch(moduleStringsProvider),
deliveryFlowModelHolder: ref.watch(deliveryFlowModelHolderProvider),
),
);
Future<void> openCameraForDeliveryDashboard() async {
if (await _checkCameraPermission()) {
await _rootNavigatorKey.currentState?.push<void>(
provideDependencies(
(watch) => CameraView(
viewModel: watch(deliveryDashboardCameraViewModelProvider),
),
settings: const RouteSettings(name: 'CameraForDeliveryDashboard'),
),
);
}
}
При этом, в любом другом процессе можно также открывать этот экран c другой реализацией ViewModel:
Future<void> openCameraForPhotoControl() async {
if (await _checkCameraPermission()) {
await _rootNavigatorKey.currentState?.push<void>(
provideDependencies(
(watch) => CameraView(
viewModel: watch(photoControlCameraViewModelProvider),
),
settings: const RouteSettings(name: 'CameraForPhotoControl'),
),
);
}
}
В-третьих, мы пришли к понятному распараллеливанию задач. В разработке мы получили сильное ускорение за счет того, что два разработчика могли заранее договориться о контракте между интерфейсом и бизнес-логикой внутри приложения (view_state). Так они могли писать код параллельно, совершенно не мешая друг другу. Это нам очень сильно помогло, особенно когда мы все же решились с нуля переписать приложение для курьера на Flutter.

Однако…
…реализация данной MVVM-архитектуры не подходит для всех все случаев жизни. Например, если у вас есть цепочка зависимостей в данных (как в нашем примере, когда задания зависят от текущей точки, текущая точка зависит от состояния смены) и при загрузке данных где-то в цепочке произошла ошибка — вы теряете предыдущий стейт, так как он затирается «состоянием ошибки». Конкретно для нас это не критично, так как нам нужны всегда самые актуальные данные с бэкенда, но потенциальным потребителям нашего архитектурного пакета — это может быть важно.
Но у нас есть понимание, как это исправить, поэтому, как только этот кейс станет актуальным для нас или потребителей нашего пакета, мы это сможем быстро закодить.
Итоги
Приложение для курьера в логистике стартовало свою разработку в далеком 2019 году. За это время мы успели попробовать и разные стеки технологий, и разные подходы к написанию кода. Но наибольшую эффективность в разработке получили только тогда, когда решились на глобальный эксперимент и решили посмотреть на то, что мы делали, с чистого листа.
Возможно, наш подход к написанию кода и выстраиванию архитектуры приложения подойдёт и вам, особенно, если у в вашей команде остро стоит вопрос «как одну фичу делать быстрее в несколько человек». Но ключевое, что я хотел донести — что не нужно бояться экспериментировать и смотреть на любую проблему под другим углом. И вот тогда вы сможете найти истинный путь к её решению.