Программирование
Однонаправленный поток в Swift
Но как быть с type-safe системой управления состояниями? Как ее построить, используя возможности языка Swift?
На этой неделе я расскажу о подходе к управлению состояниями, который я использую в своих приложениях уже много лет. Мы рассмотрим построение предсказуемой, тестируемой, отлаживаемой и модульной системы управления состояниями в Swift.
Swift способствует созданию type-safe кода, используя набор возможностей языка, позволяющих кодировать правильное поведение в системе типов. Мы стремимся моделировать наши типы таким образом, чтобы логическая ошибка становилась ошибкой времени компиляции, а не времени выполнения. Для этого используются типы значений, перечисления, опции, протоколы, дженерики, фантомные типы и т.д.
Система управления состоянием
Но как быть с type-safe системой управления состояниями? Как ее построить, используя возможности языка Swift? Мы применим те же инструменты для построения функциональной и безопасной системы управления состоянием.
import Observation @Observable final class Store<State, Action> { private(set) var state: State private let reduce: (State, Action) -> State init( initialState state: State, reduce: @escaping (State, Action) -> State ) { self.state = state self.reduce = reduce } func send(_ action: Action) { state = reduce(state, action) } }
Вот базовый пример системы управления состоянием, которую мы можем сделать в Swift. Мы выражаем тип Store, который определяет два общих типа: один для состояния, другой для действия. Он хранит текущее состояние в переменной, которую мы можем мутировать только внутри типа Store. Он также предоставляет нам функцию send, которая принимает в качестве параметра общее действие и мутирует текущее состояние. Весь тип помечен макросом Observable, что позволяет нам получать уведомления о каждом изменении состояния.
Предсказуемая
Первая цель при построении системы управления состоянием, которую мы хотим достичь, — сделать ее предсказуемой. Как видно из приведенного примера, единственным способом изменения состояния является отправка предопределенного действия. Все представления и контроллеры представления в вашем приложении берут экземпляр типа Store и могут читать только фактическое состояние. Не существует способа изменить что-либо в состоянии напрямую. Единственным способом обновления состояния является отправка действия.
struct ShopState: Equatable { var products: [String] = [] } enum ShopAction: Equatable { case add(String) case remove(String) } let reduce: (ShopState, ShopAction) -> ShopState = { state, action in var newState = state switch action { case let .add(product): newState.products.append(product) case let .remove(product): newState.products.removeAll { $0 == product } } return newState } typealias ShopStore = Store<ShopState, ShopAction>
Функция reduce — это единственное место, содержащее логику обновления состояния. Это не означает, что нужно иметь одну функцию для всего приложения. Вы можете объединить несколько функций reduce в одну. Обычно у меня есть функция reduce для каждого функционального модуля моего приложения.
import SwiftUI struct ShopView: View { @State private var store = ShopStore( initialState: .init(products: []), reduce: reduce ) var body: some View { List(store.state.products, id: \.self) { product in Text(verbatim: product) .swipeActions { Button(role: .destructive) { store.send(.remove(product)) } label: { Label("Delete", systemImage: "trash") } } } } }
Как видно из приведенного примера, мы определяем список, в котором отображаются товары. Мы также позволяем удалять товары из списка с помощью свайпов. Представление имеет доступ к состоянию только для чтения и может отображать его как есть. Единственный способ изменить состояние — использовать функцию send с одним из предопределенных в перечислении ShopAction случаев.
Такой подход называется однонаправленным потоком. Как следует из названия, движение осуществляется только в одном направлении. Представление посылает действия, магазин обновляет состояние, а представление принимает обновленное состояние. Применяя этот подход, мы делаем управление состоянием предсказуемым. Вы всегда знаете, где искать мутацию и где находится бизнес-логика вашего приложения.
Тестируемая
Как мы уже говорили, логика приложения находится в функции reduce. Это чистая функция, которая принимает в качестве параметров текущее состояние и действие, которое нужно применить, и возвращает новое состояние. И состояние, и действие являются типами значений. Обычно мы используем структуру для состояния и перечисление для действия. Это означает, что мы можем легко проверить любые функции reduce в модульных тестах.
import XCTest final class ShopReducerTests: XCTestCase { func testRemove() { let initialState = ShopState(products: ["p1"]) let newState = reduce(initialState, .remove("p1")) XCTAssertTrue(newState.products.isEmpty) } }
Все, что нам нужно сделать, — это создать начальное состояние и вызвать функцию reduce с определенным действием. Затем мы можем убедиться, что новое состояние, возвращаемое функцией reduce, содержит все необходимые изменения.
С предварительным просмотром
Этот подход отлично работает с предварительным просмотром в Xcode. Можно создать несколько превью с различными начальными состояниями. Например, одно для пустого списка, другое — для списка товаров. При этом нет необходимости в мокировании протоколов, поскольку состояние представляет собой простую структуру, которую можно создать и поместить в хранилище для отображения в представлении.
#Preview { AnotherShopView( store: .init( initialState: .init(products: ["Product"]), reduce: reduce ) ) } #Preview { AnotherShopView( store: .init( initialState: .init(products: []), reduce: reduce ) ) }
Отлаживаемая
Отладка и логирование стали очень простыми. Если что-то идет не так, вы знаете, где искать. Функция reduce — единственное место, содержащее логику приложения, и это лучшее место для размещения сообщений логов, поскольку все действия проходят через функцию reduce. Это означает, что ваши журналы никогда не пропустят ни одного изменения состояния.
Храня состояние функции в одном месте, мы можем легко отслеживать историю изменения состояния. Это поможет нам понять, какая последовательность действий привела к ошибке. Мы также можем закодировать состояние в JSON и получить его для анализа или восстановить под отладчиком для проверки ошибки.
Модульная
Многие мои коллеги сравнивают однонаправленный поток с single-state контейнером, где все состояние приложения хранится в одном экземпляре определенной структуры AppState. Да, это возможно, но это не единственный путь.
Обычно я определяю хранилище для каждой функции. Таким образом, каждая независимая функция имеет свое собственное хранилище. Это позволяет оптимизировать производительность, поскольку в случае огромного приложения наличие одного хранилища может привести к снижению производительности, когда вся иерархия приложения обновляется при каждом небольшом изменении состояния.
Ссылки
После нескольких лет создания подобных приложений эти идеи вылились в Swift-пакет под названием Swift Unidirectional Flow. Он реализует все обсуждаемые нами идеи в готовом к производству коде, поддерживающем параллелизм и другие возможности, которые могут понадобиться при создании реального приложения.
Я не настаиваю на использовании этого пакета. В частности, не следует импортировать сторонние библиотеки или фреймворки для создания основных функций вашего приложения. Но вы можете использовать его для вдохновения, чтобы построить систему управления состояниями в своем приложении с учетом ваших потребностей.
Заключение
Надеюсь, вам понравилась эта статья. Не стесняйтесь следовать за мной в Twitter и задавать свои вопросы, связанные с этой статьей. Спасибо, что прочитали, и до встречи!
Еще про однонаправленные потоки
- ReSwift: однонаправленный поток данных в Swift
- Workflow: конечные автоматы для UI
- UDF: реализация шаблона Unidirectional Data Flow для iOS
- Реализация Unidirectional Data Flow в супераппе