随着 Combine 和 SwiftUI 的引入,我们的代码库将面临一些过渡期。我们的应用程序将同时使用 Combine 和第三方响应式框架,或者同时使用 UIKit 和 SwiftUI,这可能会使得在一段时间内保证一致架构变得困难。
Spin 是一个工具,用于在基于 Swift 的应用程序中构建反馈循环,它允许您使用统一语法,无论底层响应式编程框架是什么,或者您使用的是哪种 Apple UI 技术(RxSwift、ReactiveSwift、Combine、UIKit、AppKit、SwiftUI)。
如果您已经熟悉反馈循环理论,请深入了解演示应用程序。
摘要
- 变更日志
- 关于状态机
- 关于 Spin
- 构建 Spin 的多种方式
- 创建反馈的多种方式
- 反馈生命周期
- 反馈和调度
- 关于反馈中的依赖关系
- 在 UIKit 或 AppKit 应用中使用 Spin
- 在 SwiftUI 应用中使用 Spin
- 与多个响应式框架一起使用 Spin
- 如何让多个 Spin 交互
- 演示应用程序
- 安装
- 鸣谢
变更日志
请阅读CHANGELOG.md 以了解功能和重大更改。
关于状态机
什么是状态机?
它是一个抽象的机器,在任意时刻只能处于有限数量的状态中的一个。状态机可以根据外部输入从一种状态转变为另一种状态。从一个状态到另一个状态的转换称为转换。状态机由其状态列表、初始状态和每个转换的条件定义。
你知道吗!一个应用程序就是一个状态机。
我们只需要找到实现它的正确工具。这就是反馈循环发挥作用的地方。
反馈循环是一种能够通过利用其计算结果作为下一个输入来自我调节的系统,根据给定的规则不断调整这个值(反馈循环在电子等领域用于自动调节信号水平,例如)。
这样说来可能听起来有些晦涩,与软件工程无关,但“根据某些规则调整值”正是程序,以及由此拓展的应用,存在的目的!应用程序是我们希望通过精确规则来实现一致行为的各种状态的累积。
反馈循环是托管和管理应用程序内部状态机的完美候选者。
关于Spin
Spin是一款只用于帮助您构建称为“Spins”的反馈循环的工具。Spin基于三个组件:初始状态、若干反馈和reducer。为了说明每个组件,我们将使用一个基本示例:一个计数从0到10的“反馈循环/Spin”。
- 初始状态:这是计数器的起始值,0。
- 反馈:这是应用给计数器的规则,以实现我们的目的。如果0 <= 计数器 < 10,则请求增加计数器,否则请求停止它。
- reducer:这是我们的Spin的状态机。它描述了给定计数器的先前值和反馈计算出的请求的所有可能的转换。例如:如果之前的值是0且请求为增加它,则新的值为1,如果之前是1且请求为增加,则新的值为2,依此类推。当反馈的请求是停止时,则返回先前值作为新的值。
反馈是您唯一可以进行副作用(网络、局部I/O、UI渲染等)的地方(访问或改变循环的局部作用域以外的状态)。相反,reducer是一个纯函数,只能根据前一个值和转换请求产生一个新值。在reducer中执行副作用是被禁止的,因为这会损害其可重复性。
在实际应用中,您显然可以在每个Spin中拥有多个反馈来分离关注点。每个反馈将对输入值按顺序应用。
构建Spin的多种方式
Spin提供了两种构建反馈循环的方式。两种方式是等价的,选择哪一种取决于您的偏好。
让我们通过构建一个使两个整数值趋近于其平均值的Spin来尝试它们(就像一种调整立体声扬声器左右声道音量以使其音量相同的系统)。
以下示例将基于RxSwift,这里提供了ReactiveSwift和Combine的对等部分;您将看到它们的相似之处。
我们需要一个数据类型来表示我们的状态
struct Levels {
let left: Int
let right: Int
}
我们还需要一个数据类型来描述在级别上要执行的状态转换
enum Event {
case increaseLeft
case decreaseLeft
case increaseRight
case decreaseRight
}
现在我们可以编写两个将对每个级别产生影响的反馈
func leftEffect(inputLevels: Levels) -> Observable<Event> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return .empty() }
// this is the regulation for the left level
if inputLevels.left < inputLevels.right {
return .just(.increaseLeft)
} else {
return .just(.decreaseLeft)
}
}
func rightEffect(inputLevels: Levels) -> Observable<Event> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return .empty() }
// this is the regulation for the right level
if inputLevels.right < inputLevels.left {
return .just(.increaseRight)
} else {
return .just(.decreaseRight)
}
}
最后,要描述控制转换的状态机,我们需要一个reducer
func levelsReducer(currentLevels: Levels, event: Event) -> Levels {
guard currentLevels.left != currentLevels.right else { return currentLevels }
switch event {
case .decreaseLeft:
return Levels(left: currentLevels.left-1, right: currentLevels.right)
case .increaseLeft:
return Levels(left: currentLevels.left+1, right: currentLevels.right)
case .decreaseRight:
return Levels(left: currentLevels.left, right: currentLevels.right-1)
case .increaseRight:
return Levels(left: currentLevels.left, right: currentLevels.right+1)
}
}
构建方式
在这种情况下,“Spinner” 类是你的人口点。
let levelsSpin = Spinner
.initialState(Levels(left: 10, right: 20))
.feedback(Feedback(effect: leftEffect))
.feedback(Feedback(effect: rightEffect))
.reducer(Reducer(levelsReducer))
就是这些。反馈循环已经建立。接下来呢?
如果你想启动它,那么你必须订阅到其底层的响应式流。为此,在 Observable 中新增了一个操作符“.stream(from:)”,用于连接东西并提供可以订阅的Observable
Observable
.stream(from: levelsSpin)
.subscribe()
.disposed(by: self.disposeBag)
有一个快捷函数可以直接订阅到底层的流
Observable
.start(spin: levelsSpin)
.disposed(by: self.disposeBag)
例如,使用 Combine 的相同 Spin 是这样的(假设效果返回 AnyPublishers)
let levelsSpin = Spinner
.initialState(Levels(left: 10, right: 20))
.feedback(Feedback(effect: leftEffect))
.feedback(Feedback(effect: rightEffect))
.reducer(Reducer(levelsReducer))
AnyPublisher
.stream(from: levelsSpin)
.sink(receiveCompletion: { _ in }, receiveValue: { _ in })
.store(in: &cancellables)
or
AnyPublisher
.start(spin: levelsSpin)
.store(in: &cancellables)
声明方式
在这种情况下,我们使用了 Swift 5.1 功能构建器的“DSL like”语法
let levelsSpin = Spin(initialState: Levels(left: 10, right: 20)) {
Feedback(effect: leftEffect)
Feedback(effect: rightEffect)
Reducer(levelsReducer)
}
再次使用 Combine,相同的语法,假设效果返回 AnyPublishers
let levelsSpin = Spin(initialState: Levels(left: 10, right: 20)) {
Feedback(effect: leftEffect)
Feedback(effect: rightEffect)
Reducer(levelsReducer)
}
启动 Spin 的方式保持不变。
创建反馈的多种方式
如您所见,一个“反馈环/Spin”是由多个反馈创建的。反馈是一个围绕副作用函数的包装结构。基本上,副作用有这个签名 (Stream<State>) -> Stream<Event>,Stream 是响应式流(Observable、SignalProducer 或 AnyPublisher)。
由于直接操作流可能并不总是容易,Spin 提供了一组辅助构造函数用于反馈,允许
- 直接接收 State 而不是 Stream(如在
Levels
的例子中) - 通过提供谓词来过滤输入 State:
RxFeedback(effect: leftEffect, filteredBy: { $0.left > 0 })
- 通过提供透镜或键路径从 State 中提取子状态:
RxFeedback(effect: leftEffect, lensingOn: \.left)
请参阅 FeedbackDefinition+Default.swift 以获取完整内容。
反馈生命周期
存在一些典型场景,副作用由异步操作(如网络调用)组成。如果重复调用相同的副作用,而不等待先前的操作完成,会发生什么?操作会被堆叠吗?当执行新操作时,它们会被取消吗?
嗯,这取决于
- .cancelOnNewState,在处理新状态时取消前一个操作
- .continueOnNewState,在处理新状态时允许前一个操作自然结束
明智地选择适合您需求的选项。如果不取消前一个操作,可能会因为reducer没有保护无序事件而导致状态不一致。
反馈与调度
响应式编程通常与异步执行关联。尽管每个响应式框架都有自己的GCD抽象,但始终是要声明副作用应在哪个调度器上执行。
默认情况下,Spin将在一个由框架创建的后台线程上执行。
然而,Spin提供了一种指定Spin本身和您添加到其中的每个反馈的调度器的方法
Spinner
.initialState(Levels(left: 10, right: 20), executeOn: MainScheduler.instance)
.feedback(Feedback(effect: leftEffect, on: SerialDispatchQueueScheduler(qos: .userInitiated)))
.feedback(Feedback(effect: rightEffect, on: SerialDispatchQueueScheduler(qos: .userInitiated)))
.reducer(Reducer(levelsReducer))
或
Spin(initialState: Levels(left: 10, right: 20), executeOn: MainScheduler.instance) {
Feedback(effect: leftEffect)
.execute(on: SerialDispatchQueueScheduler(qos: .userInitiated))
Feedback(effect: rightEffect)
.execute(on: SerialDispatchQueueScheduler(qos: .userInitiated))
Reducer(levelsReducer)
}
当然,仍然可以在反馈函数内部自行处理调度器。
关于反馈中的依赖项
如我们所见,反馈是对副作用的包装。副作用,按定义,将需要一些依赖项来完成其工作。例如:网络服务、一些持久化工具、加密实用程序等。
然而,副作用签名不允许传递依赖项,只允许传递状态。我们如何考虑这些依赖项?
这里有三种可能的技术
1: 使用容器类型
class MyUseCase {
private let networkService: NetworkService
private let cryptographicTool: CryptographicTool
init(networkService: NetworkService, cryptographicTool: CryptographicTool) {
self.networkService = networkService
self.cryptographicTool = cryptographicTool
}
func load(state: MyState) -> AnyPublisher<MyEvent, Never> {
guard state == .loading else return { Empty().eraseToAnyPublisher() }
// use the deps here
self.networkService
.fetch()
.map { [cryptographicTool] in cryptographicTool.decrypt($0) }
...
}
}
// then we can build a Feedback with this UseCase
let myUseCase = MyUseCase(networkService: MyNetworkService(), cryptographicTool: MyCryptographicTool())
let feedback = Feedback(effect: myUseCase.load)
这种技术从概念上讲非常熟悉,并且可以与您应用程序中现有的模式兼容。
它的缺点是强迫我们在副作用中捕获依赖时必须小心。
2: 使用反馈工厂函数
在之前的技巧中,我们只将MyUseCase用作依赖项的容器。除此之外没有其他用途。我们可以通过使用一个函数(全局或静态)来去除它,这个函数将接收我们的依赖项并帮助在副作用中捕获它们
typealias LoadEffect: (MyState) -> AnyPublisher<MyEvent, Never>
func makeLoadEffect(networkService: NetworkService, cryptographicTool: CryptographicTool) -> LoadEffect {
return { state in
guard state == .loading else return { Empty().eraseToAnyPublisher() }
networkService
.fetch()
.map { cryptographicTool.decrypt($0) }
...
}
}
// then we can build a Feedback using this factory function
let effect = makeLoadEffect(networkService: MyNetworkService(), cryptographicTool: MyCryptographicTool())
let feedback = Feedback(effect: effect)
3: 使用内置的Feedback初始化器
Spin提供了一些Feedback初始化器,这些初始化器可以简化依赖项的注入。在底层,它使用了一个由上面提到的一种技术派生出来的通用技术。
func loadEffect(networkService: NetworkService,
cryptographicTool: CryptographicTool,
state: MyState) -> AnyPublisher<MyEvent, Never> {
guard state == .loading else return { Empty().eraseToAnyPublisher() }
networkService
.fetch()
.map
}
// then we can build a Feedback directly using the appropriate initializer
let feedback = Feedback(effect: effect, dep1: MyNetworkService(), dep2: MyCryptographicTool())
在这3种技术中,这是最简洁的一种。它有点像魔术,但只是在底层使用偏函数。
在基于UIKit或AppKit的应用中使用Spin
尽管反馈循环可以独立存在而不需要任何可视化,但在我们的开发者世界中,更合理的方式是将其用作产生将要渲染到屏幕上的状态和处理用户发出的事件的手段。
幸运的是,将状态作为渲染的输入,并从用户交互中返回事件流,看起来非常类似于反馈(State -> Stream<event>)的定义,我们知道如何处理反馈,当然,使用Spin。
由于视图是状态的函数,渲染它将改变UI元素的状态。这是一种超出循环局部作用域的突变:UI确实是副作用。我们只需要一种适当的方式来将其融入Spin的定义中。
一旦构建了Spin,我们就可以用一个新的、专门用于UI渲染/交互的反馈来“装饰”它。有一种特殊类型的Spin用于执行这种装饰:UISpin。
作为一个全局的视图,我们可以用这张图来展示一个反馈循环在UI上下文中的样子
在ViewController中,假设你有一个渲染函数
func render(state: State) {
switch state {
case .increasing(let value):
self.counterLabel.text = "\(value)"
self.counterLabel.textColor = .green
case .decreasing(let value):
self.counterLabel.text = "\(value)"
self.counterLabel.textColor = .red
}
}
我们需要用ViewController的UISpin实例变量装饰“业务”Spin,以确保它们的生命周期绑定在一起
// previously defined or injected: counterSpin is the Spin that handles our counter business
self.uiSpin = UISpin(spin: counterSpin)
// self.uiSpin is now able to handle UI side effects
// we now want to attach the UI Spin to the rendering function of the ViewController:
self.uiSpin.render(on: self, using: { $0.render(state:) })
然后视图准备好(比如在“viewDidLoad”函数中)就开始循环
Observable
.start(spin: self.uiSpin)
.disposed(by: self.disposeBag)
或简短的版本
self.uiSpin.start()
// the underlying reactive stream will be disposed once the uiSpin will be deinit
在循环中发送事件非常直接;只需使用emit函数
self.uiSpin.emit(Event.startCounter)
在基于 SwiftUI 的应用中使用 Spin
由于 SwiftUI 依赖于在状态和视图之间的绑定概念,并负责渲染,因此连接 SwiftUI Spin 的方法略有不同,甚至更加简单。
在你的视图中,您必须使用 “@ObservedObject” 注释 SwiftUI Spin 变量(一个 SwiftUISpin 是一个 “ObservableObject”)
@ObservedObject
private var uiSpin: SwiftUISpin<State, Event> = {
// previously defined or injected: counterSpin is the Spin that handles our counter business
let spin = SwiftUISpin(spin: counterSpin)
spin.start()
return spin
}()
然后您可以使用视图中 “uiSpin.state” 属性以显示数据,以及使用 uiSpin.emit() 发送事件
Button(action: {
self.uiSpin.emit(Event.startCounter)
}) {
Text("\(self.uiSpin.state.isCounterPaused ? "Start": "Stop")")
}
SwiftUISpin 还可用于生成 SwiftUI 绑定
Toggle(isOn: self.uiSpin.binding(for: \.isPaused, event: .toggle) {
Text("toggle")
}
\.isPaused 是一个表示状态子状态的 keypath,而 .toggle 是当切换变化时要发出的事件。
在多个响应式框架中使用 Spin
如引言中所述,Spin 旨在简化多个响应式框架在你的应用中的共存,以便实现更平顺的过渡。因此,你可能需要区分 RxSwift Feedback 和 Combine Feedback,因为它们具有相同的类型名称,即 Feedback
。对于 Reducer
、Spin
、UISpin
和 SwiftUISpin
也是如此。
Spin 框架(SpinRxSwift、SpinReactiveSwift 和 SpinCombine)都包含类型别名来区分它们的内部类型。
例如,RxFeedback
是 SpinRxSwift.Feedback
的别名,CombineFeedback
是 SpinCombine.Feedback
的别名。
通过使用这些类型别名,可以在同一源文件中安全地使用所有 Spin 风味。
所有演示应用都同时使用三个响应式框架。但 高级演示应用 最为有趣,因为它在同一个源文件中使用了这些框架(用于依赖注入)并利用了提供的类型别名。
如何使各种 Spin 互相通信
有些用例中,两个(或更多)反馈循环必须直接交谈,而不涉及现有的副作用(例如 UI)。
一个典型的用例是您有一个处理应用程序路由并检查用户认证状态的反馈循环。当应用程序启动时,如果用户已授权,则显示主屏幕,否则显示登录屏幕。当然,一旦授权,用户将使用从后端获取数据的特性,这可能导致授权问题。在这种情况下,您希望驱动这些特性的循环与路由循环通信,以触发新的授权状态检查。
在设计模式中,这种需求通过中介者(Mediator)来满足。这是一个跨系统用作通信总线的横向对象。
在 Spin 中,中介者的等价物称为 齿轮。一个齿轮可以附加到几个反馈上,允许它们推送和接收事件。
如何将反馈附加到齿轮以使其能够从它推送/接收事件?
首先,必须创建一个齿轮
// A Gear has its own event type:
enum GearEvent {
case authorizationIssueHappened
}
let gear = Gear<GearEvent>()
我们必须告诉从检查授权的 Spin 的反馈如何对齿轮中发生的事件做出反应
let feedback = Feedback<State, Event>(attachedTo: gear, propagating: { (event: GearEvent) in
if event == .authorizationIssueHappened {
// the feedback will emit an event only in case of .authorizationIssueHappened
return .checkAuthorization
}
return nil
})
// or with the short syntax
let feedback = Feedback<State, Event>(attachedTo: gear, catching: .authorizationIssueHappened, emitting: .checkAuthorization)
...
// then, create the Check Authorization Spin with this feedback
...
最后,我们必须告诉特性 Spin 如何在齿轮中推送事件
let feedback = Feedback<State, Event>(attachedTo: gear, propagating: { (state: State) in
if state == .unauthorized {
// only the .unauthorized state should trigger en event in the Gear
return .authorizationIssueHappened
}
return nil
})
// or with the short syntax
let feedback = Feedback<State, Event>(attachedTo: gear, catching: .unauthorized, propagating: .authorizationIssueHappened)
...
// then, create the Feature Spin with this feedback
...
当特性齿轮处于 .unauthorized 状态时将发生以下情况
FeatureSpin: 状态 = .unauthorized
↓
齿轮: 传播事件 = .authorizationIssueHappened
↓
AuthorizationSpin: 事件 = .checkAuthorization
↓
AuthorizationSpin: 状态 = authorized/unauthorized
当然,在这种情况下,必须在这两个 Spin 之间共享齿轮。您可能需要根据用例将其制作成单一实例。
演示应用程序
在 Spinners 组织中,您可以找到 2 个演示应用程序,演示了使用 RxSwift、ReactiveSwift 和 Combine 与 Spin 的使用方法。
- 一个基本的计数器应用程序:UIKit 版本 和 SwiftUI 版本
- 一个更高级的“基于网络”的应用程序,使用依赖注入和协调器模式(UIKit):UIKit 版本 和 SwiftUI 版本
安装
Swift 包管理器
将此 URL 添加到您的依赖项
https://github.com/Spinners/Spin.Swift.git
Carthage
将以下条目添加到你的 Cartfile 中
github "Spinners/Spin.Swift" ~> 0.20.0
然后
carthage update Spin.Swift
CocoaPods
将以下依赖项添加到你的 Podfile 中
pod 'SpinReactiveSwift', '~> 0.20.0'
pod 'SpinCombine', '~> 0.20.0'
pod 'SpinRxSwift', '~> 0.20.0'
然后你应该能够导入 SpinCommon(基本实现)、SpinRxSwift、SpinReactiveSwift 或 SpinCombine
致谢
高级演示应用程序使用 Alamofire 进行网络堆栈,Swinject 进行依赖注入,Reusable(UIKit 版本)进行视图实例化,以及 RxFlow(UIKit 版本)的协调模式。
以下仓库也是灵感的来源