Окружение: Swift 5, iOS 14, Xcode 12
Несколько лет назад, ребята из Фейсбука столкнулись с неприятным багом: количество непрочитанных сообщений в мессенджере иногда расходилось с таковым в других частях страницы. К проблеме было решено подойти системно, а именно придумать способ гарантировать консистентность данных, используемых в компонентах веб-приложений. В результате чего появился шаблон проектирования Flux.
Чуть позже Дэн Абрамов и Эндрю Кларк представили миру архитектуру Redux (читается как ридакс), которая стремительно набрала популярность в мире Javascript-разработки. Её подходы являются логическим развитием Flux и применимы не только в рамках экосистемы Javascript и веб-приложений.
Какую проблему мы решаем?
В «классических» архитектурах (MVC, MVP, MVVM и т.д.) состояние приложения размывается по отдельным компонентам, а взаимодействие компонентов и поток данных между ними могут не иметь чёткого протокола. Ридакс решает эту проблему тем, что приложение имеет единое состояние, на обновление которого подписаны все представления. Поток данных при этом является однонаправленным (см. unidirectional data flow), т.е. данные приходят в каждый компонент только одним способом (в одном направлении).
Основные понятия
Хранилище (Store) — хранит состояние (State) всего приложения в форме единой структруры (дерева) данных. Состояние может изменяться только через вызов действий (Actions). Каждый раз, когда состояние изменяется, об этом уведомляются все наблюдатели.
Действия (Actions) — декларативный способ описания изменения состояния. Действия не содержат кода, они принимаются хранилищем и перенаправляются обрабочикам (Reducers). Обработчики применяют действия, реализуя изменение состояния для каждого действия.
Обработчики (Reducers) — чистые функции, которые на основе переданного действия и текущего состояния, создают измененную копию состояния приложения. Важно запускать все обработчики на одном потоке (это не обязательно должен быть главный поток), так как в противном случае может произойти рассинхронизация состояния.
Что такое чистые функции?
Чистая функция (Pure function) - это функция которая:
- Всегда выдаёт одинаковый результат для одних и тех же входных параметров;
- Не производит сайд-эффектов: вызовы сетевых запросов, I/O, изменение данных приложения.
Ознакомившись с определением чистой функции, мы поймём, что не сможем, к примеру, сделать запрос к API из обработчика. Вызов нужно делать за его пределами. Мы же должны сделать 3 действия: загрузка, данные загружены, ошибка, которые будут вызываться перед запросом, при получении данных и при ошибке соотвественно. Обработчик будет только соотвествующим образом обновлять состояние приложения, а представление его отрисовывать.
Представления (View) — не имеют собственного состояния, они только могут подписаться на изменения глобального состояния и реагировать на него.
Какие существуют реализации Redux для Swift?
Понятно, что я не первый, кому пришла в голову использовать устоявшийся подход из мира веб-приложений, и если порыться на Гитхабе, то можно найти большое количество самых разных реализаций. Я бы обратил внимание на 3 наиболее удачных реализации:
Реализация простейшего приложения на Redux и SwiftUI
Реализуем канонический пример, демонстрирующий работу с изменением состояния приложения: счётчик и две кнопки, по нажатию на одну кнопку мы увеличиваем счётчик, на другую — уменьшаем. Для реализации будем использовать Combine и SwiftUI. Поэтому при создании нового проекта в Xсode нужно выбрать SwiftUI приложение.
Описание базовых сущностей Redux
Начнём с описания базовых сущностей архитектуры, определения которых мы дали выше.
Опишем состояние приложения. К нему у нас нет требований.
public protocol ReduxState {}
Опишем действие. К нему мы тоже не предъявляем требований.
public protocol ReduxAction {}
Опишем хранилище. Оно должно уметь уведомлять View об изменении состояния. Для этого оно должно удовлетворять протоколу ObservableObject
.
Что такое ObservableObject?
Объекты, которые удовлетворяют протоколу ObservableObject
, получают генерируемое компилятором свойство objectWillChange
типа Publisher
, на которое можно подписаться и слушать уведомления об изменении свойств объекта. Чтобы сообщить компилятору, какие именно свойства нас интересуют, мы должны использовать для них обёртку (property wrapper) @Published
. Слушая objectWillChange
мы будем получать событие каждый раз, когда какое-либо свойство отмеченное @Published
будет изменяться. Следует понимать, что событие будет типа Void
, поэтому мы не узнаем какое именно свойство изменилось.
Хранилище должно содержать текущее состояние приложения, это может быть любой тип удовлетворяющий 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
. Теперь только осталось запустить приложение и убедиться, что наш счётчик работает.
Что дальше?
Сегодня мы заложили основы создания приложения. Данных знаний должно хватить, чтобы написать очень простое приложение для iOS или macOS, а также, чтобы использовать готовые библиотеки, реализующие эту архитектуру, с пониманием происходящего. Но если вы попытаетесь написать real-world приложение, то у вас возникнет, как минимум 2 вопроса: как реализовать сайд-эффекты (например, загрузку данных из сети) и как реализовать навигацию в приложении? Об этом мы поговорим в следующих статьях.