用Swift进行并行编程:Promises
原文 《Parallel programming with Swift: Promises》
作者 | Jan Olbrich
翻译 | JACK
编辑 | JACK
并发在我们日常工作中变得越来越重要。在之前的系列文章(用Swift进行并行编程:基础部分和Operation)中,我们研究了 Apple 提供的控制并发的工具。这次,我们会介绍一个在 Swift 开发中比较出名的用来支持并发的三方库 Promise。
回顾一下:
并行的关键是同时执行多个任务的能力,而并发不强调同时。
想想一下,我们有一个耗时的下载任务,结束后我们需要拿到下载好的数据。假设这个数据是一张图片,我们需要在下载完成时将其展示到一个 UIImageView 上。那么,我们会如何实现?
最简单的方式就是通过使用 NSURLSession 完成文件的下载,下载结果会通过一个 block 回调。
URLSession.shared.dataTask(with: url!) { data, _, error in
if error != nil {
print(error)
return
}
DispatchQueue.main.async {
imageView.image = UIImage(data: data!)
}
}.resume()
我们基本都知道该如何完成上面的操作。接下来,我们把问题变复杂一些。我们现在需要通过向后台服务器发起请求来解析并获取图片的下载地址,然后进行图片下载,完成后再将图片更新显示在 UIImageView 上。
URLSession.shared.dataTask(with: backendurl!) { data, _, error in
if error != nil {
print(error)
return
}
let imageurl = String(data: data!, encoding: String.Encoding.utf8) as String! // For simplicity response is only the URL
URLSession.shared.dataTask(with: URL(string:imageurl!)!) { (data, response, error) in
if error != nil {
print(error)
return
}
DispatchQueue.main.async {
imageView.image = UIImage(data: data!)
}
}.resume()
}.resume()
那么通过上面的实现方式,随着问题变得越来越复杂,我们可能会面临回调地狱。
对应的,我们也可以使用代理委托的方式,但请求任务一旦超过两个,这会让我们的代码变得很难维护。
public class ApiRequestDelegate:NSObject, URLSessionDelegate, URLSessionTaskDelegate,
URLSessionDataDelegate{
// called once as soon as a response returns
public func urlSession(session: URLSession, dataTask: URLSessionDataTask,
didReceiveResponse response: URLResponse,
completionHandler: (URLSession.ResponseDisposition) -> Void) {
// store Response to further process it and call completion Handler to continue
}
// called when finished
public func urlSession(session: URLSession, task: URLSessionTask,
didCompleteWithError error: NSError?) {
// handle errors and e.g. call a completion handler so you can continue with your tasks or start a different request
}
// called if data is not returned in one block
public func urlSession(_: URLSession, dataTask: URLSessionDataTask,
didReceive data: NSData) {
}
}
再来看下,如果使用 Operation,我们会拆分这些任务,并用下面的方式进行实现:
class NetworkOperation: Operation {
var targetURL: String?
var resultData: Data?
var _finished = false
override var isFinished: Bool {
get {
return _finished
}
set (newValue) {
willChangeValue(for: .isFinished)
_finished = newValue
didChangeValue(for: .isFinished)
}
}
convenience init(url: String) {
self.init()
targetURL = url
}
override func start() {
if isCancelled {
isFinished = true
return
}
guard let url = URL(string: targetURL!) else {
fatalError("Failed URL")
}
URLSession.shared.dataTask(with: url) { data, _, error in
if error != nil {
print (error)
return
}
self.resultData = data
self.isFinished = true
}.resume
}
}
let operationQueue = //...
let backendOperation = NetworkOperation(url: <Backend-URL>)
let imageOperation = NetworkOperation()
let adapter = BlockOperation() { [unowned backendOperation, unowned imageOperation] in
imageOperation.targetUrl = backendOperation.data // let's imagine this is already converted to string
}
// '=>' 表示操作之间的依赖关系,之前的文章有提到过,这是我们自己自定义的操作符
backendOperation => adapter => imageOperation => adapter2 => setImageOperation
但是就像你看到的,这也很难让我们遵循 用例 的逻辑。那除此之外我们还有其他的选择吗?
Promise对象
有一种想法,就是通过一种实现方式,它可以考虑到我们创建的变量在未来某一个时刻是有值的。它不要求变量在使用时就进行赋值,但在合适某个时候,我们会给变量赋值,然后通过这种方式继续执行代码,完成后面的任务。
这种实现方式就是 Promise——确保可以在某个时间点及时的完成值的传递。对于 Promise,在你编写代码的时候,这个变量将在某个时刻包含一个值或一个错误。并且只有在这个 Promise 被持有的时候才会被执行。
实例化
一个 Promise 对象是由一个带有两个回调的闭包组成。你可以通过调用 fulfill(), 履行并完成相应值的传递,或者通过调用 reject() 传递一个错误。
创建 Promise 对象时,你必须把生成值的代码逻辑放在闭包内。
Promise() { fulfill, reject in
return "Hello World" //or other async code
}
使用
Promise 可以被拆分成多个部分:Promise 本身、成功回调(如果携带有值就会调用 then() )、错误回调(catch())。
fetchPromise().then { value in
// do something
}.catch { error in {
// in case of error
}
如果没有实现能够响应传递的值的代码逻辑,Promise 将不会被执行。要添加响应逻辑,我们使用.then()
。但这仍不能决定它的执行时间。这部分代码只是声明了如果 Promise 履行了,接下来要做的事情。
在响应链的代码中,我们不仅可以返回值,我们还可以返回 Promise 实例。
fetchPromise().then { value in
return fetchPromise2(value)
}.then { value in
// do something
}.catch { error in
// error
}
如果有错误产生,就会直接跳转执行 catch 闭包。
fetchPromise().then { value in
return errorPromise(value) // this will throw an error
}.then { value in
//this will not execute on error
}.catch { error in
//we got an error
}
PromiseKit
关于 Promises,有很多支持库。就在最近 Google 还发布了自己的版本。此外还有一个竞争对手HoneyBee。我建议使用 PromiseKit,因为它非常成熟并且已经使用了多年。根据我的经验,他们 issue 响应时间非常快,只需几个小时。此外,PromiseKit 提供了许多可以在 iOS 中使用的扩展来简化使用过程。
安装
PromiseKit 支持几乎所有的安装方式。
Cocoapods:
use_frameworks!
target "target" do
pod "PromiseKit", "~> 6.0"
end
Carthage:
github "mxcl/PromiseKit" ~> 6.0
SwiftPM:
package.dependencies.append(
.Package(url: "https://github.com/mxcl/PromiseKit", majorVersion: 6)
)
或者你也可以手动导入。
创建 Promises
如上所述,一个 Promise 由一个包含两个不同的回调闭包组成。一个是履行 fulfill,一个是拒绝 reject。而在 PromisKit 中有些不同。我们有一个 seal 对象,它有很多方法,其中包括了 fulfill(结果) 和 reject(错误)。它还包含了可以自动确认 Promise 状态的方法 resolve(result, object) 。
如下,从后端请求图像:
func fetch(url: URL) -> Promise<Data> {
return Promise { seal in
URLSession.shared.dataTask(with: url!) { data, _, error in
seal.resolve(data, error)
}.resume()
}
}
正如你所看到的,Promises 为我们提供了处理错误的方法。但有些时候确实不会出错(例如返回静态文本)。为此 PromiseKit 提供了一种特殊的 Promise。它被称为 guarantee(保证):
Guarantee { seal in
seal("Hello World")
}
Promise 链式调用和传递
创建好 Promise 后,我们通过 then() 就可以将其激活。
fetch(url: <backend-url>).then { value in
// value
}
then() 的闭包内会返回一个 value,它不同于当初传入 Promise 的值。如果回调内只存在一行代码,Swift 会尝试进行类型推断,可惜大多数情况下这并不奏效,我们更多的是会看到如下的错误信息:
Cannot invoke ‘then’ with an argument list of type ‘(() -> _)
我们可以通过指定 参数 和 返回值 类型来解决这个问题。 你可以通过调用 done() 来结束链式调用。它在处理成功返回结果的闭包之后进行调用,而且我们不能在其闭包内返回一个 Promise 实例。
通常,在调用链的最后(除了 guarantee)会调用 catch(),用于在执行期间对任何类型的错误做出响应。在 Promise 的链式调用中,一旦因产生错误而被拒绝,则该调用链将被终止,进而执行 catch 闭包。在这个闭包中,你可以添加错误处理,例如向用户显示提示。
与往常一样,此类情况也有例外。有时你不希望走错误处理,例如在错误发生时,你想给它一个默认值。这可以通过调用 recover() 来完成。
fetch(url: <imageurl>).recover { error -> Promise<UIImage> in
return UIImage(name: <placeholder>)
}...
综合上面的链式调用,之前那个下载图片的例子,我们就可以像下面这样进行实现:
func fetch(url: URL) -> Promise<Data> {
return Promise { seal in
URLSession.shared.dataTask(with: url!) { data, _, error in
seal.resolve(data, error)
}.resume()
}
}
fetch(url: <backendURL>).then { data in
return JSONParsePromise(data) // we skip the wrapping of JSONParsing
}.then { imageurl in
return fetch(url: imageurl)
}.then { data in
imageView.image = UIImage(data: data)
}.catch { error in
// in case we have an error
}
传递链的其他元素
至此,我们已经知晓了 Promise 调用链中的基本组成元素。现在我们再进一步,可以使用语法糖 firstly() 来启动链式调用。
firstly {
return fetch(url:url)
}.then{
...
}
如果我们希望某些代码总是在 Promise 结束时执行,我们可以使用 ensure:
firstly {
fetch(url: url)
}.ensure {
cleanup()
}.catch {
handle(error: $0)
}
再次回顾下载图片的例子,我们便可以添加网络加载提示:
func fetch(url: URL) -> Promise<Data> {
return Promise { seal in
URLSession.shared.dataTask(with: url!) { data, _, error in
seal.resolve(data, error)
}.resume()
}
}
firstly {
UIApplication.shared.isNetworkActivityIndicatorVisible = true
return fetch(url: <backendURL>)
}.then { data in
return JSONParsePromise(data) // we skip the wrapping of JSONParsing
}.then { imageurl in
return fetch(url: imageurl)
}.then { data in
imageView.image = UIImage(data: data)
}.ensure {
UIApplication.shared.isNetworkActivityIndicatorVisible = false
}.catch { error in
// in case we have an error
}
有时我们希望返回收到的相同的 Promise。为此,我们可以使用 get()。
firstly {
fetch(url: url)
}.get { data in
//…
}.done { data in
// same data!
}
这在我们执行那些依赖相同结果的多任务时非常有用。
通常,等待多个异步命令的执行既缓慢又不好处理。我们同步执行它们时会变得很慢,而在我们尝试处理不同的回调时又会很麻烦。在 PromiseKit 中,给我们提供了 when()。在 when() 中,你可以添加所有要同时执行的 Promises,只有当 when() 中所有的 promises 全部结束后才会继续。
firstly {
when(fulfilled: promise1(), promise2())
}.done { result1, result2 in
//…
}
在上面的两个例子中,我们都在最后使用了 done()。它会告诉传递链在这里结束并且不存在返回值,如果使用 then(),我们总是需要有一个返回值。
在 Promise 的传递链中,还有很多其他可以使用的元素,例如:
- map:需要你返回一个对象或值类型
- compactMap:需要你返回一个可选类型(nil 代表一个 error)
执行线程
通常,我们在处理异步任务时要考虑到线程问题。Promises 的工作原理是:所有的 promises 都在后台线程中执行。但传递链本身的那些方法(then()、catch()、map() 等)是在主线程上执行的。了解这一点很重要,因为它可能导致某种意外行为。例如将一个网络请求的 Promise ,解析并进行持久化存储。如果响应体比较小,执行很快,在主线程上做是没有问题的,但并不建议这么去做。较大的响应体,较长的处理时间可能会导致掉帧甚至屏幕卡住。为了解决这个问题,你可以将解析过程写成一个 Promise,或者你可以手动更改 then() 闭包的执行线程。
fetch(url: url).then(on: .global(QoS: .userInitiated)) {
//then closure executes on different thread
}
特殊模式
为了简化向 Promise 的转换,让我们看一下开发中习惯使用的一些模式:
Delay
有时,我们需要将执行延迟一段时间,我们可以通过调用 after() 进行实现:
let waitAtLeast = after(seconds: 0.3)
firstly {
fetch(url: url)
}.then {
waitAtLeast
}.done {
//…
}
Timeout
既然有 after(),我们也可以设置超时。这可以通过使用 race() 来实现。
let timeout = after(seconds: 4)
race(when(fulfilled: fetch(url:url)).asVoid(), timeout).then {
//…
}
这里要注意,race 中的两个 promise 需要返回相同的值,我们可以使用 .asVoid() 轻松实现。
Retry
在网络请求中,我们时长会碰到连接中断,需要重试的情况。这里,我们可以通过 recover() 进行实现:
func attempt<T>(maximumRetryCount: Int = 3, delayBeforeRetry: DispatchTimeInterval = .seconds(2), _ body: @escaping () -> Promise<T>) -> Promise<T> {
var attempts = 0
func attempt() -> Promise<T> {
attempts += 1
return body().recover { error -> Promise<T> in
guard attempts < maximumRetryCount else { throw error }
return after(delayBeforeRetry).then(on: nil, attempt)
}
}
return attempt()
}
attempt(maximumRetryCount: 3) {
fetch(url: url)
}.then {
//…
}.catch { _ in
// we still failed
}
Delegate
Delegate 是 UIKit 中主要的设计模式之一,我们可以将代理封装在 Promise 中。要注意,这么做可能并不会得到你想要的结果。Promises 只会执行一次,例如如果你对 UIButton 进行封装就能会产生问题。在那些只需要响应一次的事件中,你可以保存 seal 对象,并在代理方法中进行调用。
extension Foo {
static func promise() -> Promise<FooResult> {
return PromiseWithDelegate().promise
}
}
class PromiseWithDelegate: FooDelegate {
let (promise, seal) = Promise<FooResult>.pending()
private let foo = Foo()
init() {
super.init()
retainCycle = self
foo.delegate = self // does not retain hence the `retainCycle` property
promise.ensure {
// ensure we break the retain cycle
self.retainCycle = nil
}
}
func fooSuccess(data: FooResult) {
seal.fulfill(data)
}
func fooError(error: FooError) {
seal.reject(error)
}
}
存储先前的结果
有时,你可能希望在下一个 Promise 中使用之前 Promise 的结果,我们可以使用 Swift 中的元祖进行实现。想想一下,在一个登录的任务队列中,你首次登录之后,希望将 username 和 token 一并向下传递给下一个 then 使用:
login().then { username in
getTokens(for: username).map { ($0, username) }
}.then { tokens, username in
//…
}
分支链
对于 Promises 来说,只能存在一种执行。如果你将两个不同的传递链放入一个 promise 中,它只会选其一进行执行,但是你可以在此次执行中有两种不同的任务流程(对于 catch 同样适用):
let p1 = promise1.then {
...
}
let p2 = promise2.then {
...
}
when(fulfilled: p1, p2).catch { error in
...
}
取消执行
与 Operation 不同的是,在 promise 的内建机制中并不存在取消机制,但我们可以自己实现它:
func foo() -> (Promise<Void>, cancel: () -> Void) {
let task = Task(…)
var cancelme = false
let promise = Promise<Void> { seal in
task.completion = { value in
guard !cancelme else { reject(NSError.cancelledError) }
seal.fulfill(value)
}
task.start()
}
let cancel = {
cancelme = true
task.cancel()
}
return (promise, cancel)
}
UI
用于转场 ViewController 和 封装 Delegate 有些类似,不同的是,在 ViewController 转场当中,你存储的是 promise 本身。进一步来说,presentee 必须知道如何 present 和 dismiss 自己。如果你想使用 promise 对其封装,你可以这么做:
class ViewController: UIViewController {
private let (promise, seal) = Guarantee<...>.pending()
func show(in: UIViewController) -> Promise<…> {
in.show(self, sender: in)
return promise
}
func dismiss() {
dismiss(animated: true)
seal.fulfill(…)
}
}
// use:
ViewController().show(in: self).done {
...
}.catch { error in
...
}
结论
可以看到,Promises 是一种控制并发不错的方式。相比 Operation,Promise 有很多优势,但也存在一些缺点。有些人会强调 promise 中的 state,因为它提供了更好的支持。但在我看来,这不是使用 Promises 的理由。我们之所以选择使用 Promise,是因为它提高了代码的可读性,并使控制并发变得更容易。
使用 Promise 一段时间后,我可以说,只要你清除的知道 Promise 是什么,就可以让你的编码变得简单。如果你还没有开始使用它,Promise 还是要比 Operation 容易理解和使用。
最后一件事,Promise 并不是语言特性,它不同于其他编程语言中所涉及的 Promise 的实现,例如 ECMA-Script 的版本。