随着 Combine 和 SwiftUI 的最新引入,我们的代码库将面临一些过渡期。我们的应用程序将同时使用 Combine 和第三方响应式框架,或者同时使用 UIKit 和 SwiftUI,这使得在一段时间内保证一致的架构可能变得困难。
Spin 是一个工具,可以在基于 Swift 的应用程序内部构建反馈循环,使您无论底层响应式编程框架是什么,无论您使用什么 Apple UI 技术(RxSwift、ReactiveSwift、Combine 和 UIKit、AppKit、SwiftUI),都可以使用统一的语法。
如果您已经熟悉反馈循环理论,请深入了解 演示应用程序。
摘要
- 变更日志
- 关于状态机
- 关于 Spin
- 构建 Spin 的多种方法
- 创建反馈的多种方法
- 反馈的生命周期
- 反馈和调度
- 在基于 UIKit 或 AppKit 的应用程序中使用 Spin
- 在基于 SwiftUI 的应用程序中使用 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是一个纯函数,它只能根据以前的值和一个过渡请求产生一个新的值。在reducers中执行副作用是禁止的,因为这会损害其可重复性。
在实际应用程序中,一个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),
reducer: Reducer(levelsReducer)) {
Feedback(effect: leftEffect)
Feedback(effect: rightEffect)
}
再次使用Combine,假设效果返回AnyPublishers,使用相同的语法。
let levelsSpin = Spin(initialState: Levels(left: 10, right: 20),
reducer: CombineReducer(levelsReducer)) {
Feedback(effect: leftEffect)
Feedback(effect: rightEffect)
}
启动Spin的方式保持不变。
创建反馈的多种方法
如您所见,根据多个反馈创建了一个“反馈循环/Spin”。反馈是一个围绕副作用函数的包装结构。基本上,副作用具有以下签名(Stream<State>)-> Stream<Event>,Stream是一个响应式流(Observable、SignalProducer或AnyPublisher)。
由于可能无法直接操作流,因此Spin提供了一组辅助构造函数来创建反馈,允许您
- 直接接收状态而不是流(如
Levels
示例中所示) - 通过提供谓词对输入状态进行过滤:
RxFeedback(effect: leftEffect, filteredBy: { $0.left > 0 })
- 通过提供透镜或键路径从状态中提取一个子状态:
RxFeedback(effect: leftEffect, lensingOn: \.left)
请参阅FeedbackDefinition+Default.swift以获取完整信息。
反馈的生命周期
通常,副作用(例如网络调用)可能由异步操作组成。如果同一个副作用被反复调用,而不等待前一个操作结束,会发生什么?操作会被堆叠吗?在执行新操作时会被取消吗?
嗯,这取决于
- .cancelOnNewState,在处理新状态时要取消先前的操作
- .continueOnNewState,在新状态要处理时要允许先前的操作自然结束
明智地选择适合您需求的选项。如果不取消先前操作,reducer没有防止无序事件,可能会导致状态不一致。
反馈与调度
响应式编程通常与异步执行相关联。尽管每个响应式框架都附带自己的GCD抽象,但始终关乎指定副作用应在其上执行的调度器。
Spin提供了一种指定此调度器的方法,您可以通过将其添加到循环中,以尽可能多的声明性的方式
Spinner
.initialState(Levels(left: 10, right: 20))
.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), reducer: Reducer(levelsReducer)) {
Feedback(effect: leftEffect)
.execute(on: SerialDispatchQueueScheduler(qos: .userInitiated))
Feedback(effect: rightEffect)
.execute(on: SerialDispatchQueueScheduler(qos: .userInitiated))
}
当然,您仍然可以在反馈函数内部自己处理调度器。
请注意,reducer在默认调度器上执行以处理如重入或串行方式处理事件等情况。可以通过向创建的Reducer中传递自定义调度器来覆盖此行为。
在UIKit或AppKit应用程序中使用Spin
尽管反馈循环可以独立存在,无需任何可视化,但在我们的开发者世界中更合理地将其用作产生要呈现到屏幕上的状态和处理用户触发的事件的方式。
幸运的是,将状态作为渲染输入并从用户交互返回事件流看起来与反馈(State -> Stream<Event>)的定义非常相似,我们知道如何处理反馈
由于视图是状态的函数,渲染它将改变UI元素的 states。这是一种超出循环本地作用域的变异: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 框架(Spin_RxSwift、Spin_ReactiveSwift 和 Spin_Combine)附带类型别名,以区分其内部类型。
例如,RxFeedback
是 Spin_RxSwift.Feedback
的别名,CombineFeedback
是 Spin_Combine.Feedback
的别名。
使用这些类型别名,在同一个源文件中安全地使用所有 Spin 味道变得可行。
所有演示应用程序都同时使用三个反应式框架。但 高级演示应用程序 最为有趣,因为它在同一个源文件中(用于依赖注入)使用这些框架,并利用提供的类型别名。
演示应用程序
在 Spinners 组织中,您可以找到 2 个演示应用程序,展示如何使用 Spin 与 RxSwift、ReactiveSwift 和 Combine 一起使用。
- 一个基本的计数器应用程序:UIKit 版本 和 SwiftUI 版本
- 一个更高级的“基于网络”的应用程序,使用依赖注入和协调器模式(UIKit):UIKit 版本 和 SwiftUI 版本
警告:看起来 StarWars API 已经不可访问。因此,“高级”演示不再能够显示数据。您仍然可以阅读代码以了解 Spin 的使用方法。将不久实现替换的 API。
安装
Swift 包管理器
将此 URL 添加到您的依赖项
https://github.com/Spinners/Spin.Swift.git
Carthage
在您的 Cartfile 中添加以下条目
github "Spinners/Spin.Swift" ~> 0.16
然后
carthage update Spin.Swift
然后您应该能够导入 SpinCommon(基础实现)、SpinRxSwift、SpinReactiveSwift 或 SpinCombine
致谢
高级演示应用使用 Alamofire 进行网络堆栈,使用 Swinject 进行依赖注入,使用 Reusable(UIKit 版本)进行视图实例化,使用 RxFlow(UIKit 版本)用于协调模式。
以下存储库也是灵感的来源