组合式架构

CI Slack

组合式架构 (简称 TCA) 是一个以一致且易于理解的方式构建应用程序的库,它考虑了组合、测试和人体工程学。它可以用于 SwiftUI、UIKit 以及更多,并且可以在任何 Apple 平台(iOS、macOS、iPadOS、visionOS、tvOS 和 watchOS)上使用。

什么是组合式架构?

此库提供了一些核心工具,可用于构建各种用途和复杂度的应用程序。它提供了引人入胜的示例,您可以按照这些示例来解决日常构建应用程序时遇到的许多问题,例如:

了解更多

组合式架构是在 Point-Free 的许多剧集中设计的,Point-Free 是一个探索函数式编程和 Swift 语言的视频系列,由 Brandon WilliamsStephen Celis 主持。

您可以在此处观看所有剧集,以及一个专门的、从头开始的架构的多部分教程

video poster image

示例

Screen shots of example applications

此 repo 附带大量示例,以演示如何使用组合式架构解决常见和复杂的问题。查看 目录以查看所有示例,包括

正在寻找更实质性的内容?查看 isowords 的源代码,这是一个用 SwiftUI 和组合式架构构建的 iOS 单词搜索游戏。

基本用法

注意

对于逐步交互式教程,请务必查看 了解组合式架构

要使用组合式架构构建功能,您可以定义一些类型和值来对您的域进行建模

这样做的好处是,您将立即解锁功能的测试能力,并且您能够将大型、复杂的功能分解为可以组合在一起的更小的域。

作为一个基本示例,考虑一个 UI,它显示一个数字以及递增和递减该数字的 "+" 和 "−" 按钮。为了使事情更有趣,假设还有一个按钮,点击该按钮会发出 API 请求以获取有关该数字的随机事实,并将其显示在视图中。

要实现此功能,我们创建一个新类型来容纳该域和行为,并使用 @Reducer 宏对其进行注释

import ComposableArchitecture

@Reducer
struct Feature {
}

在这里,我们需要定义一个功能状态的类型,它由当前计数的整数以及表示正在呈现的事实的可选字符串组成

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

注意

我们已将 @ObservableState 宏应用于 State,以便利用库中的观察工具。

我们还需要定义一个功能动作的类型。有一些明显的动作,例如点击递减按钮、递增按钮或事实按钮。但也有一些稍微不明显的动作,例如当我们收到来自事实 API 请求的响应时发生的动作

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

然后我们实现 body 属性,它负责组合该功能的实际逻辑和行为。在其中,我们可以使用 Reduce reducer 来描述如何将当前状态更改为下一个状态,以及需要执行哪些效果。某些操作不需要执行效果,它们可以返回 .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>,以便它可以观察状态的所有更改并重新呈现,并且我们可以将所有用户操作发送到 Store,以便状态更改

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 控制器从这个 Store 驱动也是很简单的。您可以在 viewDidLoad 中观察 Store 中的状态更改,然后使用来自 Store 的数据填充 UI 组件。代码比 SwiftUI 版本长一点,所以我们在这里折叠了它

点击展开!
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)
  }
}

一旦我们准备好显示此视图,例如在应用程序的入口点中,我们可以构造一个 Store。这可以通过指定开始应用程序的初始状态以及为应用程序提供支持的 Reducer 来完成

import ComposableArchitecture

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

这足以让一些东西出现在屏幕上进行尝试。与以原始 SwiftUI 方式执行此操作相比,这绝对需要更多步骤,但有一些好处。它为我们提供了一种一致的方式来应用状态突变,而不是将逻辑分散在一些可观察对象和 UI 组件的各种动作闭包中。它还为我们提供了一种简洁的方式来表达副作用。我们可以立即测试此逻辑,包括效果,而无需进行太多额外的工作。

测试

注意

有关测试的更多深入信息,请参阅专门的 测试 文章。

要进行测试,请使用 TestStore,可以使用与 Store 相同的信息创建它,但它会进行额外的工作以允许您断言您的功能如何随着发送动作而发展

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

创建测试 Store 后,我们可以使用它来断言整个用户流程的步骤。在每个步骤中,我们都需要证明状态以我们期望的方式发生了变化。例如,我们可以模拟用户点击递增和递减按钮的用户流程

// Test that tapping on the increment/decrement buttons changes the count
await store.send(.incrementButtonTapped) {
  $0.count = 1
}
await store.send(.decrementButtonTapped) {
  $0.count = 0
}

此外,如果某个步骤导致执行效果,从而将数据反馈到 Store 中,我们必须断言这一点。例如,如果我们模拟用户点击事实按钮,我们希望收到包含该事实的事实响应,然后导致填充 numberFact 状态

await store.send(.numberFactButtonTapped)

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

但是,我们如何知道将向我们发送什么事实呢?

目前,我们的 Reducer 正在使用一个效果,该效果会访问真实世界以访问 API 服务器,这意味着我们无法控制其行为。为了编写此测试,我们只能任由我们的互联网连接和 API 服务器的可用性摆布。

最好将此依赖项传递给 Reducer,以便我们在设备上运行应用程序时可以使用实时依赖项,但在测试中使用模拟依赖项。我们可以通过向 Feature Reducer 添加一个属性来实现此目的

@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,并且显式地将其传递到所有层可能会很烦人。您可以按照一个过程在库中“注册”依赖项,使其立即对应用程序中的任何层可用。

注意

有关依赖项管理的更多深入信息,请参阅专门的 依赖项 文章。

我们可以从将数字事实功能包装在一个新类型中开始

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)
 }

这段代码的工作方式与以前完全相同,但您不再需要在构建功能的 reducer 时显式传递依赖项。在预览、模拟器或设备中运行应用程序时,实际的依赖项将提供给 reducer,而在测试中,测试依赖项将被提供。

这意味着应用程序的入口点不再需要构建依赖项。

@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 爱好者进行讨论:

安装

您可以通过将 ComposableArchitecture 添加为软件包依赖项,将其添加到 Xcode 项目中。

  1. 文件菜单中,选择添加软件包依赖项...
  2. 在软件包仓库 URL 文本字段中输入“https://github.com/pointfreeco/swift-composable-architecture
  3. 根据您的项目结构而定
    • 如果您只有一个需要访问该库的应用程序目标,则将 ComposableArchitecture 直接添加到您的应用程序中。
    • 如果您想从多个 Xcode 目标中使用此库,或者混合 Xcode 目标和 SPM 目标,您必须创建一个依赖于 ComposableArchitecture 的共享框架,然后在您的所有目标中依赖于该框架。 有关此示例,请查看 Tic-Tac-Toe 演示应用程序,该应用程序将许多功能拆分为模块,并以这种方式使用 tic-tac-toe Swift 软件包中的静态库。

配套库

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 以及所有的 Point-Free 订阅者😁。

特别感谢 Chris Liscio,他帮助我们解决了许多奇怪的 SwiftUI 问题,并帮助改进了最终的 API。

还要感谢 Shai MishaliCombineCommunity 项目,我们从该项目采用了他们对 Publishers.Create 的实现,我们在 Effect 中使用它来帮助桥接基于委托和回调的 API,从而更容易与第三方框架交互。

其他库

Composable Architecture 是建立在其他库(特别是 ElmRedux)的思想基础上。

Swift 和 iOS 社区中也有许多架构库。 每一个都有自己的一组优先级和权衡,这些优先级和权衡与 Composable Architecture 不同。

许可

此库是在 MIT 许可证下发布的。 有关详细信息,请参见 LICENSE