Как подружить Redux и Elementary

от автора

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

  • Позволить отделить бизнес-логику от логики отображения.

  • Иметь отказоустойчивый код.

  • Расширять понятным и простым способом функциональность проекта при внедрении новых фич.

Моя коллега Кристина Зотьева уже рассказывала, как подружить 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *