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

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

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

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

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


Ссылки:

Комментарии (0)