5.12 Redux: основы

Вы уже познакомились с несколькими архитектурными паттернами для Flutter-приложений: Clean Architecture, BLoC и так далее. Мы разберём ещё один — Redux.

Изначально он появился в мире фронтенд-разработки, но быстро стал универсальным благодаря своей простоте, предсказуемости и строгой структуре: всё состояние приложения хранится в одном месте, обновляется через чистые функции и изменяется только в ответ на явно описанные действия.

Эти принципы оказались настолько мощными, что были адаптированы для Flutter с помощью библиотеки flutter_redux.

Наш разговор о Redux мы разбили на два параграфа.

В этом разберём основы: как появился Redux, из каких сущностей состоит и как они связаны друг с другом. Плюс напишем практический пример — по традиции это будет счётчик. А в следующем рассмотрим углублённые концепции.

Немного истории

На заре интернета веб-сайты были очень просты: каждый экран представлял отдельную страницу, состояния были крохотными, а навигация — очевидна. Большая часть сайтов работала по принципу «запрос — ответ»: пользователь переходил по ссылке — происходила полная перезагрузка страницы, и сервер отдавал новую HTML-страницу целиком.

Всё состояние, связанное с пользователем, — например, информацию о текущем пользователе, выбранных товарах в корзине или авторизационной сессии — хранили на сервере. Клиентская часть практически не занималась хранением или управлением данными — максимум, что нужно было помнить в браузере, — это информацию из форм или минимальные настройки пользователя, которые могли сохраняться в cookies.

Но со временем, по мере развития языка JavaScript и браузерных движков, мы пришли к одностраничным сайтам (англ. SPA, Single Page Application) — фактически полноценным приложениям в обёртке веб-сайта.

Они существенно отличаются от своих предшественников: прежде всего тем, что всё состояние теперь хранится и управляется на стороне клиента. Это привело к нескольким трудностям:

  • Сложность управления состоянием в крупных приложениях. Одно дело, если у веб-приложения 5–10 экранов. Если их 50+, то хранение и обновления состояния становятся проблемой.

  • Непредсказуемость изменения состояния. Как правило, состояние могло меняться из разных мест, что рано или поздно приводило к неочевидным багам.

  • Проблема синхронизации обновлений между разными частями UI. Если от одного состояния зависит несколько компонентов интерфейса, то нужно как-то их обновлять.

В ответ на эти проблемы в 2014 году Facebook представил паттерн Flux. Этот паттерн вдохновил разработчиков Дэна Абрамова и Эндрю Кларка — они взяли его за основу, дополнили и разработали собственный паттерн под названием Redux, а заодно и выпустили одноимённую JS-библиотеку для управления состоянием в React-приложениях.

Redux быстро завоевал популярность благодаря своей простоте и предсказуемости. Его принципы оказались настолько универсальными, что были адаптированы для множества других платформ, включая мобильную разработку и, конечно же, Flutter.

Вся суть Redux — в этих четырёх принципах:

  • Единый источник правды.

  • Состояние меняется только через чистые функции.

  • Состояние доступно только для чтения.

  • Однонаправленный поток данных.

Сейчас мы их просто назовём. Ниже — покажем реализацию через сущности Redux, а затем свяжем всё вместе.

Важно

Важно

У Redux довольно высокая кривая обучения. Если у вас возникнут проблемы с пониманием каких-то из концепций — не переживайте! Погружение в архитектуру — это итеративный процесс, требующий времени, практики и дисциплины.

Основы

Redux, как и любая архитектура, состоит из конкретных кубиков, благодаря которым выстраивается предсказуемая и гибкая система. Сейчас мы разберём, из каких конкретно частей состоит Redux, как эти части связаны между собой и зачем они нужны.

State

Любое приложение находится в каком-то состоянии.

  • Пользователь может быть авторизован или нет.

  • Данные для отображения нужно загружать, при этом показывая пользователю состояние загрузки.

  • Загрузка может завершиться успешно или закончиться ошибкой.

Всё это представляет собой какой-то кусочек состояния всего приложения, которое мы можем выразить в виде data-классов.

Примечание

Примечание

Data-класс — это простой контейнер для хранения данных без дополнительной логики. Он состоит из набора атрибутов, которые описывают состояние объекта. Data-классы позволяют легко организовывать и передавать данные в программе.

1final class AppState {
2 final bool isUserAuth;
3 final List<Todo> todos;
4 final Exception? exception;
5
6 const AppState({
7  required this.isUserAuth,
8  required this.todos,
9  required this.exception,
10 });
11}

Actions

Логика приложений подразумевает какую-то динамику. Пользователь может нажимать кнопки, запрашивать свежие данные или запускать какие-то сложные операции.

С точки зрения бизнес-логики всё, что делает пользователь, можно выразить в виде простых действий — Actions.

Нажатие кнопки авторизации, действие “pull-to-refresh”, ошибка загрузки — всё это действия, которые мы можем выразить в виде каких-то простых и осязаемых данных — data-классов.

1// Пользователь нажал кнопку «Войти»
2final class AuthButtonClick {
3 const AuthButtonClick();
4}
5
6// Пользователь потянул список и вызвал обновление данных
7final class PullToRefresh {
8 const PullToRefresh();
9}
10
11// Обновление данных завершилось с ошибкой
12final class LoadFailed {
13 final Exception exception;
14
15 const LoadFailed(this.exception);
16}

Reducer

Теперь нам необходимо место, где мы сможем подружить между собой действия и обновление состояния. В Redux этим местом является редьюсер.

С точки зрения разработки редьюсер — это чистая функция, которая принимает на вход текущее состояние и какое-то действие, которое с этим состоянием необходимо совершить. На основе этих данных редьюсер возвращает новое состояние.

1AppState reducer(AppState oldState, action) {
2  return switch (action) {
3    AuthButtonClick() => AppState(
4      isUserAuth: true, // Обновляем конкретное поле
5      todos: oldState.todos,
6      exception: oldState.exception,
7    ),
8    PullToRefresh() => AppState(
9      isUserAuth: oldState.isUserAuth,
10      todos: oldState.todos,
11      exception: oldState.exception,
12    ),
13    LoadFailed(:final exception) => AppState(
14      isUserAuth: oldState.isUserAuth,
15      todos: oldState.todos,
16      exception: exception, // Обновляем конкретное поле
17    ),
18    _ => oldState,
19  };
20}

Примечание

Примечание

Если в описанном выше редьюсере вам что-то непонятно — не переживайте!
Мы подробнее разберём их работу позже.

В главе про управление состоянием мы сказали, что подходящее представление для работы с состоянием — это конечный автомат. Так вот, чистый редьюсер в сумме с неизменяемым состоянием — это идеальное олицетворение конечного автомата.

Благодаря тому, что само состояние — это неизменяемый класс с конечным количеством значений, мы не можем получить состояние вне заданных нами правил. А благодаря чистоте самого редьюсера мы можем легко и просто описывать переходы из одного состояния в другое, не переживая о побочных эффектах.

Примечание

Примечание

Вы могли обратить внимание, что action, который мы принимаем в нашем редьюсере, не типизирован.

Это особенность реализации Redux как архитектурного решения: action является dynamic, что позволяет обрабатывать любые действия.

Если вы привыкли к строгой типизации — не переживайте, далее в параграфе мы покажем, как задавать конкретные типы для наших действий.

Store

Store — объект который объединяет всё описанное выше воедино и является входной точкой в архитектуру. В его ответственность входит:

  • Хранение актуального состояния.

  • Предоставление состояния для чтения.

  • Возможность реактивно сообщать потребителям об изменениях в состоянии.

  • Принятие действий и вызов редьюсера для обновления состояния.

Принципы и мотивация

Основные концепции разобрали, теперь давайте посмотрим, как они увязываются с принципами Redux.

Единый источник правды

В Redux за всё отвечает Store. Он же предоставляет нам доступ на чтение актуального состояния и даёт возможность инициировать какое-то действие для обработки. Эта концепция является фундаментальной особенностью и преимуществом Redux, ведь таким образом мы получаем:

  1. Предсказуемость в работе с состоянием.

  2. Отсутствие проблем с синхронизацией данных.

  3. Упрощение отладки и тестирования.

Это всё особенно важно с ростом приложения и его сложности.

Но если в подавляющем большинстве архитектур — BLoC, Riverpod, Elementary — вы вольны создать столько бизнес-сущностей, сколько считаете необходимым, то Redux пошёл дальше и возвёл идею единого источника правды в абсолют.

В Redux одно из главных требований заключается в том, что у вас обязан быть один Store на всё приложение, а значит, и одно глобальное состояние.

Состояние меняется только через чистые функции

Редьюсер, о котором мы говорили в «Основах», обязан быть чистой функцией. Он принимает на вход предыдущее состояние и действие, которое необходимо исполнить, после чего возвращает новое состояние.

Чтобы функция была чистой, она должна удовлетворять двум условиям:

  1. Быть детерминированной.

  2. Не обладать побочными эффектами (англ. side effects).

Коротко рассмотрим, что это значит.

Детерминированная функция

Это функция, которая всегда вернёт один и тот же результат для одних и тех же входных данных.
Проще всего о таких функциях думать как о математических функциях, например:

f(x) = x + 1

Мы знаем, что функция, описанная выше, всегда вернёт предсказуемый результат на единицу больше заданного числа, а также понимаем, что сколько бы раз мы ни передали число 9, нам всегда вернётся число 10.

Побочные эффекты

Это любое взаимодействие с чем-то за пределами этой функции.

Например:

1int x = 0;
2
3void incrementCounter() {
4 x = x + 1;
5}

Здесь функция incrementCounter обращается к переменой, которая находится за пределами её зоны видимости, тем самым создавая побочный эффект. Если бы мы хотели сделать эту функцию чистой, то она могла бы выглядеть так:

1void incrementCounter(int x) {
2 return x + 1;
3}

Теперь наша функция работает только со своей зоной видимости и всегда возвращает детерминированный результат.

Но существует важное правило, которое всегда нужно помнить и, что главное, использовать: чистые функции могут вызывать другие чистые функции.


Использование чистых функций в качестве редьюсеров в Redux — это не просто стилистическое предпочтение, а критически важный архитектурный выбор, который имеет несколько существенных преимуществ:

  • Предсказуемость и отладка. Поскольку чистые функции всегда возвращают одинаковый результат для одинаковых входных данных, поведение приложения становится полностью предсказуемым. Это значительно упрощает процесс отладки, ведь мы можем точно воспроизвести любое состояние приложения.

  • Тестируемость. Тестировать чистые функции необычайно просто. Нам не требуются моки или сложная среда для исполнения. Достаточно собрать конкретное состояние и прогнать его через чистую функцию с каким-то действием.

  • Централизация логики. Как и в случае с состоянием, чистые редьюсеры выступают единым источником правды для нашей логики.

Состояние доступно только для чтения

Принцип «только для чтения» (англ. read-only) является критическим для поддержания порядка в работе с состоянием. Единственный способ изменения состояния — это отправка каких-то предопределённых действий на исполнение.

Вместе с этим принципом мы получаем:

  • Предсказуемое изменение состояния. Когда состояние меняется только через конкретные действия, весь процесс становится прозрачным и отслеживаемым. Это исключает случайные изменения и гарантирует, что каждое из них проходит через заранее определённые пути.

  • Поддержку однонаправленного потока данных. Такая структура устраняет циклические зависимости и делает поток данных понятным. О потоке данных поговорим сразу далее.

  • Упрощение поиска проблем и ошибок. При возникновении проблем нам достаточно знать, какие действия и в каком порядке были исполнены, после чего мы сможем полностью воспроизвести проблемную ситуацию.

Однонаправленный поток данных

Важной особенностью Redux является то, как данные проходят сквозь всю архитектуру.

Однонаправленный поток данных (англ. Unidirectional Data Flow, UDF) создаёт чёткую и предсказуемую модель обновления состояния приложения.

5

Базовый флоу работы выглядит примерно так:

  1. Пользователю показывается UI на основе актуального состояния.

  2. Пользователь выполняет действие, которое приводит к вызову Action, например нажимает кнопку.

  3. Store принимает действие, вызывает редьюсер с передачей в него актуального состояния и вызванного действия.

  4. Редьюсер возвращает новое состояние.

  5. Store сохраняет новое состояние, реактивно сообщает всем слушателям о том, что состояние изменилось.

  6. Пользователь видит обновлённый UI на основе нового состояния.

  7. Возвращаемся на шаг 1.

Такая строгая структура потока данных особенно полезна в крупных приложениях, где несколько команд могут работать над разными частями кодовой базы, обеспечивая последовательное поведение компонентов.

Практический пример: счётчик

Давайте закрепим то, что мы узнали. Для этого напишем небольшой счётчик.

Примечание

Примечание

Для работы мы будем использовать пакет https://pub.dev/packages/redux

Техническое задание мы дадим себе сами: нам нужна сущность, состояние которой изначально равно 0, но с возможностью увеличивать и уменьшать значение на единицу.

State

Для начала давайте определим состояние. Так как это обычный счётчик, то нам достаточно простого числа.

Однако в реальном мире наше состояние представляет собой набор данных. Чтобы приблизиться к этому, мы определим состояние в виде data-класса:

1final class CounterState {
2 final int value;
3
4 const CounterState({
5  required this.value,
6 });
7}

Как вы можете заметить, это неизменяемый класс, содержащий конкретное значение счётчика.

Actions

Дальше нам надо определить действия. По бизнес-требованиям мы понимаем, что можем увеличивать и уменьшать состояние на единицу, значит, у нас будет два действия:

1enum CounterAction {
2 increment,
3 decrement,
4}

Аналогично состоянию, в реальном мире действия обычно сложнее. Они могут содержать аргументы и иметь довольно сложное дерево наследования.

Здесь мы используем enum для упрощения работы, потому что для наших действий этого достаточно.

Reducer

Теперь нам необходимо создать редьюсер. Напомним — это должна быть чистая функция и единственное место, которое манипулирует состоянием.

1CounterState counterReducer(CounterState state, action) {
2 return switch(action) {
3  CounterAction.increment => CounterState(value: state.value + 1),
4  CounterAction.decrement => CounterState(value: state.value - 1),
5  _ => state,
6 };
7}

Как видим, здесь просто выполняется обработка действий вокруг актуального состояния.

Стоит обратить внимание на несколько вещей:

  • Если в результате наших действий необходимо обновить состояние, то мы не изменяем переданное нам состояние, а создаём новое на его основе.

  • Если действие не влечёт за собой обновления состояния, то мы возвращаем то же состояние, что получили.

В дальнейшем, когда бизнес-логика будет разрастаться, редьюсер будет обрабатывать всё больше и больше действий, что усложнит чтение и поддержку. О том, как справляться с этой проблемой, мы поговорим в следующем параграфе.

Store

Теперь всё написанное выше нам необходимо как-то подружить между собой. Нам поможет Store.

1final store = Store<CounterStore>(
2 counterReducer,
3 initialState: const CounterStore(value: 0),
4);

Как мы видим, наш Store состоит всего из двух компонентов — чистая бизнес-логика в виде редьюсера (counterReducer) и изначального состояния (initialState), которое мы также описали в техническом задании.

Дальше мы можем использовать этот Store в приложении по своему усмотрению. Например:

1// Чтение актуального состояния
2final value = store.state.value;
3print(value); // Выведет 0
4
5// Отправка действия на исполнение
6store.dispatch(CounterAction.increment);
7print(store.state.value); // Выведет 1

Поздравляем, вот вы и написали первый в жизни Store!

Но это только начало: в следующем параграфе мы углубим знания о Redux и доработаем пример со счётчиком.

Поговорим о том, как связать Redux и асинхронные операции, которые нарушают принцип чистоты функций. Научимся изменять UI при изменении состояния приложения и поймём, как разделять бизнес-логику.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E
Предыдущий параграф5.11. BLoC: библиотека
Следующий параграф5.13. Redux: продвинутые концепции