Один из главных вопросов при проектировании приложения — выбор стейт-менеджера. Его реализация должна:
Позволить отделить бизнес-логику от логики отображения.
Иметь отказоустойчивый код.
Расширять понятным и простым способом функциональность проекта при внедрении новых фич.
Моя коллега Кристина Зотьева уже рассказывала, как подружить Elementary и Bloc для управления локальным состоянием.
В этой статье поговорим об управлении глобальным состоянием. Меня зовут Владимир Деев, я Flutter-разработчик компании Surf. Расскажу, как наиболее продуктивно связать Redux и Elementary и «подружить» Redux с асинхронными операциями.
Как должно работать приложение на связке Redux + Elementary
Упростим задачу: не будем глубоко закапываться в структуры данных и красоту отображения на экране. Зато подробно рассмотрим реализацию Redux в «товариществе» с Elementary.
Представим себе простейшее приложение:
При нажатии на кнопку «плюс» программа загружает и отображает случайным образом выбранную фотографию собаки.
При перезапуске приложения загруженные данные остаются на экране без загрузки по сети.
При нажатии на «крестик» все фотографии должны удалиться.
![](https://habrastorage.org/getpro/habr/upload_files/ea1/b0d/7c4/ea1b0d7c455d2a9ae6ddc381298f1c80.png)
Давайте рассмотрим, каким образом будет работать приложение. 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, конечно же, будет проблемой.
Ссылки:
Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!
Больше кейсов команды Surf ищите на сайте >>