随着 Combine 和 SwiftUI 的引入,我们的代码库将面临一些过渡期。我们的应用程序将同时使用 Combine 和第三方响应式框架,或者同时使用 UIKit 和 SwiftUI,这可能会在一段时间内保证一致架构变得困难。
Spin 是一个工具,用于在基于 Swift 的应用程序中构建反馈循环,允许你在任何底层响应式编程框架和任何 Apple UI 技术中(RxSwift、ReactiveSwift、Combine 和 UIKit、AppKit、SwiftUI)使用统一的语法。
如果您已经熟悉反馈循环理论,请深入了解演示应用程序。
摘要
- 变更日志
- 关于状态机
- 关于 Spin
- 构建 Spin 的多种方式
- 创建反馈的多种方式
- 反馈生命周期
- 反馈和调度
- 反馈中的依赖关系是什么
- 在基于 UIKit 或 AppKit 的应用程序中使用 Spin
- 在基于 SwiftUI 的应用程序中使用 Spin
- 与多个响应式框架一起使用 Spin
- 如何让 spins 互相交谈
- 演示应用程序
- 安装
- 致谢
变更日志
请阅读CHANGELOG.md 以获取有关演化和 Breaking Changes 的信息。
关于状态机
什么是状态机?
它是一个抽象机器,在任何给定时间可以处于有限多个状态中的一个。状态机可以响应某些外部输入从一种状态切换到另一种状态。从一种状态到另一种状态的转换称为转移。状态机由其状态列表、初始状态以及每个转移的条件定义。
猜猜看!应用程序就是一个状态机。
我们只需要找到正确的工具来实现它。这就是反馈循环发挥作用的地方。
反馈循环是一个系统能通过将计算结果作为下一次输入返回给自身来自我调节,并不断根据给定的规则调整此值(例如,在电子领域,反馈循环用于自动调整信号的级别)。
虽然这样描述听起来可能有些晦涩,与软件工程不相关,但是“根据某些规则调整值”正是程序乃至应用程序的目的!应用程序是我们想要调节的所有状态的总和,以确保按照精确的规则提供一致的行为。
反馈循环是内部放置和管理的状态机的完美候选人。
关于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
}
我们还需要一个数据类型来描述在Levels上要执行的状态转换
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将是(假设effect返回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,考虑到效果返回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)。
由于可能无法直接操作Stream,Spin提供了一系列反馈的辅助构造函数,允许
- 直接接收State而不是Stream(如使用
Levels
的示例中所示) - 通过提供一个谓词来过滤输入State:
RxFeedback(effect: leftEffect, filteredBy: { $0.left > 0 })
- 通过提供一个透镜或keypath从State中提取子状态:
RxFeedback(effect: leftEffect, lensingOn: \.left)
请参阅FeedbackDefinition+Default.swift以了解完整性。
反馈生命周期
存在一些典型情况,其中副作用包括异步操作(如网络调用)。如果对同一个副作用持续调用,而不等待前一个操作结束,会发生什么?操作是否会堆叠?当执行新操作时会取消吗?
嗯,这取决于情况
- .cancelOnNewState,当处理新状态时要取消前一个操作
- .continueOnNewState,当处理新状态时允许前一个操作自然结束
请明智地选择适合您需求的选项。如果不取消前一个操作,并且 reducer 没有保护不受无序事件影响,则可能导致状态的任何一致性。
反馈与调度
响应式编程通常与异步执行相关联。尽管每个响应式框架都提供了自己的 GCD 抽象,但始终在于指定副作用应在哪个调度程序上执行。
默认情况下,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())
在这三种技术中,它是最简洁的一个。它感觉有点像魔法,但实际上只是使用了部分化。
在 UIKit 或 AppKit 基础的 app 中使用 Spin
尽管反馈循环可以独立存在,无需任何可视化,但在我们的开发者世界中,将其用作生成要在屏幕上渲染的 State 以及处理用户发射的事件的方式更有意义。
幸运的是,使用 State 作为输入进行渲染,并从用户交互中返回事件流,看起来非常像反馈(State -> Stream<Event>)的定义,我们知道如何处理反馈(Spin)。
由于视图是 State 的函数,渲染它将改变 UI 元素的状态。这是一个超出循环本地作用域的突变: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
}
}
我们需要将“业务”Spin 装饰为 ViewController 的 UISpin 实例变量,这样它们的生命周期就绑定在一起
// 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 依赖于 State 和 View 之间的绑定以及处理渲染,因此连接 SwiftUI Spin 的方式略有不同,并且更加简单。
在你的视图中,你需要用“@ObservedObject”(SwiftUISpin 是“ObservableObject”)注释 SwiftUI Spin 变量
@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 版本。
所有演示应用程序都同时使用了三个响应式框架。但最有趣的要数 高级演示应用程序,因为它在同一源文件中(用于依赖注入)使用了这些框架并利用了提供的类型别名。
如何让自旋相互交流
在某些用例中,两个(或更多)反馈循环需要直接相互沟通,而不涉及现有副作用(例如UI)。
一个典型的用例是,当你有一个在应用启动时处理应用路由并检查用户认证状态的反馈循环。如果用户获得授权,则显示主页,否则显示登录界面。当然,一旦授权,用户将使用从后端获取数据的功能,这可能导致授权问题。在这种情况下,你希望驱动这些功能的自旋循环与路由自旋循环通信,以触发布新授权状态检查。
在设计模式中,这种需求通过调解器来实现。这是一个贯穿对象,用作独立系统之间的通信总线。
在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状态时会发生的情况
功能自旋:状态 = .unauthorized
↓
齿轮:传播事件 = .authorizationIssueHappened
↓
授权自旋:事件 = .checkAuthorization
↓
授权自旋:状态 = authorized/unauthorized
当然,在这种情况下,两个自旋必须共享该齿轮。你可能需要根据你的用例将其制作为一个单例。
演示应用程序
在Spinners组织中,您可以找到2个演示应用程序,演示了Spin与RxSwift、ReactiveSwift和Combine的使用方法。
- 一个基本的计数器应用程序:《a href="https://github.com/Spinners/Spin.UIKit.Demo.Basic" target="_blank" rel="noopener noreferrer">UIKit版本SwiftUI版本
- 一个更复杂“基于网络”的应用程序,使用依赖注入和协调器模式(UIKit):《a href="https://github.com/Spinners/Spin.UIKit.Demo" target="_blank" rel="noopener noreferrer">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 版本)。
以下仓库也是灵感的来源