После более чем 3 лет разработки, 145 участников и 983 закрытых пул-реквестов Composable Architecture наконец-то достигла версии 1.0.
Библиотека была достаточно стабильна с момента своего появления, но авторы не были готовы поставить на нее ярлык «1.0» до тех пор, пока не выпустили инструменты навигации, что произошло всего несколько недель назад. Если вы хотите начать работу с библиотекой уже сегодня, у вас есть два варианта:
- На этой неделе разработчики начинают выпуск фирменной серии эпизодов Point-Free, посвященных библиотеке. В первом уже доступном эпизоде вы создадите простое приложение с нуля, чтобы продемонстрировать, как реализовать первую фичу, контролировать зависимости и написать полный набор тестов.На следующей неделе обучение продолжится воссозданием с нуля приложения Scrumdinger от Apple с использованием Composable Architecture. Это предполагает объединение множества изолированных функций, изучение шаблонов навигации, работу со сложными эффектами и написание тестов для проверки всех тонкостей и нюансов логики приложения.
- В документации имеется полное руководство, в котором рассматриваются все основы построения функций в композабл архитектуре. Сюда входит реализация основной логики и поведения функций с помощью редукторов, контроль зависимостей, написание тестов и использование инструментов навигации библиотеки.
Что такое Composable Architecture?
Эта библиотека предоставляет несколько основных инструментов, которые могут быть использованы для создания приложений различного назначения и сложности. В ней представлены убедительные истории, следуя которым можно решить многие проблемы, с которыми вы ежедневно сталкиваетесь при создании приложений.
Например:
- Управление состоянием
Как управлять состоянием приложения, используя простые типы значений, и передавать состояние на множество экранов, чтобы изменения на одном экране немедленно отражались на другом. - Композиция
Как разбить большие функции на более мелкие компоненты, которые могут быть выделены в отдельные изолированные модули и легко склеены вместе для создания функции. - Сайд-эффекты
Как позволить определенным частям приложения общаться с внешним миром наиболее тестируемым и понятным способом. - Тестирование
Как не только протестировать функцию, построенную в архитектуре, но и написать интеграционные тесты для функций, состоящих из множества частей, и написать сквозные тесты, чтобы понять, как побочные эффекты влияют на ваше приложение. Это позволяет дать твердые гарантии того, что ваша бизнес-логика работает именно так, как вы ожидаете. - Эргономика
Как реализовать все вышеперечисленное в простом API с минимальным количеством концепций и движущихся частей.
Для создания функции с помощью Composable Architecture необходимо определить некоторые типы и значения, которые моделируют ваш домен:
- Состояние (State): Тип, описывающий данные, которые необходимы функции для выполнения логики и отображения пользовательского интерфейса.
- Действие (Action): Тип, представляющий все действия, которые могут происходить в вашей функции, такие как действия пользователя, уведомления, источники событий и т.д.
- Редьюсер (Reducer): Функция, которая описывает, как перевести текущее состояние приложения в следующее состояние, заданное действием. Редуктор также отвечает за возврат любых эффектов, которые должны быть запущены, например, API-запросов, что можно сделать, вернув значение Effect.
- Хранилище (Store): Рантайм, который фактически управляет вашей функцией. Вы отправляете все действия пользователя в хранилище, чтобы оно могло запустить редуктор и эффекты, и можете наблюдать за изменениями состояния хранилища, чтобы обновлять пользовательский интерфейс.
Преимущества такого подхода заключаются в том, что вы мгновенно повышаете тестируемость вашей функции и сможете разбить большую сложную функцию на более мелкие домены, которые можно склеить между собой.
Пример Composable Architecture
В качестве базового примера рассмотрим пользовательский интерфейс, в котором отображается число, а также кнопки «+» и «-«, увеличивающие и уменьшающие это число. Чтобы сделать ситуацию более интересной, предположим, что есть также кнопка, которая при нажатии делает API-запрос для получения случайного факта об этом числе, а затем выводит этот факт в виде оповещения.
Для реализации этой возможности мы создаем новый тип, который будет содержать область и поведение функции, соответствующий протоколу Reducer:
import ComposableArchitecture struct Feature: Reducer { }
Здесь нам необходимо определить тип состояния функции, который состоит из целого числа для текущего счета, а также необязательной строки, представляющей заголовок оповещения, которое мы хотим показать (необязательной, поскольку nil означает, что оповещение не нужно показывать):
struct Feature: Reducer { struct State: Equatable { var count = 0 var numberFactAlert: String? } }
Также необходимо определить тип действия функции. Есть очевидные действия, такие как нажатие кнопки уменьшения, увеличения или получения факта. Но есть и несколько неочевидных действий, например, действие пользователя, отклоняющего оповещение, и действие, возникающее при получении ответа от API-запроса факта:
struct Feature: Reducer { struct State: Equatable { /* ... */ } enum Action: Equatable { case factAlertDismissed case decrementButtonTapped case incrementButtonTapped case numberFactButtonTapped case numberFactResponse(String) } }
Затем мы реализуем метод reduce, который отвечает за обработку фактической логики и поведения функции. Он описывает, как изменить текущее состояние на следующее, и рассказывает, какие эффекты должны быть выполнены. Некоторые действия не нуждаются в выполнении эффектов, и они могут возвращать .none, чтобы показать это:
struct Feature: Reducer { struct State: Equatable { /* ... */ } enum Action: Equatable { /* ... */ } func reduce(into state: inout State, action: Action) -> Effect<Action> { switch action { case .factAlertDismissed: state.numberFactAlert = nil return .none case .decrementButtonTapped: state.count -= 1 return .none case .incrementButtonTapped: state.count += 1 return .none case .numberFactButtonTapped: return .run { [count = state.count] send in let (data, _) = try await URLSession.shared.data( from: URL(string: "http://numbersapi.com/\(count)/trivia")! ) await send( .numberFactResponse(String(decoding: data, as: UTF8.self)) ) } case let .numberFactResponse(fact): state.numberFactAlert = fact return .none } } }
И, наконец, мы определяем представление, которое отображает функцию. Оно хранит StoreOf<Feature>, так что может наблюдать за всеми изменениями состояния и перерисовывать его, а мы можем посылать в хранилище все действия пользователя, чтобы состояние менялось. Мы также должны ввести структурную обертку вокруг сообщения о числе, чтобы сделать его идентифицируемым, чего требует модификатор вида .alert:
struct FeatureView: View { let store: StoreOf<Feature> var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in VStack { HStack { Button("−") { viewStore.send(.decrementButtonTapped) } Text("\(viewStore.count)") Button("+") { viewStore.send(.incrementButtonTapped) } } Button("Number fact") { viewStore.send(.numberFactButtonTapped) } } .alert( item: viewStore.binding( get: { $0.numberFactAlert.map(FactAlert.init(title:)) }, send: .factAlertDismissed ), content: { Alert(title: Text($0.title)) } ) } } } struct FactAlert: Identifiable { var title: String var id: String { self.title } }
На основе этого хранилища можно создать контроллер UIKit. Вы подписываетесь на магазин в viewDidLoad, чтобы обновлять пользовательский интерфейс и показывать оповещения. Код немного длиннее, чем в версии для SwiftUI, поэтому мы привели его здесь в сокращенном виде:
class FeatureViewController: UIViewController { let viewStore: ViewStoreOf<Feature> var cancellables: Set<AnyCancellable> = [] init(store: StoreOf<Feature>) { self.viewStore = ViewStore(store, observe: { $0 }) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() let countLabel = UILabel() let incrementButton = UIButton() let decrementButton = UIButton() let factButton = UIButton() // Omitted: Add subviews and set up constraints... self.viewStore.publisher .map { "\($0.count)" } .assign(to: \.text, on: countLabel) .store(in: &self.cancellables) self.viewStore.publisher.numberFactAlert .sink { [weak self] numberFactAlert in let alertController = UIAlertController( title: numberFactAlert, message: nil, preferredStyle: .alert ) alertController.addAction( UIAlertAction( title: "Ok", style: .default, handler: { _ in self?.viewStore.send(.factAlertDismissed) } ) ) self?.present(alertController, animated: true, completion: nil) } .store(in: &self.cancellables) } @objc private func incrementButtonTapped() { self.viewStore.send(.incrementButtonTapped) } @objc private func decrementButtonTapped() { self.viewStore.send(.decrementButtonTapped) } @objc private func factButtonTapped() { self.viewStore.send(.numberFactButtonTapped) } }
После того как мы готовы отобразить это представление, например, в точке входа приложения, мы можем построить хранилище. Это можно сделать, указав начальное состояние, в котором будет запускаться приложение, а также редуктор, на котором будет работать приложение:
import ComposableArchitecture @main struct MyApp: App { var body: some Scene { WindowGroup { FeatureView( store: Store(initialState: Feature.State()) { Feature() } ) } } }
И этого достаточно, чтобы получить на экране нечто, с чем уже можно поиграть. Это, безусловно, на несколько шагов больше, чем если бы вы делали это в ванильном SwiftUI, но есть несколько преимуществ. Это дает нам последовательный способ применения мутаций состояния, вместо того чтобы разбрасывать логику по некоторым наблюдаемым объектам и различным замыканиям компонентов пользовательского интерфейса. Это также дает нам лаконичный способ выражения побочных эффектов. И мы можем сразу же протестировать эту логику, включая эффекты, не делая большой дополнительной работы.