- 原文地址:Combine: Getting Started
- 原文做者:Fabrizio Brancati
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:chaingangway
- 校对者:lsvih
学习如何使用 Combine 框架中的 Publisher(发布者)和 Subscriber(订阅者)来处理随时间变化的事件流,合并多个 publisher。前端
在 2019 年的 WWDC 大会上,Combine 框架登场,它是苹果公司新推出的“响应式”框架,用来处理随时间变化的事件。你能够用 Combine 来统一和简化像代理、通知、定时器、完成回调这样的代码。在 iOS 平台上,以前也有可用的第三方响应式框架,但如今苹果开发了本身的框架。android
在本教程中,你将学到:ios
Publisher
和 Subscriber
。Timer
。咱们经过优化 FindOrLose 来学习这些核心概念。FindOrLose 是一个游戏,它的玩法是:在四张图中,有一张图与其余三张图不一样,你须要快速辨别出这张图。git
准备好探索 iOS 中 Combine 的奇妙世界吗?是时候开始了!github
你能够在这里下载本教程的项目资源。编程
打开 starter 项目,查看一下项目文件。swift
在玩游戏以前,你必须先在 Unsplash Developers Portal 上注册并获取一个 API key。注册完以后,在他们的开发者门户网站上建立一个 App。建立完成后,在屏幕上看到下面的内容:后端
注释: Unsplash APIs 每小时有 50 次的调用上限。咱们的游戏颇有趣,但不要玩太多哟 :]数组
打开 UnsplashAPI.swift,而后在 UnsplashAPI.accessToken
中添加你的 Unsplash API key,以下:bash
enum UnsplashAPI {
static let accessToken = "<your key>"
...
}
复制代码
编译运行。主屏幕上会显示四个灰色正方形,还有一个用于开始或者中止游戏的按钮。
点击 Play 开始游戏:
如今,游戏运行彻底正常,可是请看看 GameViewController.swift 文件中的 playGame()
,这个方法的结尾是这样的:
}
}
}
}
}
}
复制代码
有太多内嵌的闭包了。你能理清里面的逻辑和顺序吗?若是你想改变调用顺序或者增长新功能,要怎么办?Combine 帮你的时候到了。
Combine 框架提供了一套声明式的 API,用来计算随时间变化的值。它有三个要素:
下面咱们依次来看每个要素:
遵循 Publisher
协议的对象能发送随时间变化的值序列。协议中有两个关联类型:Output
是产生值的类型;Failure
是异常类型。
每个 publisher 能够发送多种事件:
Output
类型的值输出Failure
类型的异常输出为了支持 Publishers,在 Foundation 框架中已经优化了一些类型的函数式特性,好比 Timer
和 URLSession
。在本教程咱们也会用到它们。
Operators 是特殊的方法,它能被 Publishers 调用而且返回相同的或者不一样的 Publisher。Operator 描述了对一个值进行修改、增长、删除或者其余操做的行为。你能够经过链式调用将这些操做组合在一块儿,进行复杂的运算。
想象一下,值从原始的 Publisher 开始流动,而后通过一系列 Operator 的处理,造成新的 Publisher。这个过程就像一条河,值从上游的 Publisher 流向下游的 Publisher。
若是没有监听这些发布的事件,Publishers 和 Operators 就没有意义。因此咱们须要 Subscriber 来监听。
Subscriber
是另外一个协议。跟 Publisher
协议相似,它也有两个关联类型:Input
和 Failure
。这两个类型必须和 Publisher 中的 Output
和 Failure
类型相对应。
Subscriber 接收 Publisher 的值序列以及正常或者异常的事件。
在调用 publisher 的 subscribe(_:)
方法时,它就准备给 subscriber 传值。这个时候,publisher 会给 subscriber 发送一个 subscription。subscriber 就能够用这个 subscription 向 publisher 请求数据。
这些完成以后,publisher 就能够自由地向 subscriber 传送数据了。在这个过程当中,publisher 有可能会传送请求的全部数据,有可能只会传送部分数据。若是 publisher 是有限事件流,它最终会以完成事件或者错误事件结束。下面的图表总结了这个过程:
上文是对 Combine 的概述。如今咱们在项目中使用它。
首先,建立 GameError
枚举来处理全部的 Publisher
错误。在 Xcode 的主目录中,进入 File ▸ New ▸ File... 选项卡,而后选择 template iOS ▸ Source ▸ Swift File。
给这个新文件命名为 GameError.swift,而后添加到 Game 文件夹中。
下面来完善 GameError
这个枚举:
enum GameError: Error {
case statusCode
case decoding
case invalidImage
case invalidURL
case other(Error)
static func map(_ error: Error) -> GameError {
return (error as? GameError) ?? .other(error)
}
}
复制代码
枚举中定义了在游戏中全部可能遇到的错误,还定义了一个处理任意类型错误的方法,用来保证错误是 GameError 类型。咱们在处理 publisher 的时候就会用到。
有了这些,咱们就能够处理 HTTP 状态码和 decoding 中的错误了。
下一步,导入 Combine 框架。打开 UnsplashAPI.swift,在文件的开头加入下面这段:
import Combine
复制代码
而后把 randomImage(completion:)
的签名改为以下:
static func randomImage() -> AnyPublisher<RandomImageResponse, GameError> {
复制代码
如今这个方法没有把回调闭包做为参数,而是返回了一个 publisher,它的 output 是 RandomImageResponse 类型,faliure 是 GameError 类型。
AnyPublisher
是一个系统类型,你能够用它来包装“任意”的 publisher。这意味着,若是你想使用 operators 或者对调用者隐藏实现细节时,就没必要修改方法签名了。
下一步,咱们来修改代码,让 URLSession
支持 Combine 的新功能。找到以 session.dataTask(with:
开头的那一行,从这行开始到方法的末尾,用下面的代码替换。
// 1
return session.dataTaskPublisher(for: urlRequest)
// 2
.tryMap { response in
guard
// 3
let httpURLResponse = response.response as? HTTPURLResponse,
httpURLResponse.statusCode == 200
else {
// 4
throw GameError.statusCode
}
// 5
return response.data
}
// 6
.decode(type: RandomImageResponse.self, decoder: JSONDecoder())
// 7
.mapError { GameError.map($0) }
// 8
.eraseToAnyPublisher()
复制代码
这段代码看起来有不少,可是它用到了不少 Combine 的特性。下面一步一步来说解:
URLSession.DataTaskPublisher
类型,它的 output 类型是 (data: Data, response: URLResponse)。这不是正确的输出类型,因此你要用一系列 operator 进行转换来达到目的。tryMap
。这个 operator 会接收上游的值,并尝试将它映射成其它的类型,映射过程当中可能会抛出错误。还有一个叫 map
的 operator 能够执行映射操做,但它不会抛出错误。200 OK
。200 OK
,抛出自定义的 GameError.statusCode
错误。response.data
。这意味着如今链式调用的输出类型是 Data
。decode
,它将尝试用 JSONDecoder
把上游的值解析为 RandomImageResponse
类型。到这一步,输出类型才是正确的。mapError
的返回类型,你可能会被吓到。.eraseToAnyPublisher
操做者会帮你把一切都收拾好,让返回值会更有可读性。上面的绝大部分逻辑,你也能够在一个 operator 中实现,但这明显不是 Combine 的思想。你能够思考一下 UNIX 中的一些工具,它们每一步只作一件事情,而后把每一步中的结果向下一步传递。
重构好了网络层的逻辑,咱们来下载图片
打开 ImageDownloader.swift 文件,而后在文件的开头用下面的代码导入 Combine:
import Combine
复制代码
和 randomImage
同样,有了 Combine 你没必要使用闭包。用下面的代码替换 download(url:, completion:)
方法:
// 1
static func download(url: String) -> AnyPublisher<UIImage, GameError> {
guard let url = URL(string: url) else {
return Fail(error: GameError.invalidURL)
.eraseToAnyPublisher()
}
//2
return URLSession.shared.dataTaskPublisher(for: url)
//3
.tryMap { response -> Data in
guard
let httpURLResponse = response.response as? HTTPURLResponse,
httpURLResponse.statusCode == 200
else {
throw GameError.statusCode
}
return response.data
}
//4
.tryMap { data in
guard let image = UIImage(data: data) else {
throw GameError.invalidImage
}
return image
}
//5
.mapError { GameError.map($0) }
//6
.eraseToAnyPublisher()
}
复制代码
这里的代码与以前例子中的很是相似。下面一步一步来说解:
dataTaskPublisher
。tryMap
检查响应码,若是没有错误,就提取数据。tryMap
操做者把上游的 Data
转换成 UIImage
,若是失败,就抛出错误。GameError
类型。.eraseToAnyPublisher
返回一个优雅的类型咱们已经用 publisher 来代替回调闭包修改完了全部网络相关的方法。如今,咱们来调用这些方法。
打开 GameViewController.swift,在文件的开头导入 Combine:
import Combine
复制代码
在 GameViewController
类的开头加入下面的属性:
var subscriptions: Set<AnyCancellable> = []
复制代码
这个属性是用来存储全部的 subscriptions。目前为止,咱们使用过 publishers 和 operators,可是没有订阅。
删除 playGame()
中全部的代码,在 startLoaders()
方法调用的后面,用下面的代码替换:
// 1
let firstImage = UnsplashAPI.randomImage()
// 2
.flatMap { randomImageResponse in
ImageDownloader.download(url: randomImageResponse.urls.regular)
}
复制代码
在上面的代码中:
flatMap
,把上一个 publisher 的值映射为新的 publisher。在本例中,你首先调用了 randomImage,得到了 output 后,将它映射成下载图片的 publisher。下一步,咱们用一样的逻辑来获取第二张图片。把下面的代码添加到 firstImage
后面:
let secondImage = UnsplashAPI.randomImage()
.flatMap { randomImageResponse in
ImageDownloader.download(url: randomImageResponse.urls.regular)
}
复制代码
如今咱们已经下载了两张随机图片了。用 zip
对这些操做进行组合。在 secondImage
的后面添加下面的代码:
// 1
firstImage.zip(secondImage)
// 2
.receive(on: DispatchQueue.main)
// 3
.sink(receiveCompletion: { [unowned self] completion in
// 4
switch completion {
case .finished: break
case .failure(let error):
print("Error: \(error)")
self.gameState = .stop
}
}, receiveValue: { [unowned self] first, second in
// 5
self.gameImages = [first, second, second, second].shuffled()
self.gameScoreLabel.text = "Score: \(self.gameScore)"
// TODO: Handling game score
self.stopLoaders()
self.setImages()
})
// 6
.store(in: &subscriptions)
复制代码
下面的步骤分解:
zip
经过组合现有的 pulisher 的 output,来建立一个新的 publisher。它会等全部的 publisher 都发送 output 以后,才会把组合值发送给下游。receive(on:)
能够指定上游的事件在哪里处理。若是要在 UI 上操做,就必须使用主队列。sink(receiveCompletion:receiveValue:)
建立了一个 subscriber,它有两个闭包参数。当收到完成事件或者正常值时,闭包就会调用。subscriptions
中,用于消除引用。没有引用以后,订阅信息就会取消,publisher 也会当即中止发送。最后,编译运行吧。
恭喜,如今你的 App 成功使用了 Combine 来处理事件流。
你也许会注意到,分数逻辑没有起做用。重构以前,咱们选择图片的同时分数也在倒数,可是如今分数是静止的。如今咱们要用 Combine 重构计时器的功能。
首先,用下面的代码替换 playGame() 方法中的 // TODO: Handling game score
,用来恢复计时器功能:
self.gameTimer = Timer
.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [unowned self] timer in
self.gameScoreLabel.text = "Score: \(self.gameScore)"
self.gameScore -= 10
if self.gameScore <= 0 {
self.gameScore = 0
timer.invalidate()
}
}
复制代码
在上面的代码中,咱们打算让 gameTimer
每 0.1
秒触发一次,同时让分数减少 10
。当分数达到 0
的时候,终止定时器。
如今编译运行,肯定游戏分数是否随着时间流逝在减少。
定时器是另一种支持 Combine 功能的 Foundation 类型。如今咱们把定时器迁移到 Combine 的版原本看看差别。
在 GameViewController
的顶部,修改 gameTimer
的定义。
var gameTimer: AnyCancellable?
复制代码
如今是在定时器里存储一个 subscription,而不是定时器自己。在 Combine 中咱们使用 AnyCancellable
。
用下的代码替换 playGame()
和 stopGame()
方法的第一行:
gameTimer?.cancel()
复制代码
如今用下面的代码在 playGame()
方法中修改 gameTimer
的赋值:
// 1
self.gameTimer = Timer.publish(every: 0.1, on: RunLoop.main, in: .common)
// 2
.autoconnect()
// 3
.sink { [unowned self] _ in
self.gameScoreLabel.text = "Score: \(self.gameScore)"
self.gameScore -= 10
if self.gameScore < 0 {
self.gameScore = 0
self.gameTimer?.cancel()
}
}
复制代码
下面是分解步骤:
.autoconnect
经过链接或者断开链接来进行管理。sink
建立的 subscriber,只须要处理正常值。编译运行,玩一下你的 Combine App 吧。
这里还有几个待优化的地方,咱们用 .store(in: &subscriptions)
连续添加了多个 subscriber,但没有移除它们。下面咱们来改进。
在 resetImages()
的顶部添加下面这行代码:
subscriptions = []
复制代码
这里,你声明了一个空数组,用来移除全部无用订阅信息的引用。
下一步,在 stopGame()
方法的顶部添加下面这行代码:
subscriptions.forEach { $0.cancel() }
复制代码
这里,你遍历了全部的 subscriptions
,而后取消了它们。
最后一次编译运行了。
使用 Combine 框架是一个很好的选择。它既流行又新颖,并且仍是官方的,为何不如今就用呢?不过在你打算全面使用以前,你得考虑一些事情:
首先,你得为用户考虑。若是你打算继续支持 iOS 12,你就不能使用 Combine。(Combine 须要 iOS 13 及以上的版本才支持)
响应式编程在思惟上的转变很大,会有学习曲线,可是你的团队要赶进度。在你的团队中是否每一个人都像你同样热衷于改变固有的工做方式?
在采用 Combine 以前,思考一下你的 app 中已经用到的技术。若是你有其余基于回调的 SDK,好比 Core Bluetooth,你必须用 Combine 对它们进行封装。
当你逐渐掌握 Combine 时,就没有那么多顾虑了。你能够先从网络层调用开始重构,而后切换到 app 的其余模块。你也能够在使用闭包的地方使用 Combine。
你能够在原文页面下载本工程的完整版本。
本教程中,你学习了 Combine 的基础知识:Publisher
和 Subscriber
。你也学会了 operator 和定时器的使用。恭喜,你已经入门了!
想学习更多 Combine 的用法,请看咱们的书籍 Combine: Asynchronous Programming with Swift!
若是你对本教程有问题或者评价,欢迎在下讨论区讨论!
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。