Один из главных вопросов при проектировании приложения — выбор стейт-менеджера. Его реализация должна:
Позволить отделить бизнес-логику от логики отображения.
Иметь отказоустойчивый код.
Расширять понятным и простым способом функциональность проекта при внедрении новых фич.
Моя коллега Кристина Зотьева уже рассказывала, как подружить 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, конечно же, будет проблемой.
Ссылки: