SpinCombine 0.21.0

SpinCombine 0.21.0

Thibault Wittemberg 维护。




Swift Package Manager compatible Carthage Compatible CocoaPods Compatible platforms codecov

Spin Logo

随着 Combine 和 SwiftUI 的引入,我们的代码库将面临一些过渡期。我们的应用程序将同时使用 Combine 和第三方响应式框架,或者同时使用 UIKit 和 SwiftUI,这使得在一段时间内确保一致性的架构变得可能很困难。

Spin 是一个工具,用于在基于 Swift 的应用程序内构建反馈循环,允许您无论底层响应式编程框架是什么,以及您使用的是哪种 Apple UI 技术都能使用统一的语法(RxSwift、ReactiveSwift、Combine 和 UIKit、AppKit、SwiftUI)。

如果您已经对反馈循环理论感到舒适,请深入了解 示例应用程序

摘要

变更日志

请阅读 CHANGELOG.md 以获取有关发展和重大更改的信息。

关于状态机

什么是状态机?

它是一个抽象的机器,在任何时候都可以处于有限数量的状态之一。状态机可以响应某些外部输入而从一种状态转换为另一种状态。从一种状态到另一种状态的变化称为转换。状态机由其状态列表、初始状态以及每个转换的条件定义。

你知道什么!应用程序本身就是一个状态机。

我们只需找到合适的工具来实现它。这就是反馈循环发挥作用的地方。👍.

反馈循环是一种系统,能通过使用自己计算结果作为下一个输入来自我调节,不断根据给定规则调整这个值(例如,在电子领域,反馈循环用于自动调整信号电平)。

Feedback Loop

这样表述可能听起来有些晦涩,与软件工程无关,但“根据某些规则调整值”正是程序和由此扩展的应用所为之事!一个应用是我们想要调节的所有状态的总和,以提供符合精确规则的稳定行为。

反馈循环是应用内部管理和容纳状态机的完美选择。

关于Spin

Spin是一个工具,其唯一目的是帮助你构建称为“Spins”的反馈循环。一个Spin基于三个组件:一个初始状态,几个反馈和 一个reducer。为了说明每一个,我们将依赖一个基本示例:一个从0计数到10的“反馈循环/Spin”。

  • 初始状态:这是我们的计数器的起始值,0。
  • 一个反馈:这是我们用于计数器的规则,以实现我们的目标。如果0 <= counter < 10,则要求增加计数器,否则请求停止。
  • 一个reducer:这是我们的Spin的状态机。它描述了根据计数器的先前值和反馈计算出的请求的所有可能转换。例如:如果上一个值是0,请求增加,则新值是1,如果上一个值是1,请求增加,则新值是2,依此类推。当反馈请求停止时,返回上一个值作为新值。

Feedback Loop

反馈是唯一可以进行副作用(网络、本地I/O、UI渲染等)的地方,这些副作用访问或修改了循环局部作用域之外的状态。相反,reducer是一个纯函数,只能根据前一个值和一个转换请求来生成新值。在reducer中执行副作用是禁止的,因为这会破坏它的可再现性。

在现实生活中的应用中,显然可以有一个Spin中的多个反馈,以分离关注点。每个反馈都将按顺序应用于输入值。

构建Spin的多种方式

Spin提供两种构建反馈循环的方法。两者是等效的,选择哪一种仅取决于你的偏好。

让我们通过构建一个将两个整数值调节到其平均值(类似于某种调整立体声扬声器的左右通道音量使其达到相同水平)的Spin来尝试它们。

以下示例将依赖于 RxSwift,这里提供了 ReactiveSwiftCombine 的对应功能;您将看到它们之间是多么相似。

我们需要一个数据类型来表示我们的状态

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)
    }
}

最后,为了描述控制转换的状态机器,我们需要一个还原器

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 的语法

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 的方式保持不变。

创建 Feedback 的多种方法

正如您所看到的,将“反馈环路/Spin”从多个反馈中创建。反馈是一个围绕副作用函数的包装结构。基本上,副作用具有这种签名(Stream<State>) -> Stream<Event>,Stream 代表一个反应流(Observable,SignalProducer 或 AnyPublisher)。

由于直接操作流可能并不总是那么容易,所以 Spin 提供了一组反馈的辅助构造函数,允许您

  • 直接接收 State 而不是 Stream(如在 Levels 中的示例所示)
  • 通过提供谓词对输入状态进行过滤: RxFeedback(effect: leftEffect, filteredBy: { $0.left > 0 })
  • 通过提供透镜或路径键提取状态的一部分: RxFeedback(effect: leftEffect, lensingOn: \.left)

请参阅 FeedbackDefinition+Default.swift 以了解更多信息。

反馈生命周期

存在一些典型情况,其中副作用包括异步操作(例如网络调用)。如果在未等待之前操作完成的情况下反复调用相同副作用会发生什么?操作是堆积吗?当执行新的操作时,它们被取消了吗?

这个嘛,要看情况了😁默认情况下,Spin将会取消之前的操作。但是可以通过一种方法来覆盖这种行为。每个接受状态作为参数的反馈构建器也可以传递一个执行策略

  • 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())

在这三种技术中,这种方法最为简洁。它可能感觉有点像魔法,但只是简单地使用了部分化技术。

在基于 UIKit 或 AppKit 的应用中使用 Spin

尽管反馈循环可以独立于任何可视化而存在,但在这个开发者世界中,将它用作产生屏幕上渲染的态和响应用户事件的方式更为合理。

幸运的是,将态作为渲染的输入并从用户交互返回事件流看起来非常像反馈(State -> Stream<事件>)的定义,我们知道如何处理反馈,当然,使用 Spin。😁

由于视图是状态的函数,渲染它将改变 UI 元素的状态。这是一个超出循环本地作用域的突变:UI 实际上是一个副作用。我们只需要找到一种适当的方法将其结合到 Spin 的定义中。

一旦构建了一个 Spin,我们可以在 UI 渲染/交互方面用新的反馈“装饰”它。存在一种特殊的 Spin 来执行这种装饰:UISpin。

作为一个整体图景,我们可以用这张图在 UI 的背景下展示一个反馈循环。

Feedback Loop

在一个 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 依赖于 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 是一个表示状态的子状态的关键路径,而 .toggle 是当切换被更改时发出的事件。

与多个响应式框架一起使用 Spin

正如引言中所述,Spin 目标简化应用程序内部多个响应式框架的共存,以实现更平滑的过渡。因此,您可能需要区分 RxSwift Feedback 和 Combine Feedback,因为它们共享相同的类型名称,即 Feedback。对于 ReducerSpinUISpinSwiftUISpin 也是如此。

Spin 框架(SpinRxSwift、SpinReactiveSwift 和 SpinCombine)包含类型别名来区分其内部类型。

例如,RxFeedbackSpinRxSwift.Feedback 的类型别名,CombineFeedbackSpinCombine.Feedback 的类型别名。

使用这些类型别名,在同一个源文件中使用所有的 Spin 变种是安全的。

所有示例应用程序都同时在三个响应式框架中使用了它们。但高级演示应用程序最有意思,因为它在同一个源文件中(为了依赖注入)使用了这些框架,并利用了提供的类型别名。

如何使旋转 spole 互相交流

在某些情况下,两个(或更多)反馈循环需要直接交流,而不涉及现有的副作用(例如UI)。

典型的用例是当你有一个处理应用程序路由并检查应用程序启动时用户的身份验证状态的反馈循环时。如果用户授权,则显示主页,否则显示登录页。当然,一旦授权,用户将使用从后端获取数据的特征,这可能导致授权问题。在这种情况下,你会希望驱动这些功能的循环与路由循环进行通信,以触发新的授权状态检查。

在设计模式中,这种需求通过中介者得到满足。这是一个跨地区的对象,用作独立系统之间的通信总线。

在Spin中,中介者的对应物称为齿轮。齿轮可以附加到多个反馈上,允许它们推动和接收事件。

Gear

如何将反馈附加到齿轮以便它可以从它那里推动/接收事件?

首先,必须创建一个齿轮。

// A Gear has its own event type:
enum GearEvent {
    case authorizationIssueHappened
}

let gear = Gear<GearEvent>()

我们必须告诉对授权进行检查的反馈齿轮如何对齿轮中发生的事件做出反应。

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
...

最后,我们必须告诉功能反馈如何推动齿轮中的事件。

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
...

这就是功能旋涡处于未授权状态时会发生的事情。

FeatureSpin:状态=.unauthorized

齿轮:传播事件=.authorizationIssueHappened

身份验证Spin:事件=.checkAuthorization

身份验证Spin:状态=authorized/unauthorized

当然,在这种情况下,齿轮必须在这两个Spins之间共享。你可能必须根据你的用例将其制作为一个单例。

演示应用

在Spinners组织中,您可以找到2个演示应用程序,演示了使用Spin结合RxSwift,ReactiveSwift和Combine的使用方法。

安装

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 版本)。

以下回购也是灵感来源