Архитектура Redux в Swift: Введение


Картинка для привлечения внимания

Окружение: Swift 5, iOS 14, Xcode 12

Несколько лет назад, ребята из Фейсбука столкнулись с неприятным багом: количество непрочитанных сообщений в мессенджере иногда расходилось с таковым в других частях страницы. К проблеме было решено подойти системно, а именно придумать способ гарантировать консистентность данных, используемых в компонентах веб-приложений. В результате чего появился шаблон проектирования Flux.

Диаграмма паттерна Flux

Чуть позже Дэн Абрамов и Эндрю Кларк представили миру архитектуру Redux (читается как ридакс), которая стремительно набрала популярность в мире Javascript-разработки. Её подходы являются логическим развитием Flux и применимы не только в рамках экосистемы Javascript и веб-приложений.

Какую проблему мы решаем?

В «классических» архитектурах (MVC, MVP, MVVM и т.д.) состояние приложения размывается по отдельным компонентам, а взаимодействие компонентов и поток данных между ними могут не иметь чёткого протокола. Ридакс решает эту проблему тем, что приложение имеет единое состояние, на обновление которого подписаны все представления. Поток данных при этом является однонаправленным (см. unidirectional data flow), т.е. данные приходят в каждый компонент только одним способом (в одном направлении).

Основные понятия

Схема архитектуры Redux

Хранилище (Store) — хранит состояние (State) всего приложения в форме единой структруры (дерева) данных. Состояние может изменяться только через вызов действий (Actions). Каждый раз, когда состояние изменяется, об этом уведомляются все наблюдатели.

Действия (Actions) — декларативный способ описания изменения состояния. Действия не содержат кода, они принимаются хранилищем и перенаправляются обрабочикам (Reducers). Обработчики применяют действия, реализуя изменение состояния для каждого действия.

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

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

Представления (View) — не имеют собственного состояния, они только могут подписаться на изменения глобального состояния и реагировать на него.

Какие существуют реализации Redux для Swift?

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

Реализация простейшего приложения на Redux и SwiftUI

Реализуем канонический пример, демонстрирующий работу с изменением состояния приложения: счётчик и две кнопки, по нажатию на одну кнопку мы увеличиваем счётчик, на другую — уменьшаем. Для реализации будем использовать Combine и SwiftUI. Поэтому при создании нового проекта в Xсode нужно выбрать SwiftUI приложение.

Описание базовых сущностей Redux

Начнём с описания базовых сущностей архитектуры, определения которых мы дали выше.

Опишем состояние приложения. К нему у нас нет требований.

public protocol ReduxState {}

Опишем действие. К нему мы тоже не предъявляем требований.

public protocol ReduxAction {}

Опишем хранилище. Оно должно уметь уведомлять View об изменении состояния. Для этого оно должно удовлетворять протоколу ObservableObject.

Хранилище должно содержать текущее состояние приложения, это может быть любой тип удовлетворяющий ReduxState.

Для вызова действий должна быть реализована функция dispatch принимающая в качестве параметра тип, удовлетворящий ReduxAction.

public protocol ReduxStore: ObservableObject {
    associatedtype S: ReduxState

    var state: S { get }

    func dispatch(_ action: ReduxAction)
}

Обработчик, согласно определению, является функцией. Его можно было бы объявить как глобальную функцию. В ReSwift он так и реализован, а в Katana его приближённым аналогом является StateUpdater, который предлагается реализовать в виде структуры.

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

public protocol ReduxReducer {
    associatedtype S: ReduxState

    func reduce(state: S?, action: ReduxAction?) -> S
}

Теперь напишем реализацию хранилища.

open class Store<AppState, RootReducer>: ReduxStore
    where RootReducer: ReduxReducer,
    RootReducer.State == AppState
{
    @Published
    private(set) public var state: AppState

    private let rootReducer: RootReducer

    init(
        initialState: State,
        rootReducer: RootReducer
    ) {
        self.state = initialState
        self.rootReducer = rootReducer
    }

    public func dispatch(_ action: ReduxAction) {
        state = rootReducer.reduce(
            state: state,
            action: action
        )
    }
}

Переменная state у нас помечена как @Published для того, чтобы мы могли получать уведомления об её изменениях. Также мы инжектим RootReducer — это корневой обработчик, задача которого объединить все «локальные» обработчики, которые отвечают за модификацию состояния своих компонентов. Идея заключается в следующем: состояние приложения представляет собой дерево состояний компонентов приложения. С каждым из этих состояний умеет работать отдельный обработчик. RootReducer объединяет в себе эти обработчики, возвращая финальное состояние приложения.

Реализация функционала приложения

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

struct CounterState: ReduxState {
    static var initialState: CounterState {
        .init(count: 0)
    }

    let count: Int
}

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

struct AppState: ReduxState {
    let counterState: CounterState
}

Состояние нашего приложения будет определяться состоянием счётчика. А корневой обработчик состояния будет вызывать обработчик состояния счётчика.

struct RootReducer: ReduxReducer {
    let counterReducer: CounterReducer

    func reduce(state: AppState?, action: ReduxAction?) -> AppState {
        return AppState(
            counterState: counterReducer.reduce(
                state: state?.counterState,
                action: action
            )
        )
    }
}

Теперь, после всей этой кучи boilerplate-кода, настало время перейти к реализации бизнес-логики приложения. Определим какие действия могут происходить со счётчиком.

enum CounterAction: ReduxAction {
    case increase
    case decrease
}

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

struct CounterReducer: ReduxReducer {
    func reduce(state: CounterState?, action: ReduxAction?) -> CounterState {
        let currentState = state ?? CounterState.initialState

        guard let action = action as? CounterAction else {
            return currentState
        }

        switch action {
        case .increase: return increaseCounter(for: currentState)
        case .decrease: return decreaseCounter(for: currentState)
        }
    }

    private func increaseCounter(for state: CounterState) -> CounterState {
        CounterState(count: state.count + 1)
    }

    private func decreaseCounter(for state: CounterState) -> CounterState {
        CounterState(count: state.count - 1)
    }
}

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

struct CounterView: View {
    @EnvironmentObject var store: AppStore

    var body: some View {
        VStack {
            Text("\(store.state.counterState.count)")
                .padding()
            HStack {
                Button("Increase") {
                    self.store.dispatch(CounterAction.increase)
                }

                Button("Decrease") {
                    self.store.dispatch(CounterAction.decrease)
                }
            }
        }
    }
}

По нажатию на кнопки, мы говорим хранилищу сделать вызов того, или иного события. Хранилище передаётся как @EnvironmentObject, помечая свойство этой обёрткой (property wrapper), мы говорим, что переменная будет заполнена при помощи окружения SwiftUI, а не создаваться явно. Объект окружения должен удовлетворять ObservableObject, чтобы работал механизм подписок SwiftUI. Протокол ReduxStore как раз требует этого соотвествия, а state указан как @Published. Так при обновлении состояния, текст счётчика будет обновляться автоматически.

Настало время ответить на вопрос: а как передать начальное состояние приложения в переменную store? Для этого нам надо переместиться в класс, который в моём случае называется ReduxSwiftUICounterApp и является неким аналогом AppDelegate в SwiftUI. В нашем случае он будет выглядеть следующим образом:

typealias AppStore = Store<AppState, RootReducer>

@main
struct ReduxSwiftUICounterApp: App {
    private let store: AppStore = Store(
        initialState: AppState(
            counterState: .initialState
        ),
        rootReducer: RootReducer(
            counterReducer: .init()
        )
    )

    var body: some Scene {
        WindowGroup {
            CounterView().environmentObject(store)
        }
    }
}

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

Пример простейшего приложения с Redux

Что дальше?

Сегодня мы заложили основы создания приложения. Данных знаний должно хватить, чтобы написать очень простое приложение для iOS или macOS, а также, чтобы использовать готовые библиотеки, реализующие эту архитектуру, с пониманием происходящего. Но если вы попытаетесь написать real-world приложение, то у вас возникнет, как минимум 2 вопроса: как реализовать сайд-эффекты (например, загрузку данных из сети) и как реализовать навигацию в приложении? Об этом мы поговорим в следующих статьях.