swift-composable-architecture - 一个以一致且易于理解的方式构建应用程序的库,其中考虑了组合、测试和人体工程学。

Created at: 2020-05-04 07:18:40
Language: Swift
License: MIT

可组合架构

词

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

什么是可组合架构?

此库提供了一些核心工具,可用于构建具有不同用途和复杂性的应用程序。它提供了引人入胜的故事,你可以遵循这些故事来解决你在构建应用程序时日常遇到的许多问题,例如:

  • 状态管理
    如何使用简单的值类型管理应用程序的状态,并在多个屏幕之间共享状态,以便可以在另一个屏幕中立即观察到一个屏幕中的突变。

  • 组成
    如何将大型特征分解为较小的组件,这些组件可以提取到自己的隔离模块中,并轻松地粘合在一起以形成特征。

  • 副作用
    如何让应用程序的某些部分以最可测试和最易于理解的方式与外界对话。

  • 测试 如何不仅测试体系结构中内置的功能,而且还为由许多部分组成的功能编写集成测试,并编写端到端测试
    以了解副作用如何影响应用程序。这使你可以有力地保证业务逻辑按预期方式运行。

  • 人体工程学
    如何在简单的 API 中以尽可能少的概念和活动部件完成上述所有操作。

了解更多信息

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

你可以在此处观看所有剧集,以及从头开始的专用多部分架构之旅

视频海报图片

例子

示例应用程序的屏幕截图

此存储库附带了许多示例,以演示如何使用可组合架构解决常见和复杂的问题。查看目录以查看所有内容,包括:

寻找更实质性的东西?查看isowords的源代码,这是一款内置于SwiftUI和可组合架构的iOS单词搜索游戏。

基本用法

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

  • 状态:描述功能执行其逻辑和呈现其 UI 所需的数据的类型。
  • 操作:表示功能中可能发生的所有操作的类型,例如用户操作、通知、事件源等。
  • Reducer:描述如何将应用的当前状态演变为给定操作的下一个状态的函数。化简器还负责返回应运行的任何效果,例如 API 请求,这可以通过返回值来完成。
    Effect
  • 存储:实际驱动功能的运行时。将所有用户操作发送到存储,以便存储可以运行化简器和效果,并且可以观察存储中的状态更改,以便可以更新 UI。

这样做的好处是,你将立即解锁功能的可测试性,并且你将能够将大型复杂功能分解为可以粘合在一起的较小域。

作为基本示例,请考虑显示数字以及“+”和“−”按钮的 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
    }
  }
}

最后,我们定义显示特征的视图。它保留 a,以便它可以观察对状态的所有更改并重新呈现,我们可以将所有用户操作发送到存储,以便状态更改。我们还必须在事实警报周围引入一个结构包装器来制作它,这是视图修饰符所要求的:

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 控制器从此商店中驱动也很简单。你订阅商店是为了更新 UI 并显示警报。代码比 SwiftUI 版本长一点,所以我们在这里折叠了它:

viewDidLoad

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

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

这足以在屏幕上播放一些东西。这绝对比你用普通的 SwiftUI 方式做这件事要多几个步骤,但有一些好处。它为我们提供了一种一致的方式来应用状态突变,而不是将逻辑分散在一些 Observable 的对象和 UI 组件的各种动作闭包中。它还为我们提供了一种简洁的方式来表达副作用。我们可以立即测试这个逻辑,包括效果,而无需做太多额外的工作。

测试

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

要进行测试,请使用与 相同的信息创建 ,但它需要做额外的工作,以允许你断言功能在发送操作时如何演变:

TestStore
Store

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

创建测试存储后,我们可以使用它来断言整个用户步骤流。我们需要证明状态的每一步都改变了我们的预期。例如,我们可以模拟点击递增和递减按钮的用户流程:

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

此外,如果一个步骤导致执行一个效果,将数据反馈回存储,我们必须断言这一点。例如,如果我们模拟用户点击事实按钮,我们希望收到包含事实的事实响应,然后导致警报显示:

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

完成一点前期工作后,你可以立即开始使用任何功能中的依赖项:

struct Feature: ReducerProtocol {
  struct State {  }
  enum Action {  }
  @Dependency(\.numberFact) var numberFact
  
}

此代码的工作方式与以前完全相同,但在构造功能的化简器时,不再需要显式传递依赖项。在预览版、模拟器或设备上运行应用时,将向化简器提供实时依赖项,在测试中将提供测试依赖项。

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

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

可以在不指定任何依赖项的情况下构造测试存储,但你仍然可以重写出于测试目的所需的任何依赖项:

let store = TestStore(
  initialState: Feature.State(),
  reducer: Feature()
)

store.dependencies.numberFact.fetch = { "\($0) is a good number Brent" }

这是在可组合架构中构建和测试功能的基础知识。还有很多东西需要探索,比如构图、模块化、适应性和复杂效果。示例目录包含大量项目可供探索以查看更高级的用法。

文档

有关版本的文档,可在此处获得:

main

其他版本

文档中有许多文章可能会对你有所帮助,因为你对库越来越熟悉:

安装

你可以通过将 ComposableArchitecture 添加为 Xcode 项目作为包依赖项来将其添加到该项目。

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

帮助

如果你想讨论可组合架构或对如何使用它来解决特定问题有疑问,你可以在此存储库的讨论选项卡中开始一个主题,或者在其 Swift 论坛上四处询问。

翻译

本自述文件的以下翻译由社区成员提供:

如果你想贡献翻译,请打开带有 Gist 链接的 PR!

常见问题

  • 可组合架构与Elm,Redux和其他架构相比如何?

    展开以查看答案可组合架构(TCA)建立在Elm Architecture(TEA)和Redux普及的思想基础上,但在Swift语言和Apple平台上却让人感到宾至如归。

    在某些方面,TCA比其他库更固执己见。例如,Redux 没有规定如何执行副作用,但 TCA 要求在类型中对所有副作用进行建模并从化简器返回。

    Effect

    在其他方面,TCA比其他库更宽松一些。例如,Elm 控制可以通过类型创建哪些类型的效果,但 TCA 允许逃生舱口获得任何类型的效果,因为符合 Combine 协议。

    Cmd
    Effect
    Publisher

    然后,TCA高度重视某些事情,这些事情不是Redux,Elm或大多数其他库的重点。例如,组合是TCA非常重要的方面,它是将大特征分解成可以粘合在一起的较小单元的过程。这是通过化简器构建器和运算符(如 )实现的,它有助于处理复杂的功能以及模块化,以获得更好的隔离代码库和改进的编译时间。

    Scope

致谢和感谢

以下人员在图书馆的早期阶段提供了反馈,并帮助图书馆成为今天的样子:

保罗·科尔顿、卡恩·德德奥格鲁、马特·迪普豪斯、约瑟夫·多莱扎尔、艾曼塔斯、马修·约翰逊、乔治·凯马卡斯、尼基塔·列昂诺夫、克里斯托弗·利西奥、杰弗里·马科、亚历杭德罗·马丁内斯、沙伊·米沙利、威利斯·普卢默、西蒙-皮埃尔·罗伊、贾斯汀·普莱斯、斯文·施密特、凯尔·谢尔曼、彼得·西马、贾斯德夫·辛格、马克西姆·斯米尔诺夫、瑞安·斯通、丹尼尔·霍利斯·塔瓦雷斯和所有无点订阅者😁

特别感谢 Chris Liscio,他帮助我们解决了许多奇怪的 SwiftUI 怪癖,并帮助完善了最终的 API。

感谢Shai MishaliCombineCommunity项目,我们从中获得了他们的实现,我们用它来帮助桥接基于委托和回调的API,使与第三方框架的接口变得更加容易。

Publishers.Create
Effect

其他库

可组合架构建立在其他库(特别是ElmRedux)发起的想法基础上。

在 Swift 和 iOS 社区中也有许多架构库。其中每一个都有自己的一组优先级和权衡,与可组合架构不同。

许可证

该库在 MIT 许可下发布。有关详细信息,请参阅许可证