作者 | 何星,书回正文,所谓的响应式编程到底是什么呢?,熟悉 Combine 的同学可以直接跳到实践建议部分。,维基百科对响应式编程的定义是:,虽然定义中每个字都认识,但连起来却十分费解。我们可以把定义中的内容分开来理解,逐个击破。首先,让我们来看下声明式编程。,声明式和指令式编程是常见的编程范式。在指令式编程中,开发者通过组合运算、循环、条件等语句让计算机执行程序。声明式与指令式正相反,如果说指令式像是告诉计算机 How to do,而声明式则是告诉计算机 What to do。其实大家都接触过声明式编程,但在编码时并不会意识到。各类 DSL 和函数式编程都属于声明式编程的范畴。,举个例子,假设我们想要获取一个整形数组里的所有奇数。按照指令式的逻辑,我们需要把过程拆解为一步一步的语句:,如果按声明式编程来,我们的想法可能是“过滤出所有奇数”,对应的代码就十分直观:,可见上述两种编程方式有着明显的区别:,用说人话的方式解释,面向数据流和变化传播是响应未来发生的事件流。,在这个流程中,无数的事件组成了事件流,订阅者不断接受到新的事件并作出响应。,至此,我们对响应式编程的定义有了初步的理解,即以声明的方式响应未来发生的事件流。在实际编码中,很多优秀的三方库对这套机制进一步抽象,为开发者提供了功能各异的接口。在 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 的异步事件处理框架。,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 效率。,上文提到,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 是事件产生的源头。事件是 Combine 中非常重要的概念,可以分成两类,一类携带了值(Value),另外一类标志了结束(Completion)。结束的可以是正常完成(Finished)或失败(Failure)。,通常情况下, 一个 Publisher 可以生成 N 个事件后结束。需要注意的是,一个 Publisher一旦发出了Completion(可以是正常完成或失败),整个订阅将结束,之后就不能发出任何事件了。,Apple 为官方基础库中的很多常用类提供了 Combine 拓展 Publisher,如 Timer, NotificationCenter, Array, URLSession, KVO 等。利用这些拓展我们可以快速组合出一个 Publisher,如:,此外,还有一些特殊的 Publisher 也十分有用:,Subsriber 作为事件的订阅端,它的定义与 Publisher 对应,Publisher 中的 Output对应Subscriber 的 Input。常用的 Subscriber 有 Sink 和 Assign。,Sink 直接对事件流进行订阅使用,可以对 Value 和 completion 分别进行处理。,Assign 是一个特化版的 Sink ,支持通过 KeyPath 直接进行赋值。,需要留意的是,如果用 assign 对 self 进行赋值,可能会形成隐式的循环引用,这种情况需要改用 sink 与 weak self 手动进行赋值。,细心的读者可能发现了上面出现了一个 cancellable。每一个订阅都会生成一个 AnyCancellable 对象,用于控制订阅的生命周期。通过这个对象,我们可以取消订阅。当这个对象被释放时,订阅也会被取消。,需要注意的是,每一个订阅我们都需要持有这个 cancellable,否则整个订阅会立即被取消并结束掉。,Publisher 和 Subscriber 之间是通过 Subscription 建立连接。理解整个订阅过程对后续深入使用 Combine 非常有帮助。,图片来自《SwiftUI 和 Combine 编程》,Combine 的订阅过程其实是一个拉取模型。,Subject 是一类特殊的 Publisher,我们可以通过方法调用(如 send())手动向事件流中注入新的事件。,Combine 提供了两个常用的 Subject:PassthroughSubject 与 CurrentValueSubject。,对于刚接触 Combine 的同学来说,最困扰的问题莫过于难以找到可以直接使用的事件源。Combine 提供了一个 Property Wrapper @Pubilshed 可以快速封装一个变量得到一个 Publisher。,上面比较有趣的是 $countDown 访问到的一个 Publisher,这其实是一个语法糖,$ 访问到其实是 countDown 的 projectedValue,正是对应的 Publisher。,@Published 非常适合在模块内对事件进行封装,类型擦除后提供外部进行订阅消费。,实际实践中,对于已有的代码逻辑,使用 @Published 可以在不改动其他代码快速让属性得到 Publisher 的能力。而新编写的代码,如果不会发生错误且需要使用到当前的 Value,@Published 也是很好的选择,除此之外则需要按需考虑使用 PassthroughSubject 或 CurrentValueSubject。,现实编码中,Publisher 携带的数据类型可能并不满足我们的需求,这时需要使用 Operator 对数据进行变换。Combine 自带了非常丰富的 Operator,接下来会针对其中常用的几个进行介绍。,熟悉函数式编程的同学对这几个 Operator 应该非常熟悉。它们的作用与在数组上的效果非常相似,只不过这次是在异步的事件流中。,例如,对于 map 来说,他会对每个事件中的值进行变换:,filter 也类似,会对每个事件用闭包里的条件进行过滤。reduce 则会对每个事件的值进行计算,最后将计算结果传递给下游。,对于 Value 是 Optional 的事件流,可以使用 compactMap 得到一个 Value 为非空类型的 Publisher。,flatMap 是一个特殊的操作符,它将每一个的事件转换为一个事件流并合并在一起。举例来说,当用户在搜索框输入文本时,我们可以订阅文本的变化,并针对每一个文本生成对应的搜索请求 Publisher,并将所有 Publisher 的事件汇聚在一起进行消费。,其他常见的 Operator 还有 zip, combineLatest 等。,Combine 中的 Publisher 在经过各种 Operator 变换之后会得到一个多层泛型嵌套类型:,如果在 Publisher 创建变形完成后立即订阅消费,这并不会带来任何问题。但一旦我们需要把这个 Publisher 提供给外部使用时,复杂的类型会暴露过多内部实现细节,同时也会让函数/变量的定义非常臃肿。Combine 提供了一个特殊的操作符 erasedToAnyPublisher,让我们可以擦除掉具体类型:,通过类型擦除,最终暴露给外部的是一个简单的 AnyPublisher<String, Error>。,响应式编程写起来非常的行云流水,但 Debug 起来就相对没有那么愉快了。对此,Combine 也提供了几个 Operator 帮助开发者 Debug。,print 和 handleEvents,print 可以打印出整个订阅过程从开始到结束的 Subscription 变化与所有值,例如:,可以得到:,在一些情况下,我们只对所有变化中的部分事件感兴趣,这时候可以用 handleEvents 对部分事件进行打印。类似的还有 breakpoint,可以在事件发生时触发断点。,到了万策尽的地步,用图像理清思路也是很好的方法。对于单个 Operator,可以在 RxMarble 找到对应 Operator 确认理解是否正确。对于复杂的订阅,可以画图确认事件流的传递是否符合预期。,对于大部分的 Publisher来说,它们在订阅后才会开始生产事件,但也有一些例外。Just 和 Future 在初始化完成后会立即执行闭包生产事件,这可能会让一些耗时长的操作在不符合预期的时机提前开始,也可能会让第一个订阅错过一些太早开始的事件。,一个可行的解法是在这类 Publisher 外封装一层 Defferred,让它在接收到订阅之后再开始执行内部的闭包。,上面的代码中将用户状态的通知转化成了一个网络请求,并将请求结果更新到一个 Label 上。需要留意的是,一旦某次网络请求发生错误,整个订阅会被结束掉,后续新的通知并不会被转化为请求。,解决这个问题的方式有很多,上面使用 materialize 将事件从 Publisher<Output, MyError> 转换为 Publisher<Event<Output, MyError>, Never> 从而避免了错误发生。,Combine 官方并没有实现 materialize ,CombineExt 提供了开源的实现。,Resso 在很多场景使用到了 Combine,其中最经典的例子莫过于音效功能中多个属性的获取逻辑。音效需要使用专辑封面,专辑主题色以及歌曲对应的特效配置来驱动音效播放。这三个属性分别需要使用三个网络请求来获取,如果使用 iOS 中经典的闭包回调来编写这部分逻辑,那嵌套三个闭包,陷入回调地狱,更别提其中的错误分支很有可能遗漏。,使用 Combine,我们可以把三个请求封装成单独的 Publisher,再通过 combineLatest 将三个结果合并在一起进行使用:,这样的实现方式带来了很多好处:,此外,Resso 也对自己的网络库实现了 Combine 拓展,方便更多的同学开始使用 Combine:,一言以蔽之,响应式编程的核心在于用声明的方式响应未来发生的事件流。在日常的开发中,合理地使用响应式编程可以大幅简化代码逻辑,但在不适宜的场景(甚至是所有场景)滥用则会让同事 ?。常见的多重嵌套回调、自定义的通知都是非常适合切入使用的场景。,Combine 是响应式编程的一种具体实现,系统原生内置与优秀的实现让它相较于其他响应式框架有着诸多的优势,学习并掌握 Combine 是实践响应式编程的绝佳途径,对日常开发也有诸多毗益。
© 版权声明
文章版权归作者所有,未经允许请勿转载。