深入理解 Swift Combine
Combine
文中写一些 Swift 方法签名时,会带上 label,如
subscribe(_ subscriber:)
,正常作为 Selector 的写法时会忽略掉 label,只写作subscribe(_:)
,本文特意带上 label 以使含义更清晰。
Combine Framework
Overview
在 App 运行过程中会发生各种各样的异步事件,如网络请求的返回,Notification 的发送等。在处理这些异步事件时,我们经常会使用异步回调、代理方法等。Combine 框架提供了一种声明式的 Swift API,可以将一个异步事件的处理逻辑表示成单独的一个处理链,链上的每个节点接收上一个节点的处理结果,执行自己的处理逻辑,然后传递给下一个节点。
Combine 框架采用 publisher-subscriber 模式:
- 协议
Publisher
表示一种能够随时间产生一系列值的类型。Combine 还为这些 Publishers 提供了许多 operators 来处理从上游接收的值,然后再重新发送到下游。 - 在这个处理链的最末端,是由协议
Subscriber
表示的订阅者类型,接收并处理发送给它的值。 - 当一个 Subscriber 订阅到一个 Publisher 上时,会接收到一个新生成的由协议
Subscription
表示的类型对象,Subscriber 通过该对象来向 Publisher 请求值,而 Publisher 也只有在接收到 Subscriber 的显式请求时才会分发值。
Publisher 和 Subscriber 的交互流程
- 首先,subscriber 调用 publisher 的
subscribe(_ subscriber:)
方法,将自己作为参数传过去;subscribe(_ subscriber:)
会接着调用 publisher 的receive(subscriber:)
方法。 - 方法
receive(subscriber:)
是Publisher
协议的要求方法,所有遵循 Publisher 的类型都必须实现该方法,在这个方法里处理 subscriber 的订阅逻辑。但是使用方又不能直接调用该方法,而必须通过调用扩展方法subscribe(_ subscriber:)
来发起订阅。 - Publisher 接受订阅之后,会创建一个新的 Subscription 对象,然后调用 subscriber 的
receive(subscription:)
方法。协议 Subscription 约束了一个方法request(_ demand:)
,subscriber 通过调用该方法来说明自己需要请求多少的值。只有 request 之后,publisher 才会向该 subscriber 发布值。 - Publisher 通过调用 subscriber 的
receive(_ input:)
方法来向其发布值,并在结束时调用 subscriber 的receive(completion:)
来进行通知。注意这里面说的调用并不一定指 publisher 对象持有 subscriber 对象,然后直接调用 subscriber 对象的上述方法,具体是否持有是具体实现细节,也有可能通过某些中间对象间接调用。Anyway,实现 Subscriber 协议的类型必须实现这两个方法(以及开头的receive(subscription:)
方法),在这些方法中处理从 publisher 那里接收到的值。
Combine 中的 Publishers
内置 publishers
Combine 框架提供了许多内置的 publisher 类型供我们使用,如:
- 为 Sequence 类型实现的 publisher 扩展;
- 为 NotificationCenter 实现的
publisher(for:object:)
扩展; - URLSession 的
dataTaskPublisher(for:)
扩展; - …
Subject
Subject 给我们提供了一种向流中插入值的方式,为我们在存量命令式编程的代码中引入 Combine 提供了一个强大的工具。Subject 本身即是一个 publisher,下游可以正常去 subscribe 它,然后使用方通过调用它的 send(_ value:)
方法来发布一个值。Combine 提供了两种 subject:
- CurrentValueSubject:如其名,会维护一个当前值,初始化时需要传入一个初始值作为当前值,后续通过调用
send(_ value:)
来更新当前值。当一个新的 subscriber 订阅时,会马上收到一次最新的当前值。 - PassthroughSubject:不同于 CurrentValueSubject,内部没有缓存状态,每次调用
send(_ value:)
时才会向下游发布值。
@Published
该 property wrapper 修饰 Class 的某个属性,为其生成一个 publisher,使用方通过 $
加上属性名来访问该 publisher。当属性值变化时,该 publisher 会在属性的 willSet 里发布新值,因此需要留意属性值本身尚未被更新仍是旧值,传给 subscriber 的值是新值。
Combine 中的 Subscribers
Combine 提供了两个内置的 subscribers:Subscribers.Sink 和 Subscribers.Assign,但一般不直接创建这两个的实例,而是通过 Publisher 的两种扩展方法 sink 和 assign 来获取类型抹除后的 AnyCancellable 对象。
Sink
Sink subscriber 创建的时候会立即调用 subscription 对象的 request(.unlimited)
,后面会详细介绍 Demand 的用法,这里需要留意的是一旦请求了 .unlimited 的 demand 之后,便无法再调整了,也就是说只要 publisher 不断地产生新的值,Sink 就会持续地接收到新值,直至被 cancel。
Publisher 有两个 sink 扩展方法:
sink(receiveCompletion:receiveValue:)
:两个闭包的含义和用法不必多解释;sink(receiveValue:)
:只有当 Publisher 的 Failure associated type 是 Never 时才可以使用该方法。
Assign
Assign subscriber 会将接收到的值赋值给一个类对象的属性或者一个另一个 Published publisher 上,它对 publisher 的 demand 也是 .unlimited。
assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root)
:- 因为 keyPath 类型是 ReferenceWritableKeyPath,所以 object 只能是一个类实例;
- 注意该 Assign subscriber 会强持有 object 对象,除非上游发布了一个 completion。
assign(to published: inout Published<Self.Output>.Publisher)
:- 使用该 subscriber 可以将上游的值通过一个 @Published 修饰的属性重新发布;
- 该方法没有返回值,当关联的 Published 实例析构时会自动地 cancel 掉订阅。
Publisher 的 operators
Combine 还为 publisher 添加了许多扩展方法,称为 operators,它们返回的也是一个 Publisher,因此可以进行链式调用。每个 operator 接收一个上游 publisher,处理转换上游发布的值,然后重新发布到下游。
具体的 operator 及其用法详见文档。
Connectable
常用的 Subject 如 sink(receiveValue:)
会在订阅到一个 publisher 时立即发起一个 unlimited demand,对应的 publisher 如果此时有值则会马上发布,但这时使用方并不一定准备好接收并处理数据。另一种情况是如果一个 publisher 期望有多个 subscribers,但由于每个 subscriber 订阅的时机不一样,有可能当第一个 subscriber 订阅时,publisher 就已经把值给发布出去了,这样当第二个 subscriber 订阅时,只会收到一个 completion。
Combine 提供一个 ConnectablePublisher
协议来支持手动控制开始发布值的时机,遵循该协议的 Publisher 只有在显示调用 connect()
方法之后,才会开始值发布的过程,在这之前,即使满足 publisher 发布值的条件,也不会进行发布。
使用 Publisher 的 makeConnectable()
operator 来将一个已有的 publisher 包装成一个 Publishers.MakeConnectable
实例,该实例便是一个 ConnectablePublisher,之后使用方便可在合适的时机调用其 connect() 方法来开启值的发布。
Combine 中有些 publisher 已经实现了 ConnectablePublisher 协议,如 Publishers.Multicast
,Timer.TimerPublisher
等,有时在一些使用这些 publisher 的简单场景中,显式调用 connect()
反而显得繁琐,因此 ConnectablePublisher 又提供了一个 autoconnect()
operator,该操作符会在一个 subscriber 订阅它时立刻调用 connect 方法。
Demand and Back Pressure
在 Combine 中,一个 Publisher 只有在被 Subscriber 订阅并且发起要求的时候才会产生值。Subscriber 有下面两种方式来发起要求:
- 通过调用 Subscription 对象的
request(_ demand:)
方法,Subscription 对象在发起订阅时由receive(subscription:)
方法传入; - 每次
receive(_ input:)
调用时,可以返回一个新的 Demand。
Demand 表示 subscriber 需要多少值,Combine 提供的 API 里面,有 .none
, .unlimited
, 以及指定具体数目的 .max(Int)
。Demand 是可加的,如一个 subscriber 要求了 2 个值,然后又要求了 .max(3),则其订阅的 publisher 现在共有 5 个未满足的值,每当 publisher 发布一个值,其为满足的 demand 便随之减 1,这也是唯一使其为满足的 demand 数值减少的方式,因为 Demand 不支持负值。而一旦 subscriber 要求了 .unlimited 的 demand,则后续就无法继续再同 publisher 协商了。
自定义 Subscriber
内置的 Subscriber Sink 和 Assign 都是一开始就请求了 .unlimited 的 demand,如果需要精细化地控制 publisher 发送值的 rate,可以实现一个自定义的 Subscriber,如:
class MySubscriber: Subscriber {
typealias Input = Date
typealias Failure = Never
var subscription: Subscription?
func receive(subscription: Subscription) {
print("published received")
self.subscription = subscription
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
subscription.request(.max(3))
}
}
func receive(_ input: Date) -> Subscribers.Demand {
print("\(input) \(Date())")
return Subscribers.Demand.none
}
func receive(completion: Subscribers.Completion<Never>) {
print ("--done--")
}
}
自定义 Subscriber 的要点即是实现协议约束的三个方法,自定义的 subscriber 可以自己持有传入的 Subscription 对象,以实现精细化的控制。这种由 subscriber 来控制流速的行为称为 back pressure。
Back-Pressure 操作符
除了自定义 Subscriber,Combine 也提供了一些操作符给内置的 subscriber 使用以协助控制流速,这些操作符内部实现一些缓存相关的逻辑:
buffer:最大缓存一定数目的值,超出后丢弃或抛出错误;
debounce:设定一个 dueTime,假设时间为 t0 上游发布一个值,这时 debounce 不会立刻重新发布,而是创建一个重发布任务在 t0 + dueTime 之后执行(注意这里的任务是为了便于理解抽象出的概念,不代表具体实现真正地创建了一个任务,不过有可能确实是这样实现的);但如果在这期间,在时间 t1 时上游又发布了一个值,则之前的延时任务会被丢弃,会重新创建一个任务在 t1 + dueTime 之后才会重新发布,如果在这期间上游又发送了一个值,则以此类推。如:
let bounces:[(Int,TimeInterval)] = [
(0, 0),
(1, 0.25), // 0.25s interval since last index
(2, 1), // 0.75s interval since last index
(3, 1.25), // 0.25s interval since last index
(4, 1.5), // 0.25s interval since last index
(5, 2) // 0.5s interval since last index
] let subject = PassthroughSubject<Int, Never>()
cancellable = subject
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
.sink { index in
print ("Received index \(index)")
} for bounce in bounces {
DispatchQueue.main.asyncAfter(deadline: .now() + bounce.1) {
subject.send(bounce.0)
}
} // Prints:
// Received index 1
// Received index 4
// Received index 5 // Here is the event flow shown from the perspective of time, showing value delivery through the `debounce()` operator: // Time 0: Send index 0. (republish task0 at: 0.5)
// Time 0.25: Send index 1. (task0 is discarded, republish task1 at 0.25 + 0.5 = 0.75)
// Time 0.75: Debounce period ends, publish index 1. (execute task1)
// Time 1: Send index 2. (republish task2 at: 1 + 0.5 = 1.5)
// Time 1.25: Send index 3. (task2 is discarded, republish task3 at 1.25 + 0.5 = 1.75)
// Time 1.5: Send index 4. (task3 is discarded, republish task4 at 1.5 + 0.5 = 2.0)
// Time 2: Debounce period ends, publish index 4. Also, send index 5. (execute task4. republish task5 at: 2 + 0.5 = 2.5)
// Time 2.5: Debounce period ends, publish index 5. (execute task5)
throttle:设定一个 interval,每次达到时间时,会检查这一小段时间内有无值被发布,如果有的话,根据设定的 latest 参数决定将最新或最旧的值发布到下游。
collect:从上游接收到值时,先搜集起来,超过给定的数目或者超过给定的时间间隔之后,再把所有搜集到的值发给下游。
许多人经常搞不清 debounce 和 throttle 的区别,从上面的解释可以很清楚地看出二者的机制和差异,throttle 很稳定地定期发布一次(如有值可发布),而 debounce 如果上游频繁地发布值的话,可能要等好久才会发布一次,这也正是 debounce 的作用,比如在输入框输入文字的场景。
开源实现:OpenCombine
Apple 家的新东西都有一个特点:只能在比较新的操作系统版本上使用,比如 Combine 要求 iOS 13 以上才能使用,而且一些 API 更新可能会要求更新的 OS 版本。然而有位大佬 Sergej Jaskiewicz 开发了 Combine 的开源实现:OpenCombine,完全兼容 Combine 的 API,可以运行在老的 iOS 和 macOS 版本上,甚至支持 Windows、Linux 和 WASM。
我们来简单看一下内置的 Publisher 之一的 PassthroughSubject 的开源实现。
先从订阅流程看起,可以结合上面的图作为参照。首先是 Publisher 的扩展方法 subscribe(_ subscribe)
,其实就是简单地调用了一下具体 Publisher 类型的协议约束方法,传入参数 subscriber: receive(subscriber: subscriber)
。PassthroughSubject 的协议方法实现为:
public func receive<Downstream: Subscriber>(subscriber: Downstream)
where Output == Downstream.Input, Failure == Downstream.Failure
{
lock.lock()
if active {
let conduit = Conduit(parent: self, downstream: subscriber) // a.
downstreams.insert(conduit) // b.
lock.unlock()
subscriber.receive(subscription: conduit) // c.
} else {
let completion = self.completion!
lock.unlock()
subscriber.receive(subscription: Subscriptions.empty) // d.
subscriber.receive(completion: completion) // e.
}
}
先看 c 处,最终调用了 subscriber 的 receive(subscription:)
,传入的 subscription 对象在 a 处创建,在 b 处被 publisher 插入到自己内部的一个 downstreams 数组中,该对象传给 subscriber 之后也会被 subscriber 对象持有,用于向 publisher 要求 demand、执行 cancel。
我们上面多次介绍到 Subscription 类型,但 Combine 并没有提供具体的实现类型,因为它其实是某个具体 publisher 的实现的一部分,这里的 Conduit 便是 PassthroughSubject 的 Subscription 实现类。我们接着看 PassthroughSubject 的另一个协议约束方法:
// PassthroughSubject 的 send(_ input:) 实现
public func send(_ input: Output) {
lock.lock()
guard active else {
lock.unlock()
return
}
let downstreams = self.downstreams
lock.unlock()
for conduit in downstreams {
conduit.offer(input)
}
}
// Conduit 的 offer(_ output:) 实现
override func offer(_ output: Output) {
lock.lock()
guard demand > 0, let downstream = downstream else {
lock.unlock()
return
}
demand -= 1 // a.
lock.unlock()
downstreamLock.lock()
let newDemand = downstream.receive(output) // b.
downstreamLock.unlock()
guard newDemand > 0 else { return }
lock.lock()
demand += newDemand // c.
lock.unlock()
}
PassthroughSubject 的 send(_ input:)
会调用 Conduit 的 offer(_ output:)
,在 offer 方法中,a 处将 demand 减 1,b 处调用 subscriber 的 receive(_ input:) 方法,c 处再将返回的 newDemand 加到现有 demand 上面,和前文描述的逻辑完全一致。
其他方法实现、Subscriber 的实现、各种操作符的实现可以直接翻源码,了解各个协议之间的约束之后非常简洁易读。
References
- Publisher
- Subscriber
- Processing Published Elements with Subscribers
- Controlling Publishing with Connectable Publishers
- 关于 Backpressure 和 Combine 中的处理
- OpenCombine
深入理解 Swift Combine的更多相关文章
- 深入理解 Swift 派发机制
原文: Method Dispatch in Swift作者: Brain King译者: kemchenj 译者注: 之前看了很多关于 Swift 派发机制的内容, 但感觉没有一篇能够彻底讲清楚这件 ...
- [翻译]理解Swift中的Optional
原文出处:Understanding Optionals in Swift 苹果新的Swift编程语言带来了一些新的技巧,能使软件开发比以往更方便.更安全.然而,一个很有力的特性Optional,在你 ...
- 理解Swift中map 和 flatMap对集合的作用
map和flatMap是函数式编程中常见的概念,python等语言中都有.借助于 map和flapMap 函数可以非常轻易地将数组转换成另外一个新数组. map函数可以被数组调用,它接受一个闭包作为參 ...
- 最通俗易懂的方式让你理解 Swift 的函数式编程
函数式编程(Functional Programming)是相对于我们常用的面向对象和面向过程编程的另外一种开发思维方式,它更加强调以函数为中心.善用函数式编程思路,可以对我们的开发工作有很大的帮助和 ...
- swift 深入理解Swift的闭包
我们可用swift的闭包来定义变量的值. 先来一个简单的例子大家先感受感受. 定义一个字符串的变量的方法: 直接赋值 var str="JobDeer" 还可以用闭包的方式定义: ...
- 站在OC的基础上快速理解Swift的类与结构体
阅读此文章前,您已经有一定的Object-C语法基础了!) 2014年,Apple推出了Swift,最近开始应用到实际的项目中. 首先我发现在编写Swift代码的时候,经常会遇到Xcode不能提示,卡 ...
- 如何理解swift中的delegate
Delegation翻译为代理或者委托,是一种设计模式.顾名思义,使class或struct能够将某些职责移交给其他类型的实例. 该设计模式通过定义一个封装(包含)delegate的protocol( ...
- Swift语言快速入门
Swift语言快速入门(首部同步新版官方API文档和语法的Swift图书,确保代码可编译,作者专家在线答疑,图书勘误实时跟进) 极客学院 编著 ISBN 978-7-121-24328-8 201 ...
- 强大的swift字符串
Swift集百家之长,吸收了主流语言java,c,c++等的好的特性,所以它功能十分强大,今天我们就来看看它强大的字符串. 首先,我们带着这样几个问题去了解.理解swift的字符串. 1.swift字 ...
- Swift 3 新特性
原文:What's New in Swift 3? ,作者:Ben Morrow,译者:kmyhy Swift 3将于今年下半年推出,为Swift开发者们带来了很多核心代码的改变.如果你没有关注过 S ...
随机推荐
- C# OpenCv DNN 人脸检测
using OpenCvSharp; using OpenCvSharp.Dnn; using System; using System.Collections.Generic; using Syst ...
- 带你快速入坑ES6
一.了解ES6 1)ES6官网:http://www.ecma-international.org/ecma-262/6.0/ 2)Javascript是ECMAScript的实现和扩展 3)ES学习 ...
- [ROS串口通信]报错:IO Exception (13): Permission denied, file /tmp/binarydeb/ros-noetic-serial-1.2.1/src/impl/unix.cc, line 151. [ERROR] [1705845384.528602780]: Unable to open port
ROS在串口通信时,当我们插入USB后,catkin_make之后,报错: IO Exception (13): Permission denied, file /tmp/binarydeb/ros- ...
- Redis redis-cli 你需要知道这些有用的命令
一.--stat 输出当前 redis 服务节点状态 命令:redis-cli -h host -p port --stat 输出: 连续输出,默认interval 1s 键数 | 内存 | 客户端数 ...
- #高精度,排列组合、dp#JZOJ 2755 树的计数
题目 求\(n\)个点直径为\(d\)的标号树个数(多组数据) (\(0\leq d\leq n\leq 50,n>0\)) 分析 首先特判一下\(n==d\)无解,\(d=0\)除非只有一个点 ...
- #威佐夫博弈#洛谷 2252 [SHOI2002]取石子游戏
题目 有两堆石子,数量任意,可以不同.游戏开始由两个人轮流取石子. 游戏规定,每次有两种不同的取法,一是可以在任意的一堆中取走任意多的石子: 二是可以在两堆中同时取走相同数量的石子.最后把石子全部取完 ...
- 深入解析 C 语言中的 for 循环、break 和 continue
C语言中的 for 循环 当您确切地知道要循环执行代码块的次数时,可以使用 for 循环而不是 while 循环 for (语句 1; 语句 2; 语句 3) { // 要执行的代码块 } 语句 ...
- Pandas选择与索引
Series和DataFrame对象与Numpy数组和标准Python字典的数据索引和选择模式一样. 字典形式选择和索引 Series In [1]: import pandas as pd In [ ...
- 通过 Traefik Hub 暴露家里的网络服务
Traefik Hub 简介 ️Reference: 你的云原生网络平台 -- 发布和加固你的容器从未如此简单. Traefik Hub 为您在 Kubernetes 或其他容器平台上运行的服务提供一 ...
- input 去除默认样式
前言 如何不自己写框架,基本用不上. 正文 input{ border: 0px; background-color: none; outline: none; } input:focus{ outl ...