Skip to content

Instantly share code, notes, and snippets.

@SubvertDev
Created October 21, 2024 16:58
Show Gist options
  • Save SubvertDev/3317d0c3b35ed601be330d6fc0df5aba to your computer and use it in GitHub Desktop.
Save SubvertDev/3317d0c3b35ed601be330d6fc0df5aba to your computer and use it in GitHub Desktop.

The Composable Architecture

Slack

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

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

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

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

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

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

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

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

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

video poster image

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

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

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

Note

Для пошагового интерактивного руководства обязательно ознакомьтесь с Meet the Composable Architecture.

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

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

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

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

Чтобы реализовать этот функционал мы создаем новый тип, который будет содержать домен и поведение функционала, который будет помечен макросом @Reducer:

import ComposableArchitecture

@Reducer
struct Feature {
}

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

@Reducer
struct Feature {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var numberFact: String?
  }
}

Note

Мы применили макрос @ObservableState к State, чтобы воспользоваться инструментами наблюдения в библиотеке.

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

@Reducer
struct Feature {
  @ObservableState
  struct State: Equatable { /* ... */ }
  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(String)
  }
}

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

@Reducer
struct Feature {
  @ObservableState
  struct State: Equatable { /* ... */ }
  enum Action { /* ... */ }

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      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.numberFact = fact
        return .none
      }
    }
  }
}

И, наконец, мы определяем представление, которое отобразит функционал. Оно хранит StoreOf<Feature>, чтобы можно было наблюдать за всеми изменениями состояния и обновлять представление. Также мы можем отправлять все действия пользователей в хранилище, чтобы изменять состояние:

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    Form {
      Section {
        Text("\(store.count)")
        Button("Decrement") { store.send(.decrementButtonTapped) }
        Button("Increment") { store.send(.incrementButtonTapped) }
      }

      Section {
        Button("Number fact") { store.send(.numberFactButtonTapped) }
      }
      
      if let fact = store.numberFact {
        Text(fact)
      }
    }
  }
}

Также достаточно просто создать контроллер UIKit, управляемый этим хранилищем. Вы можете отслеживать изменения состояния в хранилище во viewDidLoad, чтобы затем обновить UI с данными из хранилища. Код немного длиннее, чем версия для SwiftUI, поэтому здесь мы его сократили:

Click to expand!
class FeatureViewController: UIViewController {
  let store: StoreOf<Feature>

  init(store: StoreOf<Feature>) {
    self.store = 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 decrementButton = UIButton()
    let incrementButton = UIButton()
    let factLabel = UILabel()
    
    // Omitted: Add subviews and set up constraints...
    
    observe { [weak self] in
      guard let self 
      else { return }
      
      countLabel.text = "\(self.store.text)"
      factLabel.text = self.store.numberFact
    }
  }

  @objc private func incrementButtonTapped() {
    self.store.send(.incrementButtonTapped)
  }
  @objc private func decrementButtonTapped() {
    self.store.send(.decrementButtonTapped)
  }
  @objc private func factButtonTapped() {
    self.store.send(.numberFactButtonTapped)
  }
}

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

import ComposableArchitecture

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

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

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

Note

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

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

@Test
func basics() async {
  let store = TestStore(initialState: Feature.State()) {
    Feature()
  }
}

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

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

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

await store.send(.numberFactButtonTapped)

await store.receive(\.numberFactResponse) {
  $0.numberFact = ???
}

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

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

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

@Reducer
struct Feature {
  let numberFact: (Int) async throws -> String
  // ...
}

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

case .numberFactButtonTapped:
  return .run { [count = state.count] send in 
    let fact = try await self.numberFact(count)
    await send(.numberFactResponse(fact))
  }

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

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

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

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

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

await store.send(.numberFactButtonTapped)

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

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

Note

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

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

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: URL(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:

 @Reducer
 struct Feature {
-  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 {
    WindowGroup {
      FeatureView(
        store: Store(initialState: Feature.State()) {
          Feature()
        }
      )
    }
  }
}

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

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

// ...

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

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

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

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

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

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

Чтобы добавить ComposableArchitecture в проект Xcode:

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

Сопутствующие библиотеки

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

  • Composable Architecture Extras: Сопутствующая библиотека для Composable Architecture.
  • TCAComposer: Фреймворк макросов для генерации шаблонного кода в Composable Architecture.
  • TCACoordinators: Паттерн координатора в Composable Architecture.

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

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

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

У нас есть специальная статья с ответами на все часто задаваемые вопросы и комментарии по поводу библиотеки.

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

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

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.

Лицензия

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

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