Point-Free 的 The Composable Architecture 使用 Apple 的 Combine 框架作为其 Effect
类型的基石。
这个分支修改了 The Composable Architecture,使其使用 Reactive Swift 作为 Effect
类型的基石。 最初的动机是在 iOS 13 和 macOS 10.15 之前的 OS 版本中使用 The Composable Architecture,这些版本是最早支持 Combine 框架的版本,因此也支持 TCA。自 0.8.1 版本起,也增加了对 Linux 的兼容性。
然而,由于一些原因,维持对较旧 Apple OS 的支持变得不切实际,因此从 0.33.1 版本开始,最低 OS 要求与 TCA 相同:iOS 13 和 macOS 10.15。当然,0.28.1 及更早的版本仍然支持较旧的 OS 版本。
在 Pointfreeco 的 Composable Architecture 中,Effect
类型包装了一个 Combine Producer
,并且也符合 Publisher
协议。这是必需的,因为对 Publisher
的每个操作(例如 map
)都会返回一种新的 Publisher 类型(在 map
的情况下为 Publishers.Map
),因此为了拥有单个 Effect
类型,这些发布者类型始终需要使用 eraseToEffect()
擦除回 Effect
类型。
使用 ReactiveSwift 时,ReactiveSwift 不使用 Combine 的类型模型,Effect<Output, Failure>
只是 SignalProducer<Value, Error>
的类型别名。永远不需要类型擦除。此外,由于 ReactiveSwift 基于生命周期的可释放处理,因此您很少需要保留对 Disposable
的引用,这与 Combine 不同,在 Combine 中,您必须始终保留对任何 Cancellable
的引用,否则它将立即终止。
The Composable Architecture(简称 TCA)是一个以一致和易于理解的方式构建应用程序的库,它考虑了组合性、测试和人体工程学。它可以用于 SwiftUI、UIKit 等,并可在任何 Apple 平台(iOS、macOS、tvOS 和 watchOS)以及 Linux 上使用。
The Composable Architecture(简称 TCA)是一个以一致和易于理解的方式构建应用程序的库,它考虑了组合性、测试和人体工程学。它可以用于 SwiftUI、UIKit 等,并可在任何 Apple 平台(iOS、macOS、tvOS 和 watchOS)上使用。
此库提供了一些核心工具,可用于构建各种用途和复杂性的应用程序。它提供了引人入胜的故事,您可以按照这些故事来解决在构建应用程序时遇到的许多日常问题,例如:
状态管理
如何使用简单的值类型管理应用程序的状态,并在多个屏幕之间共享状态,以便在一个屏幕中的更改可以立即在另一个屏幕中观察到。
组合
如何将大型功能分解为更小的组件,这些组件可以提取到它们自己的隔离模块中,并且可以轻松地组合在一起以形成该功能。
副作用
如何以最可测试和易于理解的方式让应用程序的某些部分与外部世界对话。
测试
如何不仅测试在架构中构建的功能,还可以为由多个部分组成的功能编写集成测试,并编写端到端测试以了解副作用如何影响您的应用程序。这使您可以强烈保证您的业务逻辑以您期望的方式运行。
人体工程学
如何在简单的 API 中以尽可能少的概念和移动部件来完成上述所有操作。
The Composable Architecture 是在 Point-Free 上的许多剧集中设计的,Point-Free 是一个探索函数式编程和 Swift 语言的视频系列,由 Brandon Williams 和 Stephen Celis 主持。
您可以在这里观看所有剧集,以及从头开始专门针对该架构的 多部分游览。
此 repo 附带大量示例,用于演示如何使用 Composable Architecture 解决常见和复杂的问题。查看 这个 目录以查看所有示例,包括:
正在寻找更实质性的东西?查看 isowords 的源代码,这是一个用 SwiftUI 和 Composable Architecture 构建的 iOS 单词搜索游戏。
要使用 Composable Architecture 构建功能,您可以定义一些类型和值来建模您的域
Effect
值来完成。这样做的好处是,您将立即解锁功能的测试能力,并且您可以将大型、复杂的功能分解为可以粘合在一起的较小域。
作为一个基本示例,考虑一个 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>
,以便它可以观察状态的所有变化并重新渲染,并且我们可以将所有用户操作发送到 store,以便状态发生变化。我们还必须在事实警报周围引入一个 struct 包装器,使其成为 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 }
}
直接使用此 store 驱动 UIKit 控制器也很简单。您在 viewDidLoad
中订阅 store 以更新 UI 并显示警报。代码比 SwiftUI 版本长一点,所以我们在这里折叠了它
class FeatureViewController: UIViewController {
let viewStore: ViewStoreOf<Feature>
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.produced
.map { "\($0.count)" }
.assign(to: \.text, on: countLabel)
self.viewStore.produced.numberFactAlert
.startWithValues { [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)
}
}
@objc private func incrementButtonTapped() {
self.viewStore.send(.incrementButtonTapped)
}
@objc private func decrementButtonTapped() {
self.viewStore.send(.decrementButtonTapped)
}
@objc private func factButtonTapped() {
self.viewStore.send(.numberFactButtonTapped)
}
}
一旦我们准备好显示此视图,例如在应用程序的入口点中,我们可以构造一个 store。这可以通过指定开始应用程序的初始状态以及将驱动应用程序的 reducer 来完成
import ComposableArchitecture
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
FeatureView(
store: Store(
initialState: Feature.State(),
reducer: Feature()
)
)
}
}
}
这足以在屏幕上显示一些内容以供使用。它肯定比您以普通的 SwiftUI 方式执行此操作要多几个步骤,但是有一些好处。它为我们提供了一种一致的方式来应用状态变化,而不是将逻辑分散在一些可观察的对象和 UI 组件的各种动作闭包中。它还为我们提供了一种简洁的方式来表达副作用。我们可以立即测试此逻辑,包括效果,而无需进行太多的额外工作。
有关测试的更多深入信息,请参阅专门的 测试 文章。
要测试,请使用 TestStore
,可以使用与 Store
相同的信息创建它,但它会进行额外的工作,以便您可以断言您的功能如何随着操作的发送而演变
@MainActor
func testFeature() async {
let store = TestStore(
initialState: Feature.State(),
reducer: 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 中,我们必须对此进行断言。例如,如果我们模拟用户点击事实按钮,我们希望收到带有事实的事实响应,然后导致显示警报
await store.send(.numberFactButtonTapped)
await store.receive(.numberFactResponse(.success(???))) {
$0.numberFactAlert = ???
}
但是,我们如何知道将发送回给我们的事实是什么?
目前,我们的 reducer 正在使用一种效果,该效果深入到现实世界以访问 API 服务器,这意味着我们无法控制其行为。为了编写此测试,我们受互联网连接和 API 服务器可用性的支配。
最好将此依赖项传递给 reducer,以便我们可以在设备上运行应用程序时使用实时依赖项,但在测试中使用模拟依赖项。我们可以通过向 Feature
reducer 添加属性来实现
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
…
}
此代码的工作方式与之前完全相同,但是您不再需要在构造功能的 reducer 时显式传递依赖项。在预览、模拟器或设备中运行应用程序时,实时依赖项将被提供给 reducer,在测试中,测试依赖项将被提供给 reducer。
这意味着应用程序的入口点不再需要构造依赖项
@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 目录包含许多项目可供探索,以了解更多高级用法。
此处提供了发行版本和 main
的文档
随着您对库越来越熟悉,您可能会发现文档中的许多文章很有帮助
您可以通过将其添加为包依赖项来将 ComposableArchitecture 添加到 Xcode 项目。
如果您想讨论 Composable Architecture 或对如何使用它来解决特定问题有疑问,您可以在此仓库的 discussions 选项卡中发起一个主题,或者在其 Swift 论坛上询问。
以下此 README 的翻译由社区成员贡献
如果您想贡献翻译,请打开一个 PR,其中包含指向 Gist 的链接!
Composable Architecture 与 Elm、Redux 等相比如何?
在某些方面,TCA 比其他库更固执己见。例如,Redux 不规定如何执行副作用,但 TCA 要求所有副作用都以 Effect
类型建模,并从 reducer 返回。
在其他方面,TCA 比其他库更宽松。例如,Elm 通过 Cmd
类型控制可以创建哪些类型的效果,但 TCA 允许使用任何类型的效果,因为 Effect
符合 Combine Publisher
协议。
还有一些 TCA 高度优先考虑的事情,而不是 Redux、Elm 或大多数其他库的重点。例如,组合是 TCA 非常重要的一个方面,它是将大型功能分解为可以粘合在一起的较小单元的过程。这是通过 reducer 构建器和诸如 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,以及所有 Point-Free 订阅者 😁。
特别感谢 Chris Liscio,他帮助我们解决了许多奇怪的 SwiftUI 怪癖,并帮助完善了最终 API。
感谢 Shai Mishali 和 CombineCommunity 项目,我们从中采用了他们对 Publishers.Create
的实现,我们在 Effect
中使用它来帮助桥接基于委托和回调的 API,从而更容易与第三方框架交互。
Composable Architecture 建立在其他库开始的思想基础上,特别是 Elm 和 Redux。
在 Swift 和 iOS 社区中也有许多架构库。这些库中的每一个都有自己的一组优先级和权衡,这与 Composable Architecture 不同。
此库是在 MIT 许可证下发布的。有关详细信息,请参见 LICENSE。