从响应式编程到 Combine 实践

分类:科技频道 时间:2024-10-13 14:29:26 浏览:4
概述

从响应式编程到 Combine 实践

内容

响应式编程

维基百科对响应式编程的定义是:

在计算中,响应式编程是一种面向数据流和变化传播的声明式编程范式。

虽然定义中每个字都认识,但连起来却十分费解。我们可以把定义中的内容分开来理解,逐个击破。首先,让我们来看下声明式编程。

声明式编程

声明式和指令式编程是常见的编程范式。在指令式编程中,开发者通过组合运算、循环、条件等语句让计算机执行程序。声明式与指令式正相反,如果说指令式像是告诉计算机 How to do,而声明式则是告诉计算机 What to do。其实大家都接触过声明式编程,但在编码时并不会意识到。各类 DSL 和函数式编程都属于声明式编程的范畴。

举个例子,假设我们想要获取一个整形数组里的所有奇数。按照指令式的逻辑,我们需要把过程拆解为一步一步的语句:

  1. 遍历数组中的所有元素。
  2. 判断是否为奇数。
  3. 如果是的话,加入到结果中。继续遍历。
var results = [Int]()
for num in values {
    if num %2 != 0 {
        results.append(num)
    }
}

如果按声明式编程来,我们的想法可能是“过滤出所有奇数”,对应的代码就十分直观:

var results = values.filter { $0 % 2 != 0 }

可见上述两种编程方式有着明显的区别:

  • 指令式编程:描述过程(How),计算机直接执行并得结果。
  • 声明式编程:描述结果(What),让计算机为我们组织出具体过程,最后得到被描述的结果。

“面向数据流和变化传播”

用说人话的方式解释,面向数据流和变化传播是响应未来发生的事件流。

  1. 事件发布:某个操作发布了事件 A,事件 A 可以携带一个可选的数据 B 。
  2. 操作变形:事件 A 与数据 B 经过一个或多个的操作发生了变化,最终得到事件 A' 与数据 B'
  3. 订阅使用:在消费端,有一个或多个订阅者来消费处理后的 A' 和B',并进一步驱动程序其他部分 (如 UI )

在这个流程中,无数的事件组成了事件流,订阅者不断接受到新的事件并作出响应。

至此,我们对响应式编程的定义有了初步的理解,即以声明的方式响应未来发生的事件流。在实际编码中,很多优秀的三方库对这套机制进一步抽象,为开发者提供了功能各异的接口。在 iOS 开发中,有三种主流的响应式“流派“。

响应式流派

  • ReactiveX:RxSwift
  • Reactive Streams:Combine
  • Reactive*:ReactiveCocoa / ReactiveSwift /ReactiveObjc

这三个流派分别是 ReactiveX、Reactive Streams 和 Reactive*ReactiveX 接下来会详细介绍。Reactive Stream 旨在定义一套非阻塞式异步事件流处理标准,Combine 选择了它作为实现的规范。以 ReactiveCocoa 为代表的 Reactive* 在 Objective-C 时代曾非常流行,但随着 Swift 崛起,更多开发者选择了 RxSwift 或 Combine,导致 Reactive* 整体热度下降不少。

ReactiveX (Reactive Extension)

ReactiveX 最初是微软在 .NET 上实现的一个响应式的拓展。它的接口命名并不直观,如 Observable (可观测的) 和 Observer(观测者)。ReactiveX 的优势在于创新地融入了许多函数式编程的概念,使得整个事件流的变形非常灵活。这个易用且强大的概念迅速被各个语言的开发者青睐,因此 ReactiveX 在很多语言都有对应版本的实现(如 RxJS,RxJava,RxSwift),都非常流行。Resso 的 Android 团队就在重度使用 RxJava。

为何选择 Combine

Combine 是 Apple 在 2019 年推出的一个类似 RxSwift 的异步事件处理框架。

通过对事件处理的操作进行组合 (combine) ,来对异步事件进行自定义处理 (这也正是 Combine 框架的名字的由来)。Combine 提供了一组声明式的 Swift API,来处理随时间变化的值。这些值可以代表用户界面的事件,网络的响应,计划好的事件,或者很多其他类型的异步数据。

Resso iOS 团队也曾短暂尝试过 RxSwift,但在仔细考察 Combine 后,发现 Combine 无论是在性能、调试便捷程度上都优于 RxSwift,此外还有内置框架和 SwiftUI 官配的特殊优势,受其多方面优势的吸引,我们全面切换到了 Combine。

Combine 的优势

相较于 RxSwift,Combine 有很多优势:

  • Apple 出品
    • 内置在系统中,对 App 包体积无影响
  • 性能更好
  • Debug 更便捷
  • SwiftUI 官配

性能优势

Combine 的各项操作相较 RxSwift 有 30% 多的性能提升。

Reference: Combine vs. RxSwift Performance Benchmark Test Suite

Debug 优势

由于 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 接口

上文提到,Combine 的接口是基于 Reactive Streams Spec 实现的,Reactive Streams 中已经定义好了 Publisher, SubscriberSubscription 等概念,Apple 在其上有一些微调。

具体到接口层面,Combine API 与 RxSwift API 比较类似,更精简,熟悉 RxSwift 的开发者能无缝快速上手 Combine。Combine 中缺漏的接口可以通过其他已有接口组成替代,少部分操作符也有开源的第三方实现,对生产环境的使用不会产生影响。

OpenCombine

细心的读者可能有发现 Debug 优势 的图中出现了一个 OpenCombine。Combine 万般好,但有一个致命的缺点:它要求的最低系统版本是 iOS 13,许多要维护兼容多个系统版本的 App 并不能使用。好在开源社区给力,实现了一份仅要求 iOS 9.0 的 Combine 开源实现:OpenCombine。经内部测试,OpenCombine 的性能与 Combine 持平。OpenCombine 使用上与 Combine 差距很小,未来如果 App 的最低版本升级至 iOS 13 之后,从 OpenCombine 迁移到 Combine 的成本也很低,基本只有简单的文本替换工作。公司内 Resso、剪映、醒图、Lark 都有使用 OpenCombine。

Combine 基础概念

上文提到,Combine 的概念基于 Reactive Streams。响应式编程中的三个关键概念,事件发布/操作变形/订阅使用,分别对应到 Combine 中的 PublisherOperator 与 Subscriber

在简化的模型中,首先有一个 Publisher,经过 Operater 变换后被 Subscriber消费。而在实际编码中, Operator 的来源可能是复数个 PublisherOperator 也可能会被多个 Publisher 订阅,通常会形成一个非常复杂的图。

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 也十分有用:

  • Future:只会产生一个事件,要么成功要么失败,适用于大部分简单回调场景
  • Just:对值的简单封装,如 Just(1)
  • @Published:下文会详细介绍 在大部分情况下,使用这些特殊的 Publisher 以及下文介绍的 Subject 可以灵活组合出满足需要的事件源。极少的情况下,需要实现自定义的 Publisher ,可以看这篇文章。

Subscriber

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。每一个订阅都会生成一个 AnyCancellable 对象,用于控制订阅的生命周期。通过这个对象,我们可以取消订阅。当这个对象被释放时,订阅也会被取消。

// 取消订阅
cancellable.cancel()

需要注意的是,每一个订阅我们都需要持有这个 cancellable,否则整个订阅会立即被取消并结束掉。

Subscription

Publisher 和 Subscriber 之间是通过 Subscription 建立连接。理解整个订阅过程对后续深入使用 Combine 非常有帮助。

图片来自《SwiftUI 和 Combine 编程》

评论
签到
购物车
客服
赚钱

入驻猿来入此平台

睡后收入不是梦想

我要赚钱
公众号

扫码关注公众号

每月领专属优惠