SwiftUI(Combine)学习整理(三)

如果有RxSwift的学习经验那么理解combine会更加迅速

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

在响应式异步编程中,一个事件及其对应的数据被发布出来,最后被订阅者消化和使用。期间这些事件和数据需要通过一系列操作变形,成为我们最终需要的事件和数据。Combine 中最重要的角色有三种,恰好对应了这三种操作:负责发布事件的 Publisher,负责订阅事件的 Subscriber,以及负责转换事件和数据的 Operator

image.png

image.png

Publisher

在 Combine 中,我们使用 Publisher 协议来代表事件的发布者。Swift 提倡使用面向协议编程的方式,Combine 中包括 Publisher 在内的一系列角色都使用协议来进行定义,也正是这一思想的具体体现。协议本身定义了一个 Publisher 应该具备的能力,而具体的实现则由具体的遵守 Publisher 协议的类型提供。在 Combine 框架中,已经有一系列框架自带的发布者类型,它们大部分被定义在了 Publishers 这个 enum 之中。我们会在下一章仔细研究一些常用的 Publisher 类型的行为特点,在那之前,让我们先来看一看 Publisher 共通的部分特性。

Publisher 协议中所必须的内容十分简单,它包括两个关联类型 (associatedtype) 以及一个 receive 方法:

public protocol Publisher {
    associatedtype Output
    associatedtype Failure : Error
    func receive<S>(subscriber: S) where
          S : Subscriber,
                Self.Failure == S.Failure,
                Self.Output == S.Input
      }

Publisher 最主要的工作其实有两个:发布新的事件及其数据,以及准备好被 Subscriber订阅。

Output 定义了某个 Publisher 所发布的值的类型,Failure 则定义可能产生的错误的类型。随着时间的推移,事件流也会逐渐向前发展。对应 Output 及 Failure,Publisher 可以发布三种事件:
类型为 Output 的新值:这代表事件流中出现了新的值。

类型为 Failure 的错误:这代表事件流中发生了问题,事件流到此终止。

完成事件:表示事件流中所有的元素都已经发布结束,事件流到此终止。
对于第一种事件,Publisher 会直接将新的值发布出来。后两种事件在 Combine 中 则使用Subscribers.Completion 来描述,它是一个含有两个成员的 enum,其中成 员类型为 .failure(Failure) 以及 .finished。我们在本书后面的部分,会使用 output, failure 和 finished 来描述这三种事件。

虽然 Publisher 可以发布三种事件,但是它们并不是必须的。一个 Publisher 可能发 出一个或多个 output 值,也可能一个值都不发出;Publisher 有可能永远不会停止 终结,也有可能通过 failure 或者 finished 事件来表明不再会发出新的事件。我们将 最终会终结的事件流称为有限事件流,而将不会发出 failure 或者 finished 的事件流 称为无限事件流。

有限事件流和无限事件流
有限事件流最常见的一个例子是网络请求的相关操作:发出网络请求后,可以把每 次接收到数据的事件看作一个output。在请求的所有数据都被返回时,整个操作正 常结束,finished 事件被发布。如果在过程中遭遇到错误,比如网络连接断开或者连 接被服务器关闭等,则发布 failure 事件。不论是 finished 或者是 failure,都表明这 次请求已经完成,将来不会有更多的 output 发生。
无限事件流则正好相反,这类 Publisher 永远不会发出 failure 或者 finished。一 个典型的例子是 UI 操作,比如用户点击某个按钮的事件流:如果将按钮看作是事件 的发布者,每次按钮点击将发布一个不带有任何数据的 output。这种按钮操作并没 有严格意义上的完结:用户可能一次都没有点击这个按钮,也可能无限次地点击这 个按钮,不论用户如何操作,你都无法断言之后不会再发生任何按钮事件。

Operator

客户端的响应式编程中,由状态驱动 UI 是最核心的思想。不过,异步 API 的 Publisher 所提供的事件和数据,往往并不能够直接用来驱动决定 UI 的状态。

在响应式编程中,绝大部分的逻辑和关键代码的编写,都发生在数据处理和变形中。 每个 Operator 的行为模式都一样:它们使用上游 Publisher 所发布的数据作为输入, 以此产生的新的数据,然后自身成为新的 Publisher,并将这些新的数据作为输出, 发布给下游。通过一系列组合,我们可以得到一个响应式的 Publisher 链条:当链条 最上端的 Publisher 发布某个事件后,链条中的各个 Operator 对事件和数据进行处 理。在链条的末端我们希望最终能得到可以直接驱动 UI 状态的事件和数据。这样, 终端的消费者可以直接使用这些准备好的数据,而这个消费者的角色由 Subscriber 来担任。

Subscriber

和 Publisher 类似,Combine 中的 Subscriber 也是一个抽象的协议,它定义了某个 类型想要成为订阅者角色时所需要满足的条件:

public protocol Subscriber { associatedtype Input associatedtype Failure : Error
func receive(subscription: Subscription)
func receive(_ input: Self.Input) !" Subscribers.Demand
func receive(completion: Subscribers.Completion<Self.Failure>)
}

定义中 Input 和 Failure 分别表示了订阅者能够接受的事件流数据类型和错误类型。 想要订阅某个 Publisher,Subscriber 中的这两个类型必须与 Publisher 的 Output 和 Failure 相同。
Combine 中也定义了几个比较常见的 Subscriber,我们承接上面的按钮的例子来进 行说明。在上面,我们通过 scan 和 map,对 buttonClicked 进行了变形,将它从一 个不含数据的按钮事件流,转变为了以 String 表示的按钮按下次数的计数。如果我 们想要订阅和使用这些值,可以使用 sink:

let buttonClicked: AnyPublisher<Void, Never> buttonClicked
.scan(0) { value, _ in value + 1 }
.map { String($0) }
.sink { print("Button pressed count: \($0)") }

因为 buttonClicked 是一个无限事件流,所以我们在上面只对 output 值进行了打印。 sink 方法完整的函数签名如下:

func sink( receiveCompletion:
@escaping ((Subscribers.Completion<Self.Failure>) !" Void), receiveValue:
@escaping ((Self.Output) !" Void) ) !" AnyCancellable

你可以同时提供两个闭包,receiveCompletion 用来接收 failure 或者 finished 事件, receiveValue 用来接收 output 值。
sink 可以充当一座桥梁,将响应函数式的 Publisher 链式代码,终结并桥接到基于 闭包的指令式世界中来。如果你不得不在指令式的世界中进行一些操作,或者只是 以学习和验证为目的,那么使用 sink 无可厚非。但是如果你是想要让数据继续在 SwiftUI 的声明式的世界中来驱动 UI 的话,另一个 Subscriber 可能会更为简洁常用, 那就是 assign。

和通过 sink 提供闭包,可以执行任意操作不同,assign 接受一个 class 对象以及对 象类型上的某个键路径 (key path)。每当 output 事件到来时,其中包含的值就将被 设置到对应的属性上去:

class Foo {
var bar: String = ""
}
let foo = Foo()
let buttonClicked: AnyPublisher<Void, Never> buttonClicked
.scan(0) { value, _ in value + 1 } .map { String($0) }
.assign(to: \.bar, on: foo).

这样的 Subscriber 让我们可以彻底摆脱指令式的写法,直接将事件值 “绑定” 到具 体的属性上。assign 方法的具体定义如下,它要求 keyPath 满足 ReferenceWritableKeyPath:

func assign<Root>(
to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root
) !" AnyCancellable

也就是说,只有那些 class 类型的实例中的属性能被绑定。在 SwiftUI 中,代表 View 对应的模型的 ObservableObject 接口只能由 class 修饰的类型来实现,这也 正是 assign 最常用的地方。

其他角色

Publisher,Operator 和 Subscriber 三者组成了从事件发布,变形,到订阅的完整 链条。在建立起事件流的响应链后,随着事件发生,app 的状态随之演变,这些是响 应式编程处理异步程序的 Combine 框架的基础架构。
除此之外,Combine 框架中还有两个比较重要的概念,那就是 SubjectScheduler。它们和 Publisher 及 Subscriber 一样,都是通过 protocol 的方式来对 抽象概念进行描述。

Subject

Subject 本身也是一个 Publisher:

public protocol Subject : AnyObject, Publisher {
func send(_ value: Self.Output)
func send(completion: Subscribers.Completion<Self.Failure>)
}

从定义可以看到,Subject 暴露了两个 send 方法,外部调用者可以通过这两个方法 来主动地发布 output 值、failure 事件或 finished 事件。如果我们说 sink 提供了由 函数响应式向指令式编程转变的路径的话,Subject 则补全了这条通路的另一侧:它 让你可以将传统的指令式异步 API 里的事件和信号转换到响应式的世界中去。
Combine 内置提供了两种常用的 Subject 类型,分别是 PassthroughSubjectCurrentValueSubject

PassthroughSubject 简单地将 send 接收到的事件转发给 下游的其他 Publisher 或 Subscriber,PassthroughSubject 并不会对接受到的值进行保留,当订阅开始后,它将监听并响 应接下来的事件。
CurrentValueSubject 则会包装和持有一个值,并在 设置该值时发送事件并保留新的值。在订阅发生的瞬间,CurrentValueSubject 会把 当前保存的值发送给订阅者。

Scheduler

如果说 Publisher 决定了发布怎样的 (what) 事件流的话,Scheduler 所要解决的就 是两个问题:在什么地方 (where),以及在什么时候 (when) 来发布事件和执行代码。

关于 where

在更新 UI 时,我们需要保证相关操作发生在主线程。因为异步 API 往往涉及到在不 同线程间切换,这个问题就显得尤为重要。比如,使用 URLSession 进行网络请求, 默认情况下异步回调方法会在后台线程被调用,如果这时候需要根据数据更新 UI, 最简单的方式就是将它

Dispatch 到 main queue 中去:
URLSession.shared.dataTask(
with: URL(string: "https:!"example.com")!)
{
data, _, _ in
if let data = data,
let text = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
!" 在 main queue 中执行 UI 更新
textView.text = text }
} }.resume()

异步的响应式编程中也一样,如果前序的 Publisher 是在后台线程进行操作,那么在 订阅时,当状态的变化会影响 UI 时,我们需要将接收事件的线程切换到主线程。 Combine 里提供了 receive(on:options:) 来让下游在指定的线程中接收事件。比如, 对于后台线程的网络请求返回,可以通过这样的方式在 main runloop 中进行处理:

URLSession.shared
.dataTaskPublisher(for: URL(string: "https:!"example.com")!) .compactMap { String(data: $0.data, encoding: .utf8) } .receive(on: RunLoop.main)
.sink(receiveCompletion: { _ in
}, receiveValue: { textView.text = $0
})

RunLoop 就是一个实现了 Scheduler 协议的类型,它知道要如何执行后续的订阅任 务。如果没有 receive(on: RunLoop.main) 的话,sink 的闭包将会在后台线程执行, 这在更新 UI 时将带来问题。

关于 when

Scheduler 的另一个重要工作是为事件的发布指定和规划时间。默认情况下,被订阅 的 Publisher 将尽可能快地把事件传递给后续的处理流程,不过有些情况下,我们会 希望改变事件链的传递时间,比如加入延迟或者等待空闲时再进行传递,这些延时 也是由 Scheduler 负责调度的。
比较常见的两种操作是 delay 和 debounce。delay 简单地将所有事件按照一定事件 延后。debounce则是设置了一个计时器,在事件第一次到来时,计时器启动。在计时器有效期间,每次接收到新值,则将计时器时间重置。当且仅当计时窗口中没有新的值到来时,最后一次事件的值才会被当作新的事件发送出去。

Publisher 和常见Operator

某些例如map和flatmap等常见的高阶函数不在总结

Publisher 和 Subscriber 的调用关系如下:

Subscriber.png

基础 Publisher 和 Operator

元素变形

reduce 和 scan

reduce 方法可以将数组中的元素按照某种规则进行合并,并得到一个最终的结果
我们也有可能会想要把中途的过程保存下来。在 Array 中,这种操作一般叫做 scan

map

compactMap 比较简单,它的作用是将 map 结果中那些 nil 的元素去除掉,这个操作通常会 “压缩” 结果,让其中的元素数减少
flatMap 则要复杂许多。map 及 compactMap 的闭包返回值是单个的 Output 值0。

check("Compact Map") {
["1", "2", "3", "cat", "5"]
.publisher
.compactMap { Int($0) } }
!" 输出:
!" ----- Compact Map -----
!" receive subscription: ([1, 2, 3, 5]) !" request unlimited
!" receive value: (1)
!" receive value: (2)
!" receive value: (3) !" receive value: (5) !" receive finished

等价于

check("Compact Map By Filter") { ["1", "2", "3", "cat", "5"]
.publisher
.map { Int($0) } .filter { $0 !" nil } .map { $0! }
}

而与它们不同,flatMap 的变形闭包里需要返回 一个 Publisher。也就是说,flatMap 将会涉及两个 Publisher:一个是
flatMap 操 作本身所作用的外层 Publisher,一个是 flatMap 所接受的变形闭包中返回的内层 Publisher。flatMap 将外层 Publisher 发出的事件中的值传递给内层 Publisher,然 后汇总内层 Publisher 给出的事件输出,作为最终变形后的结果

removeDuplicates

Publisher 还有一个很方便的操作, removeDuplicates。在一个不断发送 Output 值的事件流中,可能会存在连续多次 发送相同事件的情况。有时,我们会想要过滤掉这些重复的事件,进而避免无谓的额 外开销。移除连续出现的重复事件值,正是 removeDuplicates 所提供的

check("Remove Duplicates") {
["S", "Sw", "Sw", "Sw", "Swi", "Swif", "Swift", "Swift", "Swif"]
.publisher
.removeDuplicates() }
!" 输出:
!" ----- Remove Duplicates -----
!" receive subscription: (["S", "Sw", "Swi", "Swif", "Swift", "Swif"]) !" request unlimited
!" receive value: (S)
!" receive value: (Sw)
!" receive value: (Swi)
!" receive value: (Swif)
!" receive value: (Swift)

错误处理

Publisher 总是在发布一些值后,以 .finished 事件作为 正常结束。但实际上,Publisher 的结束事件有两种可能:代表正常完成的 .finished 和代表发生了某个错误的 .failure,两者都表示 Publisher 不再会有新的事件发出
Subscriber 在订阅上游 Publisher 时,不仅需要保证 Publisher.Output 的类型和 Subscriber.Input 的类型一致,也要保证两者所接受的 Failure 也具有相同类型

page201image48190752.png

如果 Publisher 在出错时发送的是 SampleError,但订阅方声明只接受 MyError 时, 就算实际上 Publisher 只发出 Output 值而从不会发出 Failure 值,我们也无法使用 这个 Subscriber 去接收一个类型不符的 Publisher 的事件。
如果 Publisher 在出错时发送的是 SampleError,但订阅方声明只接受 MyError 时, 就算实际上 Publisher 只发出 Output 值而从不会发出 Failure 值,我们也无法使用 这个 Subscriber 去接收一个类型不符的 Publisher 的事件。
在这种情况下,我们可以通过使用 mapError 来将 Publisher 的 Failure 转换成 Subscriber 所需要的 Failure 类型:

抛出错误

有时候,Operator 在对上游 Output 的数据进行处理时,可能会遇到发生错误的情 况。比如说尝试将一个字符串转换为 Int 时,我们并不总是能得到结果 Int。有时候 我们可以用上面提到的 compactMap 来把这种结果过滤掉,但是有些时候,我们会 希望不要放过这种 “例外”,而是让事件流以明确的错误作为结束,来表明输入数据 出现了问题。

Combine 为 Publisher 的 map 操作提供了一个可以抛出错误的版本,tryMap。使用 tryMap 我们就可以将这类处理数据时发生的错误转变为标志事件流失败的结束事件:

check("Throw") {
["1", "2", "Swift", "4"].publisher
.tryMap { s !" Int in
guard let value = Int(s) else {
throw MyError.myError }
return value }
}
!" 输出:
!" ----- Throw -----
!" receive subscription: (TryMap) !" request unlimited
!" receive value: (1)
!" receive value: (2)
!" receive error: (myError)

除了 tryMap 以外,Combine 中还有很多类似的以 try 开头的 Operator,比如 tryScan,tryFilter,tryReduce 等等。当你有需求在数据转换或者处理时,将事件 流以错误进行终止,都可以使用对应操作的 try 版本来进行抛出,并在订阅者一侧接 收到对应的错误事件。

从错误中恢复

不管是什么语言或者框架,错误处理总是很考验细节的地方。大多数情况下我们可 能会以某种形式把错误反馈给用户,用弹框或者文本告诉他们某个地方可能出了问 题。不过有些时候,我们也可能会选择使用默认值来让事件流从错误中 “恢复”。
在 Combine 里,有一些 Operator 是专门帮助事件流从错误中恢复的,最简单的是 replaceError,它会把错误替换成一个给定的值,并且立即发送 finished 事件:

check("Replace Error") {
["1", "2", "Swift", "4"].publisher
.tryMap { s !" Int in
guard let value = Int(s) else {
throw MyError.myError }
return value }
.replaceError(with: -1) }

!" 输出:
!" ----- Replace Error -----
!" receive subscription: (ReplaceError) !" request unlimited
!" receive value: (1)
!" receive value: (2)
!" receive value: (-1)
!" receive finished

如果我们想要在事件流以错误结束时被转为一个默认值的话,replaceError 就会很 有用。replaceError 会将 Publisher 的 Failure 类型抹为 Never,这正是我们使用 assign 来将 Publisher 绑定到 UI 上时所需要的 Failure 类型。我们可以用 replaceError 来提供这样一个在出现错误时应该显示的默认值。

page205image47366144.png

replaceError 在错误时接受单个值,另一个操作 catch 则略有不同,它接受的是一 个新的 Publisher,当上游 Publisher 发生错误时,catch 操作会使用新的 Publisher 来把原来的 Publisher 替换掉。举个例子:

check("Catch with Just") {
["1", "2", "Swift", "4"].publisher
.tryMap { s !" Int in
guard let value = Int(s) else {
throw MyError.myError }
return value }
.catch { _ in Just(-1) } }
!" ----- Catch with Just ----- !" receive subscription: (Catch) !" request unlimited
!" receive value: (1)
!" receive value: (2)
!" receive value: (-1)
!" receive finished

看上去输出和上面的 replaceError 没有区别,但是记住在 catch 的闭包中,我们返 回的是 Just(-1) 这个 Publisher,而不仅仅只是 Int 的 -1。实际上,任何满足 Output == Int 和 Failure == Never 的 Publisher 都可以作为 catch 的闭包被返回, 并替代原来的 Publisher:


Publisher.png

总结

在学习 Combine 框架时,只有确实理解了每个 Operator 的作用和行为特点,才能 进一步理解各种 Operator 在组合后所形成的逻辑。最终,依靠这些小块知识和常见 模式,才能按照需求写出合适的组合逻辑。毫无疑问,如果没有各个 Operator 的知 识基石,是不可能构建出一套异步响应式的逻辑大厦的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341