Один из главных вопросов при проектировании приложения — выбор стейт-менеджера. Его реализация должна:
-
Позволить отделить бизнес-логику от логики отображения.
-
Иметь отказоустойчивый код.
-
Расширять понятным и простым способом функциональность проекта при внедрении новых фич.
Моя коллега Кристина Зотьева уже рассказывала, как подружить Elementary и Bloc для управления локальным состоянием.
В этой статье поговорим об управлении глобальным состоянием. Меня зовут Владимир Деев, я Flutter-разработчик компании Surf. Расскажу, как наиболее продуктивно связать Redux и Elementary и «подружить» Redux с асинхронными операциями.
Как должно работать приложение на связке Redux + Elementary
Упростим задачу: не будем глубоко закапываться в структуры данных и красоту отображения на экране. Зато подробно рассмотрим реализацию Redux в «товариществе» с Elementary.
Представим себе простейшее приложение:
-
При нажатии на кнопку «плюс» программа загружает и отображает случайным образом выбранную фотографию собаки.
-
При перезапуске приложения загруженные данные остаются на экране без загрузки по сети.
-
При нажатии на «крестик» все фотографии должны удалиться.
Давайте рассмотрим, каким образом будет работать приложение. State — хранилище данных приложения. State является иммутабельным, нам доступно только создание нового стейта.
-
Action — триггер изменения state.
-
Чистый Redux не умеет работать с асинхронностью. А так как у нас есть сетевые запросы, которые нужно обработать в пространстве самого Redux, мы будем использовать redux_epics в качестве middleware-составляющей. Epics middleware — промежуточная часть между reducer и action. Принимает на вход action, обрабатывает сетевые запросы и запускает следующий action.
-
Reducer — «командный пункт» Redux-архитектуры. Принимает actions непосредственно от middleware или напрямую из приложения и работает со state путём создания нового стейта с новыми данными.
Подключим основные зависимости:
-
Elementary
-
Redux
-
Redux_epics
Это потребуется непосредственно для реализации задачи.
Разберёмся, какие данные нужны. В качестве источника данных будем использовать ресурс https://dog.ceo/dog-api/. Как можно увидеть из документации, JSON с ответом от сервера содержит message, в котором хранится url картинки с собакой, а также поле status. Поэтому опишем два класса: DogData
будем использовать для хранения данных, DogDTO
— для обмена данными между слоем данных и сетевым слоем.
@freezed class DogData with _$DogData { const factory DogData({ required final String message, required final String status, }) = _DogData; } @JsonSerializable(createToJson: false, checked: true) class DogDTO { final String message; final String status; const DogDTO( this.message, this.status, ); factory DogDTO.fromJson(Map<String, dynamic> json) => _$DogDTOFromJson(json); DogData toModel() => DogData( message: message, status: status, ); }
Приступим к разработке Redux-части. Сначала опишем стейт, хранилище данных.
@freezed class DogsState with _$DogsState { const factory DogsState({ @Default(IListConst<DogData>([])) IList<DogData> dogsList, @Default(null) DioError? error, }) = _DogsState; }
Как видим, данные хранятся в виде неиммутабельного списка с пустым листом в качестве значения по умолчанию. Давайте будем хранить здесь также информацию об ошибке запроса данных.
Реализуем первый action для загрузки сетевых данных. Создаём класс RequestLoadingAction
с миксином.
class RequestLoadingAction with ActionCompleterMixin { RequestLoadingAction(); }
mixin ActionCompleterMixin { final _completer = Completer<void>(); void complete() { if (!_completer.isCompleted) { _completer.complete(); } } void completeError(Object error, [StackTrace? stackTrace]) { if (!_completer.isCompleted) { _completer.completeError( error, stackTrace, ); } } Future<void> get future => _completer.future; }
Для контроля за выполнением сетевых запросов будем использовать completer как систему сигналов для Elementary-части. Чтобы обработку completer не прописывать заново в каждом классе action, по которому будет обрабатываться сетевой запрос, вынесем код в миксин. Кстати, этот хитрый товарищ нам потом немного скрасит хмурое бремя программиста при написании тестов для Redux-составляющей приложения, и мокировать completer не придётся.
Пишем middleware с Epics
class DogDataEpicMiddleware { final Client _client; final SharedPrefHelper _sharedPrefHelper; const DogDataEpicMiddleware( this._client, this._sharedPrefHelper, ); Epic<DogsState> getEffects() => combineEpics([ TypedEpic<DogsState, RequestLoadingAction>(_onLoadingCharacter), ]); Stream<Object> _onLoadingCharacter( Stream<RequestLoadingAction> action, EpicStore<DogsState> _) => action.asyncExpand((action) async* { try { final response = await _client.getDog(); if (response != null) { final listFromSP = await _sharedPrefHelper.get('links'); var newList = <String>[]; if (listFromSP != null) { newList = [...listFromSP]; } newList.add(response.message); await _sharedPrefHelper.set('links', newList); action.complete(); yield AddingDataAction(response.toModel()); } } on DioError catch (err) { action.completeError(err); yield CatchingErrorAction(err); } }); }
Преобразуем Stream входящего action в Stream исходящего action:
-
либо в action ошибки, в который передаём dio error,
-
либо в action добавления данных, в который передаём полученный по сети объект.
Чтобы не писать один громоздкий Epic, в котором обрабатываются все приходящие в middleware экшены, используем combineEpics
. В списке будут храниться все Epics: небольшие, хорошо тестируемые юниты, привязанные каждый к конкретному экшену. Также здесь сохраняем список данных о картинках в локальном хранилище и завершаем комплитер.
Не забываем добавить новый экшен, который будет обрабатываться уже в reducer.
class AddingDataAction { final DogData newDog; const AddingDataAction(this.newDog); }
А также экшен для ошибки.
class CatchingErrorAction { final DioError error; const CatchingErrorAction(this.error); }
Работа со State в Reducers. Это святая святых Redux.
class DogDataReducers { static final Reducer<DogsState> getReducers = combineReducers([ TypedReducer<DogsState, AddingDataAction>(_onAddingAction), TypedReducer<DogsState, CatchingErrorAction>(_onError), ]); static DogsState _onAddingAction(DogsState state, AddingDataAction action) { final dogsList = state.dogsList.add(action.newDog); return state.copyWith(dogsList: dogsList); } static DogsState _onError(DogsState state, CatchingErrorAction action) { return state.copyWith(error: action.error); } }
Здесь всё просто: получили ошибку — возвращаем новый стейт, в который добавляем текущую ошибку. Получили новые данные — возвращаем стейт с новыми данными.
Дело за малым: сообщить приложению о том, что здесь есть Redux со своим state, middleware и reducers.
Provider<Store<DogsState>>( create: (context) => Store<DogsState>( combineReducers<DogsState>([ DogDataReducers.getReducers, ]), initialState: const DogsState(), distinct: true, middleware: [ EpicMiddleware( DogDataEpicMiddleware( Client(context.read<Dio>()), context.read<SharedPrefHelper>(), ).getEffects(), ) ], )),
Всё: Redux внедрен в приложение. Осталось самое интересное — «попросить» Elementary с ним работать.
Подробно про пакет Elementary мы писали в статьях:
Как связать Redux и Elementary
Elementary состоит из трех слоев:
-
Model,
-
WidgetModel,
-
Widget.
Давайте по шагам подключим собранный ранее Redux-инструмент к Model. Потом передадим получаемую от Redux информацию через WidgetModel к презентационному слою, а также заставим работать этот механизм в обратном направлении: от Widget к Model.
В Model необходимо на этапе инициализирования добавить подписку на изменения стейта.
final Store<DogsState> _store; final _dogsList = ValueNotifier<IList<DogData>?>(null); late final StreamSubscription<DogsState> _storeSubscription; @override void init() { super.init(); _dogsList.value = _store.state.dogsList; _storeSubscription = _store.onChange.listen(_storeListener); } void _storeListener(DogsState store) { _dogsList.value = store.dogsList; final error = store.error; if (error != null) { handleError(error); } }
Теперь можно отслеживать изменения списка с данными и ошибки в работе сетевого запроса. Любое из этих событий можно обработать как нам угодно.
Чтобы Redux реагировал на изменения в UI, достаточно в методе модели вызвать dispatch и отправить в него соответствующий экшн:
Future<void> fetchDog() async { final action = RequestLoadingAction(); _store.dispatch(action); return await action.future; }
Взаимодействие между менеджером состояний и UI готово:
-
Пользователь нажимает кнопку на экране.
-
Через связку screen-WidgetModel-model запускается механизм взаимодействия с Redux: в middleware загружаются данные из сети. Через action они передаются в reducers, который и создает новый стейт с новыми данными.
-
В Model срабатывает подписка о том, что стейт изменился. Новые данные вносятся в ValueNotifier, изменения в котором проходят через WidgetModel и слушаются на экране.
Плюсы и минусы связки Redux + Elementary
Плюсы:
-
За счёт связки Redux + Elementary управляем ребилдом только нужных элементов.
-
Redux state — единственный источник правды. Достигнута иммутабельность state: доступно только копирование текущего состояния данных. Это позволяет исключить незапланированное изменение текущих данных. Благодаря выбранной архитектуре можно легко проследить, какое действие с данными к каким результатам приводит.
-
При разработке новых features можно легко добавить необходимые поля в state, необходимые actions и обработку соответствующим reducer.
-
Всё, что касается загрузки и обработки данных, управляется Redux.
Минусы:
-
Большое количество бойлерплейт-кода даже для одного state.
-
Если в приложении планируется несколько несвязанных между собой источников данных, то писать несколько redux state, reducers и большого количества actions, конечно же, будет проблемой.
Ссылки:
ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/684688/
Добавить комментарий