可组合架构
可组合架构(简称TCA)是一个用于以一致且易于理解的方式构建应用程序的库,考虑了组合、测试和人体工程学。它可以在SwiftUI、UIKit等环境中使用,并适用于任何Apple平台(iOS、macOS、visionOS、tvOS和watchOS)。
什么是可组合架构?
这个库提供了一些核心工具,可用于构建各种用途和复杂程度的应用程序。它提供了引人入胜的方案,你可以遵循这些方案来解决在日常构建应用程序时遇到的许多问题,例如:
-
状态管理
如何使用简单的值类型管理应用程序的状态,并在多个屏幕之间共享状态,以便在一个屏幕中的变化可以立即在另一个屏幕中观察到。 -
组合
如何将大型功能分解为更小的组件,这些组件可以提取到自己的独立模块中,并且可以轻松地重新组合在一起形成完整的功能。 -
副作用
如何让应用程序的某些部分以最可测试和易于理解的方式与外部世界交互。 -
测试
如何不仅测试使用该架构构建的功能,还可以为由多个部分组成的功能编写集成测试,以及编写端到端测试以了解副作用如何影响应用程序。这使你能够对业务逻辑的运行方式做出强有力的保证。 -
人体工程学
如何在一个简单的API中完成上述所有内容,使用尽可能少的概念和移动部件。
了解更多
可组合架构是在[Point-Free][pointfreeco]上的多个集数中设计的,这是一个探索函数式编程和Swift语言的视频系列,由[Brandon Williams][mbrandonw]和[Stephen Celis][stephencelis]主持。
你可以在[这里][tca-episode-collection]观看所有集数,还有一个专门的[多部分教程][tca-tour],从头开始介绍该架构。
示例
这个仓库包含了_大量_示例,用于演示如何使用可组合架构解决常见和复杂的问题。查看这个目录以查看所有示例,包括:
寻找更实质性的内容?查看[isowords][gh-isowords]的源代码,这是一个使用SwiftUI和可组合架构构建的iOS单词搜索游戏。
基本用法
[!注意] 要获得逐步的交互式教程,请务必查看[认识可组合架构][meet-tca]。
要使用可组合架构构建功能,你需要定义一些类型和值来模型化你的领域:
- 状态:描述你的功能执行其逻辑和渲染其UI所需数据的类型。
- 动作:表示你的功能中可能发生的所有动作的类型,例如用户动作、通知、事件源等。
- Reducer:描述如何根据给定的动作将应用程序的当前状态演变为下一个状态的函数。Reducer还负责返回应该运行的任何效果,例如API请求,这可以通过返回
Effect
值来完成。 - Store:实际驱动你的功能的运行时。你将所有用户动作发送到store,以便store可以运行reducer和效果,你可以观察store中的状态变化,以便更新UI。
这样做的好处是你将立即解锁功能的可测试性,并且你将能够将大型、复杂的功能分解为可以粘合在一起的较小领域。
作为一个基本示例,考虑一个显示数字以及"+"和"-"按钮的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()
// 省略:添加子视图并设置约束...
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组件的动作闭包中。它还为我们提供了一种简洁的方式来表达副作用。我们可以立即测试这个逻辑,包括效果,而无需做太多额外的工作。
测试
[!注意] 有关测试的更多深入信息,请参阅专门的[测试][testing-article]文章。
要进行测试,请使用TestStore
,它可以使用与Store
相同的信息创建,但它会做额外的工作,允许你断言你的功能在发送操作时如何演变:
@MainActor
func testFeature() 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 = ???
}
然而,我们如何知道会返回给我们什么事实呢?
目前我们的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)
}
)
}
)
}
}
}
但在测试中,我们可以使用一个立即返回确定性、可预测事实的模拟依赖:
@MainActor
func testFeature() 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
,显式地通过所有层传递它可能会变得烦人。你可以遵循一个过程,将依赖"注册"到库中,使它们立即可用于应用程序的任何层。
[!注意] 有关依赖管理的更多深入信息,请参阅专门的[依赖][dependencies-article]文章。
我们可以从将数字事实功能包装在一个新类型中开始:
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
分支的文档可以在这里找到:
其他版本
文档中有一些文章,当你对库更加熟悉时可能会发现它们很有帮助:
- [入门][getting-started-article]
- [依赖][dependencies-article]
- [测试][testing-article]
- [导航][navigation-article]
- [共享状态][sharing-state-article]
- [性能][performance-article]
- [并发][concurrency-article]
- [绑定][bindings-article]
社区
如果你想讨论Composable Architecture或有关如何使用它解决特定问题的问题,有几个地方可以与其他Point-Free爱好者讨论:
- 对于长篇讨论,我们推荐本仓库的[讨论][gh-discussions]标签。
- 对于随意聊天,我们推荐Point-Free Community slack。
安装
你可以通过添加包依赖将ComposableArchitecture添加到Xcode项目中。
- 从文件菜单中,选择添加包依赖项...
- 在包存储库 URL 文本字段中输入 "https://github.com/pointfreeco/swift-composable-architecture"
- 根据项目结构的不同:
- 如果你有一个需要访问该库的单一应用程序目标,那么直接将ComposableArchitecture添加到你的应用程序中。
- 如果你想从多个 Xcode 目标使用这个库,或者混合使用 Xcode 目标和 SPM 目标,你必须创建一个依赖于ComposableArchitecture的共享框架,然后在所有目标中依赖于该框架。有关这方面的示例,请查看井字游戏演示应用程序,它将许多功能拆分为模块,并使用tic-tac-toe Swift 包以这种方式使用静态库。
配套库
可组合架构的设计考虑了可扩展性,有许多社区支持的库可用于增强你的应用程序:
- Composable Architecture Extras: 可组合架构的配套库。
- TCAComposer:用于在可组合架构中生成样板代码的宏框架。
- TCACoordinators:可组合架构中的协调器模式。
如果你想贡献一个库,请提交 PR添加链接!
翻译
社区成员已贡献了以下 README 的翻译:
常见问题
我们有一篇[专门的文章][faq-article]回答了人们关于这个库最常问的问题和评论。
致谢
以下人员在库的早期阶段提供了反馈,帮助使这个库成为今天的样子:
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][pointfreeco] 订阅者 😁。
特别感谢 Chris Liscio,他帮助我们解决了许多奇怪的 SwiftUI 问题,并帮助我们完善了最终的 API。
感谢 Shai Mishali 和 CombineCommunity 项目,我们从中借鉴了他们的 Publishers.Create
实现,我们在 Effect
中使用它来帮助桥接基于代理和回调的 API,使与第三方框架的接口变得更加容易。
其他库
可组合架构建立在其他库开创的思想基础之上,特别是 Elm 和 Redux。
Swift 和 iOS 社区中还有许多架构库。每一个都有自己的一套优先事项和权衡,与可组合架构不同。
许可证
本库根据 MIT 许可证发布。详情请见 LICENSE。