RxComposableArchitecture 是 Composable Architecture 的一个分支,并进行了调整以使其与 UIKit 配合使用。
RxSwift
而不是 Combine
作为响应式骨干 (以支持 iOS<13)。ViewStore
,因为在 UIKit 上,我们尚不需要 ViewStore
的存在。Composable Architecture (简称 TCA) 是一个用于以一致且易于理解的方式构建应用程序的库,它考虑了组合、测试和人体工程学。这个库基于 PointFree 的 Swift Composable Architecture。
这个库提供了一些核心工具,可以用于构建各种目的和复杂度的应用程序。它提供了引人入胜的方案,您可以遵循这些方案来解决在构建应用程序时遇到的许多日常问题,例如
状态管理
如何使用简单的值类型管理应用程序的状态,并在多个屏幕之间共享状态,以便在一个屏幕中的更改可以立即在另一个屏幕中观察到。
组合
如何将大型功能分解为更小的组件,这些组件可以提取到它们自己的独立模块中,并轻松地粘合在一起以形成功能。
副作用
如何让应用程序的某些部分以最可测试和可理解的方式与外部世界对话。
测试
如何不仅测试在架构中构建的功能,还为已由多个部分组成的功能编写集成测试,并编写端到端测试以了解副作用如何影响您的应用程序。这使您可以强烈保证您的业务逻辑以您期望的方式运行。
人体工程学
如何在尽可能少的概念和移动部件的简单 API 中完成以上所有操作。
Composable Architecture 是在 Point-Free 的许多剧集中设计的,Point-Free 是一个探索函数式编程和 Swift 语言的视频系列,由 Brandon Williams 和 Stephen Celis 主持。
您可以在这里观看所有剧集,以及从头开始的架构的专门多部分导览:第一部分,第二部分,第三部分 和 第四部分。
此仓库附带示例,演示如何使用 RxComposableArchitecture 解决常见问题。查看此目录以查看所有示例
正在寻找更实质性的内容?查看 isowords 的源代码,这是一个用 SwiftUI 和 Composable Architecture 构建的 iOS 单词搜索游戏。
要使用 Composable Architecture 构建功能,您需要定义一些类型和值来模拟您的领域
Effect
值来完成。这样做的好处是您将立即解锁功能的可测试性,并且您能够将大型复杂功能分解为可以粘合在一起的更小的域。
作为一个基本示例,考虑一个 UI,它显示一个数字以及用于增加和减少数字的“+”和“−”按钮。为了使事情更有趣,假设还有一个按钮,当点击该按钮时,会发出 API 请求以获取关于该数字的随机事实,然后在警报中显示该事实。
此功能的状态将包含当前计数的整数,以及表示我们要显示的警报标题的可选字符串(可选,因为 nil
表示不显示警报)
struct AppState: Equatable {
var count = 0
var numberFactAlert: String?
}
接下来我们有功能中的动作。有一些明显的动作,例如点击减少按钮、增加按钮或事实按钮。但也有一些稍微不那么明显的动作,例如用户关闭警报的动作,以及当我们收到来自事实 API 请求的响应时发生的动作
enum AppAction: Equatable {
case factAlertDismissed
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(Result<String, ApiError>)
}
struct ApiError: Error, Equatable {}
接下来,我们为该功能完成其工作所需的依赖项环境建模。特别是,为了获取数字事实,我们需要构造一个 Effect
值,该值封装了网络请求。因此,该依赖项是从 Int
到 Effect<String>
的函数,其中 String
表示来自请求的响应。此外,effect 通常会在后台线程上执行其工作(URLSession
就是这种情况),因此我们需要一种在主队列上接收 effect 值的方法。我们通过主队列调度器来实现这一点,这是一个重要的依赖项,以便我们可以编写测试。我们必须使用 AnyScheduler
,以便我们可以在生产中使用来自 RxSwift
的实时 MainScheduler.instance
,并在测试中使用测试调度器。
struct AppEnvironment {
var mainQueue: SchedulerType
var numberFact: (Int) -> Effect<String>
}
接下来,我们实现一个 reducer,它实现此域的逻辑。它描述了如何将当前状态更改为下一个状态,并描述了需要执行哪些 effect。某些动作不需要执行 effect,它们可以返回 .none
来表示这一点
let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
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 environment.numberFact(state.count)
.receive(on: environment.mainQueue)
.catchToEffect(AppAction.numberFactResponse)
case let .numberFactResponse(.success(fact)):
state.numberFactAlert = fact
return .none
case .numberFactResponse(.failure):
state.numberFactAlert = "Could not load a number fact :("
return .none
}
}
然后最后我们定义显示该功能的视图。它持有 Store<AppState, AppAction>
,以便它可以观察状态的所有更改并重新渲染,并且我们可以将所有用户动作发送到 store,以便状态更改。我们还必须在事实警报周围引入一个结构体包装器,使其成为 Identifiable
,这是 .alert
视图修饰符所要求的
class AppViewController: UIViewController {
private let store: Store<AppState, AppAction>
private let disposeBag = DisposeBag()
init(store: Store<AppState, AppAction>) {
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 incrementButton = UIButton()
let decrementButton = UIButton()
let factButton = UIButton()
// Omitted: Add subviews and set up constraints...
store.subscribe(\.number)
.map { "\($0.count)" }
.subscribe(onNext: { [numberLabel] in
countLabel.text = String($0)
})
.disposed(by: disposeBag)
store.subscribe(\.numberFactAlert)
.subscribe(onNext: { [weak self] numberFactAlert in
let alertController = UIAlertController(
title: numberFactAlert, message: nil, preferredStyle: .alert
)
alertController.addAction(
UIAlertAction(
title: "Ok",
style: .default,
handler: { _ in self?.store.send(.factAlertDismissed) }
)
)
self?.present(alertController, animated: true, completion: nil)
})
.disposed(by: disposeBag)
}
@objc private func incrementButtonTapped() {
store.send(.incrementButtonTapped)
}
@objc private func decrementButtonTapped() {
store.send(.decrementButtonTapped)
}
@objc private func factButtonTapped() {
store.send(.numberFactButtonTapped)
}
}
重要的是要注意,我们能够在没有实际的实时 effect 的情况下实现整个功能。这很重要,因为它意味着可以在隔离状态下构建功能,而无需构建其依赖项,这可以帮助缩短编译时间。
一旦我们准备好显示此视图,例如在场景委托中,我们可以构造一个 store。这是我们需要提供依赖项的时刻,现在我们可以只使用一个立即返回模拟字符串的 effect
let viewController = AppViewController(store: Store(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment(
mainQueue: MainScheduler.instance,
numberFact: { number in Effect(value: "\(number) is a good number Brent") }
)
))
这足以在屏幕上显示一些东西来玩。这肯定比您以原始 SwiftUI 方式执行此操作要多几个步骤,但有一些好处。它为我们提供了一种一致的方式来应用状态突变,而不是将逻辑分散在一些可观察对象和 UI 组件的各种动作闭包中。它还为我们提供了一种简洁的方式来表达副作用。我们可以立即测试此逻辑,包括 effect,而无需进行太多额外的工作。
要进行测试,您首先创建一个 TestStore
,其中包含与您创建常规 Store
相同的信息,但这次我们可以提供测试友好的依赖项。特别是,我们使用测试调度器而不是实时 DispatchQueue.main
调度器,因为这允许我们控制工作何时执行,并且我们不必人为地等待队列赶上。
let scheduler = TestScheduler(initialClock: 0)
let store = TestStore(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment(
mainQueue: scheduler,
numberFact: { number in Effect(value: "\(number) is a good number Brent") }
)
)
创建测试 store 后,我们可以使用它来断言用户步骤的整个流程。在每一步中,我们都需要证明状态如何按照我们的预期变化。此外,如果某个步骤导致 effect 被执行,从而将数据反馈到 store 中,我们必须断言这些动作已正确接收。
下面的测试是用户增加和减少计数,然后他们请求一个数字事实,并且该 effect 的响应触发显示警报,然后关闭警报会导致警报消失。
// Test that tapping on the increment/decrement buttons changes the count
store.send(.incrementButtonTapped) {
$0.count = 1
}
store.send(.decrementButtonTapped) {
$0.count = 0
}
// Test that tapping the fact button causes us to receive a response from the effect. Note
// that we have to advance the scheduler because we used `.receive(on:)` in the reducer.
store.send(.numberFactButtonTapped)
scheduler.advance()
store.receive(.numberFactResponse(.success("0 is a good number Brent"))) {
$0.numberFactAlert = "0 is a good number Brent"
}
// And finally dismiss the alert
store.send(.factAlertDismissed) {
$0.numberFactAlert = nil
}
这就是在 Composable Architecture 中构建和测试功能的基础知识。还有很多更多的事情需要探索,例如组合、模块化、适应性和复杂 effect。示例目录包含一堆项目供您探索,以查看更高级的用法。
Composable Architecture 附带了许多工具来帮助调试。
reducer.debug()
使用调试打印增强了 reducer,该调试打印描述了 reducer 接收的每个动作以及它对状态进行的每次突变。
received action:
AppAction.todoCheckboxTapped(id: UUID(5834811A-83B4-4E5E-BCD3-8A38F6BDCA90))
AppState(
todos: [
Todo(
- isComplete: false,
+ isComplete: true,
description: "Milk",
id: 5834811A-83B4-4E5E-BCD3-8A38F6BDCA90
),
… (2 unchanged)
]
)
reducer.signpost()
使用 signpost 检测 reducer,以便您可以深入了解动作执行所需的时间以及 effect 何时运行。
RxComposableArchitecture 依赖于 RxSwift 框架。它需要 iOS 11 的最低部署目标。
您可以通过将 ComposableArchitecture 添加为包依赖项来将其添加到 Xcode 项目。
将其添加到您的 Podfile 中
pod "RxComposableArchitecture", :git => 'https://github.com/tokopedia/RxComposableArchitecture', :tag => '0.50.3'
由于几个 RxComposableArchitecture
deps 不支持 cocoapods,因此您需要在 Podfile
上手动添加它。
pod 'CasePaths', :podspec => './development-podspecs/CasePaths.podspec.json'
pod 'Clocks', :podspec => './development-podspecs/swift-clocks.podspec.json'
pod 'CombineSchedulers', :podspec => './development-podspecs/combine-schedulers.podspec.json'
pod 'CustomDump', :podspec => './development-podspecs/CustomDump.podspec.json'
pod 'Dependencies', :podspec => './development-podspecs/swift-dependencies.podspec.json'
pod 'XCTestDynamicOverlay', :podspec => './development-podspecs/xctest-dynamic-overlay.podspec.json'
此库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE。