随着 Combine 和 SwiftUI 的引入,我们的代码库将面临一些过渡期。我们的应用程序将同时使用 Combine 和第三方响应式框架,或者同时使用 UIKit 和 SwiftUI,这可能会导致难以保证长期架构的一致性。
Spin 是一种在基于 Swift 的应用程序中构建反馈循环的工具,允许您使用统一的语法,无论底层的响应式编程框架是什么,以及您使用的 Apple UI 技术是什么(RxSwift、ReactiveSwift、Combine 以及 UIKit、AppKit、SwiftUI)。
如果您已经熟悉反馈循环理论,请深入研究演示应用程序。
摘要
请阅读 CHANGELOG.md 了解演变和重大变更的信息。
什么是状态机?
它是一个抽象的机器,在任何给定时间都只能处于有限数量的状态中的一个。状态机可以响应一些外部输入从一种状态改变为另一种状态。从一种状态到另一种状态的改变称为转换。状态机由其状态列表、其初始状态和每个转换的条件定义。
猜猜看!一个应用程序就是一个状态机。
我们只需要找到合适的工具来实现它。这就是反馈循环发挥作用的地方 👍。
反馈循环是一个能够自我调节的系统,通过使用其计算结果作为下一个输入,根据给定的规则不断调整该值(反馈循环用于电子等领域,例如自动调整信号的电平)。
这样说可能听起来晦涩难懂并且与软件工程无关,但是“根据某些规则调整值”正是程序(以及应用程序)的目的!应用程序是我们想要调节以提供遵循精确规则的一致行为的各种状态的总和。
反馈循环是在应用程序内部托管和管理状态机的完美选择。
Spin 是一种工具,其唯一目的是帮助您构建称为“Spins”的反馈循环。 Spin 基于三个组件:初始状态、多个 feedbacks 和一个 reducer。为了说明每一个,我们将依赖一个基本示例:一个从 0 计数到 10 的“反馈循环 / Spin”。
Feedbacks 是您可以执行副作用的唯一地方(网络、本地 I/O、UI 渲染,任何访问或改变循环本地范围之外状态的操作)。相反,reducer 是一个纯函数,只能给定先前的值和转换请求来产生一个新值。禁止在 reducers 中执行副作用,因为它会损害其可重现性。
在实际应用程序中,您显然可以为每个 Spin 设置多个 feedbacks,以分离关注点。每个反馈将按顺序应用于输入值。
Spin 提供了两种构建反馈循环的方法。两者是等效的,选择哪一个仅取决于您的偏好。
让我们通过构建一个调节两个整数值以使它们收敛到它们的平均值的 Spin 来尝试它们(就像某种系统可以调整立体声扬声器上的左声道和右声道音量以使它们收敛到相同的水平)。
以下示例将依赖于 RxSwift,这里是 ReactiveSwift 和 Combine 对应项;您会看到它们是多么的相似。
我们将需要一个数据类型来表示我们的状态
struct Levels {
let left: Int
let right: Int
}
我们还需要一个数据类型来描述要在 Levels 上执行的转换
enum Event {
case increaseLeft
case decreaseLeft
case increaseRight
case decreaseRight
}
现在我们可以编写两个将对每个级别产生影响的 feedbacks
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 将是(考虑到 effects 返回 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”的语法
let levelsSpin = Spin(initialState: Levels(left: 10, right: 20)) {
Feedback(effect: leftEffect)
Feedback(effect: rightEffect)
Reducer(levelsReducer)
}
同样,使用 Combine,相同的语法,考虑到 effects 返回 AnyPublishers
let levelsSpin = Spin(initialState: Levels(left: 10, right: 20)) {
Feedback(effect: leftEffect)
Feedback(effect: rightEffect)
Reducer(levelsReducer)
}
启动 Spin 的方式保持不变。
正如您所看到的,“反馈循环 / Spin”是从多个 feedbacks 创建的。 Feedback 是一个围绕副作用函数的包装结构。基本上,副作用具有此签名 (Stream<State>) -> Stream<Event>,Stream 是一个响应式流(Observable、SignalProducer 或 AnyPublisher)。
由于可能并不总是容易直接操作 Streams,因此 Spin 提供了一系列反馈的辅助构造函数,允许
RxFeedback(effect: leftEffect, filteredBy: { $0.left > 0 })
RxFeedback(effect: leftEffect, lensingOn: \.left)
请参阅 FeedbackDefinition+Default.swift 以获取完整信息。
在典型情况下,副作用由异步操作组成(例如网络调用)。如果重复调用完全相同的副作用,而不等待先前的副作用结束会发生什么?这些操作是否堆叠?当执行新操作时是否取消它们?
嗯,这取决于 😁。默认情况下,Spin 将取消先前的操作。但是有一种方法可以覆盖此行为。每个将 State 作为参数的 feedback 构造函数也可以传递一个 ExecutionStrategy
明智地选择适合您需求的选项。如果不取消先前的操作,如果 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)
}
当然,仍然可以在反馈函数中自己处理 Schedulers。
正如我们所看到的,Feedback 是副作用的包装器。副作用,根据定义,将需要一些依赖项来执行它们的工作。诸如:网络服务、一些持久化工具、加密实用程序等等。
但是,副作用签名不允许传递依赖项,只能传递状态。我们如何考虑这些依赖项?
以下是三种可能的技术
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)
这种技术的好处是在概念方面非常熟悉,并且可以与应用程序中现有的模式兼容。
它的缺点是迫使我们在副作用中捕获依赖项时要小心。
在之前的技术中,我们仅将 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)
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())
在这三种技术中,它是最不冗长的。它感觉有点像魔法,但只是在底层使用了部分化。
虽然反馈循环可以独立存在而没有任何可视化,但在我们的开发者世界中,将其用作生成将在屏幕上呈现的状态并处理用户发出的事件的方式更有意义。
幸运的是,将 State 作为渲染的输入并从用户交互返回事件流看起来很像 feedback 的定义(State -> Stream<Event>),我们知道如何处理 feedbacks 😁,当然是用 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依赖于状态和视图之间的绑定的概念,并负责渲染,因此连接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 是一个键路径,它指定状态的一个子状态,而 .toggle 是当切换状态改变时要发出的事件。
正如引言中所述,Spin旨在简化应用程序中几个响应式框架之间的共存,以允许更平滑的过渡。因此,你可能需要区分RxSwift Feedback和Combine Feedback,因为它们共享相同的类型名称,即Feedback
。Reducer
、Spin
、UISpin
和SwiftUISpin
也是如此。
Spin框架(SpinRxSwift、SpinReactiveSwift和SpinCombine)带有类型别名,以区分其内部类型。
例如,RxFeedback
是SpinRxSwift.Feedback
的类型别名,CombineFeedback
是SpinCombine.Feedback
的类型别名。
通过使用这些类型别名,可以在同一个源文件中安全地使用所有Spin风格。
所有演示应用程序都同时使用这三个响应式框架。但高级演示应用程序是最有趣的,因为它在同一个源文件中使用这些框架(用于依赖注入),并利用了提供的类型别名。
在某些情况下,两个(或多个)反馈循环必须直接对话,而无需涉及现有的副作用(例如UI)。
一个典型的用例是,当你有一个反馈循环处理应用程序的路由,并在应用程序启动时检查用户的身份验证状态。如果用户已授权,则会显示主屏幕,否则会显示登录屏幕。可以肯定的是,一旦获得授权,用户将使用从后端获取数据的功能,这可能导致授权问题。在这种情况下,你希望驱动这些功能的循环与路由循环通信,以触发新的授权状态检查。
在设计模式中,这种需求可以通过中介者模式来满足。中介者是一个横向对象,用作独立系统之间的通信总线。
在Spin中,中介者的等效物被称为Gear。一个Gear可以连接到多个反馈,允许它们推送和接收事件。
如何将反馈连接到Gear,使其能够从Gear推送/接收事件?
首先,必须创建一个Gear:
// A Gear has its own event type:
enum GearEvent {
case authorizationIssueHappened
}
let gear = Gear<GearEvent>()
我们必须告诉检查授权Spin中的一个反馈如何对Gear中发生的事件做出反应:
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
...
最后,我们必须告诉feature Spin中的一个反馈如何在Gear中推送事件:
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
...
这就是当feature spin处于.unauthorized状态时会发生的事情:
FeatureSpin: state = .unauthorized
↓
Gear: propagate event = .authorizationIssueHappened
↓
AuthorizationSpin: event = .checkAuthorization
↓
AuthorizationSpin: state = authorized/unauthorized
当然,在这种情况下,Gear必须在两个Spin之间共享。根据你的用例,你可能需要将其设为单例。
在Spinners组织中,你可以找到2个演示应用程序,展示了如何使用Spin与RxSwift、ReactiveSwift和Combine。
将此URL添加到你的依赖项:
https://github.com/Spinners/Spin.Swift.git
将以下条目添加到你的Cartfile:
github "Spinners/Spin.Swift" ~> 0.20.0
然后:
carthage update Spin.Swift
将以下依赖项添加到你的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版本)。
以下仓库也是灵感的来源: