Skip to content

Instantly share code, notes, and snippets.

@artyom-ivanov
Created March 31, 2023 15:37
Show Gist options
  • Save artyom-ivanov/ed0417fd1f008f0492d3431c033175df to your computer and use it in GitHub Desktop.
Save artyom-ivanov/ed0417fd1f008f0492d3431c033175df to your computer and use it in GitHub Desktop.

The Composable Architecture

CI Slack

Composable Architecture (сокращенно TCA) - это библиотека для создания приложений в последовательном и понятном виде, с учетом композиции, тестирования и эргономики. Ее можно использовать в SwiftUI, UIKit и других платформах Apple (iOS, macOS, tvOS и watchOS).

Что такое Composable Architecture?

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

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

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

  • Сайд-эффекты
    Как позволить определенным частям приложения общаться с внешним миром наиболее тестируемым и понятным способом.

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

  • Эргономика
    Как выполнить все вышеперечисленное с помощью простого API с наименьшим количеством концепций.

Узнать больше

Composable Architecture была разработана в течение видео-курса на Point-Free, изучающего функциональное программирование и язык Swift, ведущими которой являются Brandon Williams и Stephen Celis.

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

video poster image

Примеры

Скриншоты примеров приложений

Этот репозиторий содержит множество примеров, которые демонстрируют, как решать распространенные и сложные проблемы с помощью Composable Architecture. Посмотрите эту директорию, чтобы увидеть их все, включая:

Ищете что-то более значимое? Посмотрите исходный код для isowords, игры по поиску слов для iOS, созданной в SwiftUI и Composable Architecture.

Базовое использование

Для создания функциональности с использованием Composable Architecture необходимо определить несколько типов и значений, которые моделируют вашу предметную область:

  • Состояние (State): тип, который описывает данные, необходимые для выполнения логики и отображения UI.
  • Действие (Action): тип, который представляет все действия, которые могут произойти в вашей функциональности, такие как действия пользователей, уведомления, источники событий и другие.
  • Редьюсер (Reducer): функция, которая описывает, как изменить текущее состояние приложения на следующее состояние при заданном действии. Редьюсер также отвечает за возвращение любых эффектов, которые должны быть запущены, таких как запросы к API, которые могут быть выполнены путем возврата значения Effect.
  • Хранилище (Store): среда выполнения, которая фактически управляет вашей функциональностью. Вы отправляете все действия пользователей в хранилище, чтобы хранилище могло запускать редьюсеры и эффекты, и вы можете наблюдать изменения состояния в хранилище, чтобы обновлять UI.

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

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

Для реализации этой функциональности мы создаем новый тип, который будет содержать предметную область и поведение, реализовав протокол ReducerProtocol:

import ComposableArchitecture

struct Feature: ReducerProtocol {
}

Здесь нам нужно определить тип состояния фичи, который состоит из целого числа для текущего счетчика, а также из необязательной строки, которая представляет заголовок оповещения, которое мы хотим показать (необязательно, потому что nil означает, что оповещение не нужно отображать):

struct Feature: ReducerProtocol {
  struct State: Equatable {
    var count = 0
    var numberFactAlert: String?
  }
}

Нам также нужно определить тип действий фичи. Есть очевидные действия, такие как нажатие кнопки уменьшения, увеличения или получения факта о числе. Но также есть некоторые несколько менее очевидные действия, такие как закрытие оповещения и действие при получении ответа от API:

struct Feature: ReducerProtocol {
  struct State: Equatable {  }
  enum Action: Equatable {
    case factAlertDismissed
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(TaskResult<String>)
  }
}

Затем мы реализуем метод reduce, который отвечает за обработку логики и поведения. Он описывает, как изменить состояние, и описывает, какие эффекты нужно выполнить. Некоторые действия не требуют выполнения эффектов, и они могут возвращать .none:

struct Feature: ReducerProtocol {
  struct State: Equatable {  }
  enum Action: Equatable {}

  func reduce(into state: inout State, action: Action) -> EffectTask<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 .task { [count = state.count] in
          await .numberFactResponse(
            TaskResult {
              String(
                decoding: try await URLSession.shared
                  .data(from: URL(string: "http://numbersapi.com/\(count)/trivia")!).0,
                as: UTF8.self
              )
            }
          )
        }

      case let .numberFactResponse(.success(fact)):
        state.numberFactAlert = fact
        return .none

      case .numberFactResponse(.failure):
        state.numberFactAlert = "Could not load a number fact :("
        return .none
    }
  }
}

И, наконец, мы определяем представление, которое выведет фичу. Оно хранит StoreOf<Feature>, чтобы можно было наблюдать за всеми изменениями состояния и обновлять представление, и мы можем отправлять все действия пользователей в хранилище, чтобы изменять состояние. Мы также должны ввести обертку структуры вокруг оповещения о факте, чтобы сделать его Identifiable, что требуется для модификатора представления .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, чтобы обновлять UI и отображать оповещения. Код немного длиннее, чем версия SwiftUI, поэтому мы свернули его здесь:

Нажмите чтобы раскрыть!
class FeatureViewController: UIViewController {
  let viewStore: ViewStoreOf<Feature>
  var cancellables: Set<AnyCancellable> = []

  init(store: StoreOf<Feature>) {
    self.viewStore = ViewStore(store)
    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()

    // Пропущено: добавляем сабвью и устанавливаем констрейнты...

    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(),
          reducer: Feature()
        )
      )
    }
  }
}

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

Тестирование

Для получения более подробной информации о тестировании смотрите посвященную этому статью.

Для тестирования используйте TestStore, который можно создать с той же информацией, что и Store, но он выполняет дополнительную работу, чтобы вы могли проверять, как ваша функциональность развивается при отправке действий:

@MainActor
func testFeature() async {
  let store = TestStore(
    initialState: Feature.State(),
    reducer: Feature()
  )
}

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

// Проверяем, что нажатие на кнопки увеличения и уменьшения изменяет счетчик
await store.send(.incrementButtonTapped) {
  $0.count = 1
}
await store.send(.decrementButtonTapped) {
  $0.count = 0
}

Кроме того, если на шаге выполняется эффект, который возвращает данные в хранилище, мы должны проверить это. Например, если мы смоделируем действие пользователя по нажатию кнопки факта, мы ожидаем получить ответ с фактом, который затем вызовет появление оповещения:

await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse(.success(???))) {
  $0.numberFactAlert = ???
}

Однако как мы узнаем, какой факт будет отправлен нам обратно?

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

Было бы лучше передать эту зависимость в редьюсер, чтобы мы могли использовать реальную зависимость при запуске приложения на устройстве, но использовать имитационную зависимость для тестов. Мы можем сделать это, добавив свойство в редьюсер Feature:

struct Feature: ReducerProtocol {
  let numberFact: (Int) async throws -> String}

Затем мы можем использовать его в реализации reduce:

case .numberFactButtonTapped:
  return .task { [count = state.count] in
    await .numberFactResponse(TaskResult { try await self.numberFact(count) })
  }

А в точке входа в приложение мы можем предоставить версию зависимости, которая фактически взаимодействует с реальным сервером API:

@main
struct MyApp: App {
  var body: some Scene {
    FeatureView(
      store: Store(
        initialState: Feature.State(),
        reducer: Feature(
          numberFact: { number in
            let (data, _) = try await URLSession.shared
              .data(from: .init(string: "http://numbersapi.com/\(number)")!)
            return String(decoding: data, as: UTF8.self)
          }
        )
      )
    )
  }
}

А в тестах мы можем использовать имитационную зависимость, которая немедленно возвращает детерминированный, предсказуемый факт:

@MainActor
func testFeature() async {
  let store = TestStore(
    initialState: Feature.State(),
    reducer: Feature(
      numberFact: { "\($0) is a good number Brent" }
    )
  )
}

С этим небольшим начальным этапом мы можем закончить тестирование, смоделировав действие пользователя по нажатию кнопки факта, получив ответ от зависимости, чтобы вызвать оповещение, а затем закрыв оповещение:

await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse(.success("0 is a good number Brent"))) {
  $0.numberFactAlert = "0 is a good number Brent"
}

await store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

Мы также можем улучшить эргономику использования зависимости numberFact в нашем приложении. С течением времени приложение может эволюционировать во многие функции, и некоторым из этих функций также может потребоваться доступ к numberFact, а явная передача его через все уровни может быть раздражающей. Есть процесс, который вы можете использовать для «регистрации» зависимостей в библиотеке, делая их мгновенно доступными для любого уровня в приложении.

Для более подробной информации см. соответствующую статью об управлении зависимостями.

Мы можем начать с обертки функциональности факта числа в новый тип:

struct NumberFactClient {
  var fetch: (Int) async throws -> String
}

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

extension NumberFactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      let (data, _) = try await URLSession.shared
        .data(from: .init(string: "http://numbersapi.com/\(number)")!)
      return String(decoding: data, as: UTF8.self)
    }
  )
}

extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

С этим небольшим начальным этапом вы можете мгновенно начать использовать зависимость в любой функции, используя обертку свойства @Dependency:

 struct Feature: ReducerProtocol {
-  let numberFact: (Int) async throws -> String
+  @Dependency(\.numberFact) var numberFact-  try await self.numberFact(count)
+  try await self.numberFact.fetch(count)
 }

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

Это означает, что точка входа в приложение больше не должна создавать зависимости:

@main
struct MyApp: App {
  var body: some Scene {
    FeatureView(
      store: Store(
        initialState: Feature.State(),
        reducer: Feature()
      )
    )
  }
}

И тестовое хранилище может быть создано без указания каких-либо зависимостей, но вы по-прежнему можете переопределить любую зависимость, которая необходима для тестирования:

let store = TestStore(
  initialState: Feature.State(),
  reducer: Feature()
) {
  $0.numberFact.fetch = { "\($0) is a good number Brent" }
}

Это основы создания и тестирования фич в Composable Architecture. Есть очень много вещей, которые можно изучить, такие как композиция, модульность, адаптивность и комплексные эффекты. В директории Examples есть множество проектов, которые можно изучить, чтобы увидеть более продвинутые применения.

That is the basics of building and testing a feature in the Composable Architecture. There are a lot more things to be explored, such as composition, modularity, adaptability, and complex effects. The Examples directory has a bunch of projects to explore to see more advanced usages.

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

Документация для релизов и ветки main доступна здесь:

Другие версии

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

Комьюнити

Если вы хотите обсудить Composable Architecture или у вас есть вопросы о том, как его использовать для решения определенной проблемы, есть несколько мест, где вы можете общаться с единомышленниками Point-Free:

  • Для долгих дискуссий мы рекомендуем использовать вкладку discussions в этом репозитории.
  • Для неформального общения мы рекомендуем Point-Free Community slack.

Установка

Чтобы добавить ComposableArchitecture в проект Xcode: You can add ComposableArchitecture to an Xcode project by adding it as a package dependency.

  1. В меню File выберите Add Packages...
  2. Введите "https://github.com/pointfreeco/swift-composable-architecture" в поле для ссылки на репозиторий пакетов
  3. В зависимости от того, как устроен ваш проект:
    • сли у вас есть одна таргет-сборка приложения, которая нуждается в доступе к библиотеке, то добавьте ComposableArchitecture непосредственно в ваше приложение.
    • сли вы хотите использовать эту библиотеку для нескольких таргетов в Xcode или использовать таргеты и SPM-таргеты цели, вам нужно создать shared-фреймворк, который зависит от ComposableArchitecture и затем зависит от этого фреймворка во всех ваших таргетах. Для примера см. Tic-Tac-Toe демо-приложение, которое разделяет многие фичи на модули и использует статическую библиотеку в этом виде с помощью Swift пакета tic-tac-toe.

Переводы

Переводы этого README были сделаны участниками сообщества:

Если вы хотите внести свой вклад в перевод, пожалуйста, откройте PR со ссылкой на Gist!

FAQ

  • Подходит ли Composable Architecture для использования с Elm, Redux и тд?

    Откройте чтобы увидеть ответ

    Composable Architecture (TCA) построен на основе идей, популяризированных Elm Architecture (TEA) и Redux, но сделанных так, чтобы они чувствовали себя комфортно в языке Swift и на платформах Apple.

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

    В других аспектах TCA немного менее строг, чем другие библиотеки. Например, Elm контролирует, какие типы эффектов могут быть созданы через тип Cmd, но TCA позволяет использовать любые виды эффектов, так как Effect соответствует протоколу Combine Publisher.

    И есть такие вещи, которые TCA высоко ценит, но которые не являются фокусом для Redux, Elm или большинства других библиотек. Например, композиция является очень важным аспектом TCA, который заключается в том, чтобы разбивать большие функции на более мелкие части, которые можно собрать вместе. Это достигается с помощью билдеров редьюсеров и операторов, таких как Scope, и это способствует обработке сложных функций, модульности для лучшей изоляции кодовой базы и улучшенной времени компиляции.

Благодарности

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

Paul Colton, Kaan Dedeoglu, Matt Diephouse, Josef Doležal, Eimantas, Matthew Johnson, George Kaimakas, Nikita Leonov, Christopher Liscio, Jeffrey Macko, Alejandro Martinez, Shai Mishali, Willis Plummer, Simon-Pierre Roy, Justin Price, Sven A. Schmidt, Kyle Sherman, Petr Šíma, Jasdev Singh, Maxim Smirnov, Ryan Stone, Daniel Hollis Tavares, and all of the Point-Free subscribers 😁.

Особая благодарность Chris Liscio который помог нам разобраться со многими странными особенностями SwiftUI и уточнить окончательный API.

И спасибо Shai Mishali и проекту CombineCommunity, откуда мы взяли их реализацию Publishers.Create, который мы используем в Effect для того, чтобы подружить между собой delegate и callback-based API. Это сделало работу со сторонними фреймворками намного проще.

Другие библиотеки

Composable Architecture была построена на основе идей, начатых другими библиотеками, в частности Elm и Redux.

В сообществе Swift и iOS также существует множество архитектурных библиотек. Каждая из них имеет свой набор приоритетов и компромиссов, которые отличаются от TCA.

License

Эта библиотека выпущена под лицензией MIT. Подробности можно узнать в LICENSE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment