响应式编程
维基百科对响应式编程的定义是:
在计算中,响应式编程是一种面向数据流和变化传播的声明式编程范式。
虽然定义中每个字都认识,但连起来却十分费解。我们可以把定义中的内容分开来理解,逐个击破。首先,让我们来看下声明式编程。
声明式和指令式编程是常见的编程范式。在指令式编程中,开发者通过组合运算、循环、条件等语句让计算机执行程序。声明式与指令式正相反,如果说指令式像是告诉计算机 How to do,而声明式则是告诉计算机 What to do。其实大家都接触过声明式编程,但在编码时并不会意识到。各类 DSL 和函数式编程都属于声明式编程的范畴。
举个例子,假设我们想要获取一个整形数组里的所有奇数。按照指令式的逻辑,我们需要把过程拆解为一步一步的语句:
var results = [Int]()
for num in values {
if num %2 != 0 {
results.append(num)
}
}
如果按声明式编程来,我们的想法可能是“过滤出所有奇数”,对应的代码就十分直观:
var results = values.filter { $0 % 2 != 0 }
可见上述两种编程方式有着明显的区别:
用说人话的方式解释,面向数据流和变化传播是响应未来发生的事件流。
在这个流程中,无数的事件组成了事件流,订阅者不断接受到新的事件并作出响应。
至此,我们对响应式编程的定义有了初步的理解,即以声明的方式响应未来发生的事件流。在实际编码中,很多优秀的三方库对这套机制进一步抽象,为开发者提供了功能各异的接口。在 iOS 开发中,有三种主流的响应式“流派“。
这三个流派分别是 ReactiveX、Reactive Streams 和 Reactive*。ReactiveX 接下来会详细介绍。Reactive Stream 旨在定义一套非阻塞式异步事件流处理标准,Combine 选择了它作为实现的规范。以 ReactiveCocoa 为代表的 Reactive* 在 Objective-C 时代曾非常流行,但随着 Swift 崛起,更多开发者选择了 RxSwift 或 Combine,导致 Reactive* 整体热度下降不少。
ReactiveX 最初是微软在 .NET 上实现的一个响应式的拓展。它的接口命名并不直观,如 Observable (可观测的) 和 Observer(观测者)。ReactiveX 的优势在于创新地融入了许多函数式编程的概念,使得整个事件流的变形非常灵活。这个易用且强大的概念迅速被各个语言的开发者青睐,因此 ReactiveX 在很多语言都有对应版本的实现(如 RxJS,RxJava,RxSwift),都非常流行。Resso 的 Android 团队就在重度使用 RxJava。
Combine 是 Apple 在 2019 年推出的一个类似 RxSwift 的异步事件处理框架。
通过对事件处理的操作进行组合 (combine) ,来对异步事件进行自定义处理 (这也正是 Combine 框架的名字的由来)。Combine 提供了一组声明式的 Swift API,来处理随时间变化的值。这些值可以代表用户界面的事件,网络的响应,计划好的事件,或者很多其他类型的异步数据。
Resso iOS 团队也曾短暂尝试过 RxSwift,但在仔细考察 Combine 后,发现 Combine 无论是在性能、调试便捷程度上都优于 RxSwift,此外还有内置框架和 SwiftUI 官配的特殊优势,受其多方面优势的吸引,我们全面切换到了 Combine。
相较于 RxSwift,Combine 有很多优势:
Combine 的各项操作相较 RxSwift 有 30% 多的性能提升。
Reference: Combine vs. RxSwift Performance Benchmark Test Suite
由于 Combine 是一方库,在 Xcode 中开启了 Show stack frames without debug symbols and between libraries 选项后,无效的堆栈可以大幅的减少,提升了 Debug 效率。
// 在 GlobalQueue 中接受并答应出数组中的值
[1, 2, 3, 4].publisher
.receive(on: DispatchQueue.global())
.sink { value in
print(value)
}
上文提到,Combine 的接口是基于 Reactive Streams Spec 实现的,Reactive Streams 中已经定义好了 Publisher, Subscriber,Subscription 等概念,Apple 在其上有一些微调。
具体到接口层面,Combine API 与 RxSwift API 比较类似,更精简,熟悉 RxSwift 的开发者能无缝快速上手 Combine。Combine 中缺漏的接口可以通过其他已有接口组成替代,少部分操作符也有开源的第三方实现,对生产环境的使用不会产生影响。
细心的读者可能有发现 Debug 优势 的图中出现了一个 OpenCombine。Combine 万般好,但有一个致命的缺点:它要求的最低系统版本是 iOS 13,许多要维护兼容多个系统版本的 App 并不能使用。好在开源社区给力,实现了一份仅要求 iOS 9.0 的 Combine 开源实现:OpenCombine。经内部测试,OpenCombine 的性能与 Combine 持平。OpenCombine 使用上与 Combine 差距很小,未来如果 App 的最低版本升级至 iOS 13 之后,从 OpenCombine 迁移到 Combine 的成本也很低,基本只有简单的文本替换工作。公司内 Resso、剪映、醒图、Lark 都有使用 OpenCombine。
上文提到,Combine 的概念基于 Reactive Streams。响应式编程中的三个关键概念,事件发布/操作变形/订阅使用,分别对应到 Combine 中的 Publisher, Operator 与 Subscriber。
在简化的模型中,首先有一个 Publisher,经过 Operater 变换后被 Subscriber消费。而在实际编码中, Operator 的来源可能是复数个 Publisher,Operator 也可能会被多个 Publisher 订阅,通常会形成一个非常复杂的图。
Publisher<Output, Failure: Error>
Publisher 是事件产生的源头。事件是 Combine 中非常重要的概念,可以分成两类,一类携带了值(Value),另外一类标志了结束(Completion)。结束的可以是正常完成(Finished)或失败(Failure)。
Events:
- Value:Output
- Completion
- Finished
- Failure(Error)
通常情况下, 一个 Publisher 可以生成 N 个事件后结束。需要注意的是,一个 Publisher一旦发出了Completion(可以是正常完成或失败),整个订阅将结束,之后就不能发出任何事件了。
Apple 为官方基础库中的很多常用类提供了 Combine 拓展 Publisher,如 Timer, NotificationCenter, Array, URLSession, KVO 等。利用这些拓展我们可以快速组合出一个 Publisher,如:
// `cancellable` 是用于取消订阅的 token,下文会详细介绍
cancellable = URLSession.shared
// 生成一个 https://example.com 请求的 Publisher
.dataTaskPublisher(for: URL(string: "https://example.com")!)
// 将请求结果中的 Data 转换为字符串,并忽略掉空结果,下面会详细介绍 compactMap
.compactMap {
String(data: $0.data, encoding: .utf8)
}
// 在主线程接受后续的事件 (上面的 compactMap 发生在 URLSession 的线程中)
.receive(on: RunLoop.main)
// 对最终的结果(请求结果对应的字符串)进行消费
.sink { _ in
//
} receiveValue: { resultString in
self.textView.text = resultString
}
此外,还有一些特殊的 Publisher 也十分有用:
Subscriber<Input, Failure: Error>
Subsriber 作为事件的订阅端,它的定义与 Publisher 对应,Publisher 中的 Output对应Subscriber 的 Input。常用的 Subscriber 有 Sink 和 Assign。
Sink 直接对事件流进行订阅使用,可以对 Value 和 completion 分别进行处理。
Sink 这个单词在初次看到会令人非常费解。这个术语可来源于网络流中的汇点(Sink),我们也可以理解为 The stream goes down the sink。
// 从数组生成一个 Publisher
cancellable = [1, 2, 3, 4, 5].publisher
.sink { completion in
// 处理事件流结束
} receiveValue: { value in
// 打印会每个值,会依次打印出 1, 2, 3, 4, 5
print(value)
}
Assign 是一个特化版的 Sink ,支持通过 KeyPath 直接进行赋值。
let textLabel = UILabel()
cancellable = [1, 2, 3].publisher
// 将 数字 转换为 字符串,并忽略掉 nil ,下面会详细介绍这个 Operator
.compactMap { String($0) }
.assign(to: \.text, on: textLabel)
需要留意的是,如果用 assign 对 self 进行赋值,可能会形成隐式的循环引用,这种情况需要改用 sink 与 weak self 手动进行赋值。
细心的读者可能发现了上面出现了一个 cancellable。每一个订阅都会生成一个 AnyCancellable 对象,用于控制订阅的生命周期。通过这个对象,我们可以取消订阅。当这个对象被释放时,订阅也会被取消。
// 取消订阅
cancellable.cancel()
需要注意的是,每一个订阅我们都需要持有这个 cancellable,否则整个订阅会立即被取消并结束掉。
Publisher 和 Subscriber 之间是通过 Subscription 建立连接。理解整个订阅过程对后续深入使用 Combine 非常有帮助。
图片来自《SwiftUI 和 Combine 编程》