原文:Getting Started with PromiseKit
做者:Michael Katz
译者:kmyhycss
异步编程真的让人头疼。无论你怎样当心,老是easy出现臃肿的托付、混乱的完毕句柄以及长时间的代码调试!git
幸运的是,现在有一个更好的办法:promise。Promise 可以让你以基于事件的方式编写一连串的动做来实现异步。对于需要以肯定顺序运行的动做尤事实上用。在本教程中,你将学习怎样使用第三方框架 PromiseKit 来让你的异步代码和头脑同一时候保持清晰。web
一般,iOS 开发中都会有不少托付和回调。数据库
你可能写过不少类似这样的代码:编程
Promise 将这样的如同乱麻的关系理清成这个样子:json
当 X 可用时,运行 Y。
是否是优雅多了?Promise 还可以将错误处理和成功代码分离开来,致使代码在处理各类条件的时候更加清晰。它们可以很是好滴解决复杂的、步骤繁多的工做流,比方web登陆,运行 SDK 登陆认证、处理和显示图片等。swift
Promise 是比較常见的,它的实现方式也很是多,但在这篇教程中,咱们将学习一个比較时髦的第三方 Swift 框架,叫作 PromiseKit。api
本文的演示样例项目是 WeatherOrNot,它是一个简单的实时天气应用。promise
它的天气 API 用的是 OpenWeatherMap。訪问这个 API 的模式和概念可以用到随意 web 服务上。缓存
从这里下载開始项目。PromiseKit 经过 cocoapods 公布,但開始项目中已经包括了这个 pod。假设你曾经没用过 CocoaPods,请參考这篇教程。
不然请直接在 CocoaPods 中安装 PromiseKit。除此以外。本教程不需要不论什么其余 CocoaPods 知识。
打开 PromiseKitTutorial.xcworkspace,你会看到项目结构很是easy。仅仅有 5 个 swift 文件:
这也是 Promise 的主要消费者。
关于天气数据,这个 app 用 OpenWeatherMap 做为天气数据源。和大部分第三方 API 一样,要訪问这个服务需要获取一个 API key。别操心。在本教程中使用的是它的免费套餐,全然够用了。
咱们先获取一个 API key。
訪问 http://openweathermap.org/appid,先进行注冊。注冊后就可以在 https://home.openweathermap.org/api_keys 这里找到你的 API key。
复制这个 key,将它粘贴到 WeatherHelper.swift 头部的 appID 常量中。
运行 app,假设一切正常。你将看到雅典当前的天气。
呃……这个 app 现在有一个 bug(都会咱们会解决它),因此 UI 显示可能有点慢。
在平常生活中的承诺(promise)你确定知道。
好比,你可以许诺本身在完毕本程以后来一本冷饮。
这个叙述中包括了一个动做(“来一杯饮料”),这个动做会在还有一个动做完毕(“完毕这篇教程”)以后发生。变成中的承诺与此类似,即指望某些事情在将来当某些数据到达以后被运行。
承诺被用于实现异步。和传统方法。比方经过完毕块或选择器进行回调不一样,承诺可能被简单地进行链式链接。从而表达一连串异步动做。承诺和 Operation 有点像。也有一个运行生命周期并能被取消。
一个 PromiseKit 中的承诺会运行一个代码块。这个代码块应当用一个值来知足(或兑现)。
假设这个值被兑现了,代码块就会被运行。假设这个块返回了一个承诺,则这个承诺也会运行(某个值被兑现),以此类推。假设在这个过程当中发生错误,一个可选的 catch 块将替代这个块运行。
好比。一个 Promisekit 承诺的口语化描写叙述是这个样子:
doThisTutorial().then { haveAColdOne() }.catch { postToForum(error) }
PromiseKit 是承诺的 Swift 实现。它不是惟一实现。仅仅是流行度最高而已。除了提供块式构造语法。PromiseKit 还提供了对不少常见 iOS SDK 类的封装和简单的错误处理机制。
要亲身体会 promise 是什么,请看一眼 BrokenPromise.swift 中的这个函数:
func BrokenPromise<T>(method: String = #function) -> Promise<T> { return Promise<T>() { fulfill, reject in let err = NSError(domain: "PromiseKitTutorial", code: 0, userInfo: [NSLocalizedDescriptionKey: "'\(method)' has not been implemented yet."]) reject(err) } }
方法返回了一个新的泛型化的 promise,它是 PromiseKit 中的核心类。它的构造函数使用一个块參数,这个块有两个參数:
对于 BrokePromise 来讲,代码仅仅会返回一个错误。这个辅助对象用于在你真正实现这个 app 时提示你仍然有功能需要实现。
訪问远程server是一种最多见的异步操做。所以咱们从简单的网络调用開始。
看一眼 WeatherHelper.swift 中的getWeatherTheOldFashionedWay(latitude:longitude:completion:) 方法。
这种方法用指定的经纬度、完毕块为參数。抓取天气数据。
但是。这个完毕块无论是成功仍是失败都会被调用。仅仅会添加完毕块的复杂性。因为你需要在代码中对成功和失败两种状况进行处理。
更过度的是。这个完毕块是在后台线程中调用的,所以会致使 (accidentally :cough:) 在后台更新 UI !:[
这里用 promise实用吗?答案是确定的!
在 getWeatherTheOldFashionedWay(latitude:longitude:completion:): 后加入方法:
func getWeather(latitude: Double, longitude: Double) -> Promise<Weather> {
return Promise { fulfill, reject in
let urlString = "http://api.openweathermap.org/data/2.5/weather?lat=\(latitude)&lon=\(longitude)" +
"&appid=\(appID)"
let url = URL(string: urlString)!
let request = URLRequest(url: url)
let session = URLSession.shared
let dataTask = session.dataTask(with: request) { data, response, error in
if let data = data,
let json = (try? JSONSerialization.jsonObject(with: data, options: [])) as?
[String: Any], let result = Weather(jsonDictionary: json) { fulfill(result) } else if let error = error { reject(error) } else { let error = NSError(domain: "PromiseKitTutorial", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unknown error"]) reject(error) } } dataTask.resume() } }
这种方法和 getWeatherTheOldFashionedWay 方法同样也使用 URLSession,但没有使用完毕块,而是将网络操做放在了一个 Promise 中。
当 dataTask 的 completion 处理回调中,假设成功返回数据,将 JSON 序列化后建立一个 Weather 对象。
用这个对象调用 fulfill 函数。完毕这个承诺。
假设发生错误。用 error 对象调用 reject 函数。
不然。代表既没有返回 JSON 数据也没有发生错误,则建立一个 NSError 传递给 reject 函数,因为调用 reject 函数必需要一个 NSError 參数。
而后,在 WeatherViewController.swift 中将 handleLocation(city:state:latitude:longitude:) 替换成:
func handleLocation(city: String?, state: String?,
latitude: CLLocationDegrees, longitude: CLLocationDegrees) {
if let city = city, let state = state {
self.placeLabel.text = "\(city), \(state)"
}
weatherAPI.getWeather(latitude: latitude, longitude: longitude).then { weather -> Void in
self.updateUIWithWeather(weather: weather)
}.catch { error in
self.tempLabel.text = "--"
self.conditionLabel.text = error.localizedDescription
self.conditionLabel.textColor = errorColor
}
}
太棒了,使用 promise 时仅仅需要提供一个 then 块和一个 catch 块!
新的 handleLocation 方法和原来相比。好了不少。
首先。单一的完毕块被分为两个可读性更好的块:then 用于成功 catch 用于失败。
其次。默认 PromiseKit 在主线程中运行这两个块,所以不会致使在后台线程中刷新 UI 的发生错误。
Promise 很是好。但 PromiseKit 并不只仅是这些。
除了 Promise,PromiseKit 还对常见的 iOS SDK 方法进行了扩展,让它们可以以承诺的方法表达。
好比。URLSession data task 方法的完毕块可以替换为 promise。
将 getWeather(latitude:longitude:) 方法替换为:
func getWeather(latitude: Double, longitude: Double) -> Promise<Weather> {
return Promise { fulfill, reject in
let urlString = "http://api.openweathermap.org/data/2.5/weather?lat=" +
"\(latitude)&lon=\(longitude)&appid=\(appID)"
let url = URL(string: urlString)!
let request = URLRequest(url: url)
let session = URLSession.shared
// 1
let dataPromise: URLDataPromise = session.dataTask(with: request)
// 2
_ = dataPromise.asDictionary().then { dictionary -> Void in
// 3
guard let result = Weather(jsonDictionary: dictionary as! [String : Any]) else {
let error = NSError(domain: "PromiseKitTutorial", code: 0,
userInfo: [NSLocalizedDescriptionKey: "Unknown error"])
reject(error)
return
}
fulfill(result)
// 4
}.catch(execute: reject)
}
}
看到了吗?PromiseKit 的 Wrapper 就这么简单!解释一下上述代码:
在以前的方法中咱们必须分别检查这两种状况。而这里。仅仅需要一个 catch 块就能让所有错误进入失败块。
在这种方法中,两个 promise 被连接在一块儿。
第一个 promise 是 dataPromise。它从 URL 请求中返回数据 data。第二个 promise 是 asDictionary()。它用 data 作參数并将它转换成字典返回。
现在网络部分已经就绪,咱们来看单位功能。无论你是否有幸去过希腊,这个 app 都不会给你真正想要的数据。要解决这个,咱们需要使用设备的定位功能。
在 WeatherViewController.swift 中,将 updateWithCurrentLocation() 替换为:
private func updateWithCurrentLocation() {
// 1
_ = locationHelper.getLocation().then { placemark in
self.handleLocation(placemark: placemark)
}.catch { error in
self.tempLabel.text = "--"
self.placeLabel.text = "--"
switch error {
// 2
case is CLError where (error as! CLError).code == CLError.Code.denied:
self.conditionLabel.text = "Enable Location Permissions in Settings"
self.conditionLabel.textColor = UIColor.white
default:
self.conditionLabel.text = error.localizedDescription
self.conditionLabel.textColor = errorColor
}
}
}
这里使用了辅助类来进行 Core Location 调用。待会再来实现这个类。getLocation() 返回一个 promise。这个 promise 会从当前位置得到一个地名 placemark。
这个 catch 块显示了各类错误并在单个 catch 块中对错误进行处理。用一个 switch 语句,依据用户是否授予位置訪问权限仍是其余类型的错误来给予不一样的提示。
而后,在 LocationHelper.swift 中将 getLocation() 替换为:
func getLocation() -> Promise<CLPlacemark> {
// 1
return CLLocationManager.promise().then { location in
// 2
return self.coder.reverseGeocode(location: location)
}
}
这里利用了前面介绍过的 PromiseKit 的两个概念:PromiseKit Wrapper 和 promise 链。
CLLocationManager.promise() 返回了一个当前位置的 promise。
一旦获取到用户当前位置,将位置传递给 CLGeocoder.reverseGeocode(location:), 方法,这也返回了一个 promise。返回反地理编码的位置。
经过 promise。两个异步动做被连接在 3 行代码里。因为所有的错误处理都由调用者的 catch 块处理,咱们也不需要显式的异常处理。
运行 app。接受地理位置受权请求。你当前位置(模拟器)的温度显示了。成功了。
https://koenig-media.raywenderlich.com/uploads/2016/10/2_build_and_run_with_location.png
干得不错,但用户想知道其余地方的气温怎么办?
在 WeatherViewController.swift 中将 textFieldShouldReturn(_:) 替换为(临时不用管编译器报的“missing method”错误):
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
guard let text = textField.text else { return true }
_ = locationHelper.searchForPlacemark(text: text).then { placemark -> Void in
self.handleLocation(placemark: placemark)
}
return true
}
这里使用了和其余几个 promise 的一样模板:查找地名,找到后刷新 UI。
而后,在 LocationHelper.swift 加入方法:
func searchForPlacemark(text: String) -> Promise<CLPlacemark> {
return CLGeocoder().geocode(text)
}
很是easy!PromiseKit 已经对 CLGeocoder 进行了扩展,会查找匹配的 placemark 并用一个 promise 返回 placemark。
运行 app,此次在顶部搜索栏中输入一个城市名称后点击回车。这回找到一个最匹配的城市名并获取天气信息。
咱们已经习惯了将所有的块都放在主线程中运行。这是一个很是好的特性。因为 view controller 中大部分工做都和刷新 UI 有关。但是,对于耗时任务,应当在后台线程中进行。这样不会堵塞 app。
咱们接下来会从 OpenWeatherMap 载入一个图标,以表示当前的天气情况。
在 WeatherHelper 的 getWeather(latitude:longitude:) 方法后加入这种方法:
func getIcon(named iconName: String) -> Promise<UIImage> {
return Promise { fulfill, fail in
let urlString = "http://openweathermap.org/img/w/\(iconName).png"
let url = URL(string: urlString)!
let request = URLRequest(url: url)
let session = URLSession.shared
let dataPromise: URLDataPromise = session.dataTask(with: request)
let backgroundQ = DispatchQueue.global(qos: .background)
_ = dataPromise.then(on: backgroundQ) { data -> Void in
let image = UIImage(data: data)!
fulfill(image)
}.catch(execute: fail)
}
}
这里。咱们在 then(on:execute:) 方法中用 on 參数指定图片的载入在后台队列中进行。PromiseKit 会将繁重任务放到指定的 dispatch 中进行。
现在。promise 在后台队列中被兑现,这样调用者就必须本身保证 UI 刷新在主队列中进行了。
回到 WeatherViewController.swift。在 handleLocation(city:state:latitude:longitude:) 方法中,将调用 getWeather(latitude:longitude:) 的语句改动为:
// 1
weatherAPI.getWeather(latitude: latitude, longitude: longitude).then { weather -> Promise<UIImage> in
self.updateUIWithWeather(weather: weather)
// 2
return self.weatherAPI.getIcon(named: weather.iconName)
// 3
}.then(on: DispatchQueue.main) { icon -> Void in
self.iconImageView.image = icon
}.catch { error in
self.tempLabel.text = "--"
self.conditionLabel.text = error.localizedDescription
self.conditionLabel.textColor = errorColor
}
在这个调用中。有 3 个地方与以前有细微的差异:
这样,承诺以一种顺序运行的步骤连接在一块儿。当一个承诺兑现。下一个承诺会被运行时,以此类推直到最后一个 then 或者有发生错误——即 catch 块被调用。这样的方式比起嵌套完毕块来讲有两大优势:
每个 then 块都有单独的上下文,避免逻辑和状态相互污染。竖列的代码块不需要很是多缩进,读起来更加轻松。
运行 app。图片显示了!
怎样调用不支持 PromiseKit 的老代码、SDK 或者第三方库?PromiseKit 有一个 promise wrapper。
以咱们的 APP 为例。因为天气情况老是有限的。没有必要每次都从 web 抓取表示天气情况的图片,不但效率低下,而且会形成浪费。
在 WeatherHelper.swift 已经有一个辅助方法,将图片载入并保存到本地缓存中。
这些函数在后台线程中进行文件 IO 操做,当操做完毕时调用异步完毕块。
这是最普通的方法,PromiseKit 提供了一种替代方法。
将 WeatherHelper 中的 getIcon(named:) 替换为(一样, 临时忽略编译器的报警):
func getIcon(named iconName: String) -> Promise<UIImage> {
// 1
return wrap {
// 2
getFile(named: iconName, completion: $0)
} .then { image in
if image == nil {
// 3
return self.getIconFromNetwork(named: iconName)
} else {
// 4
return Promise(value: image!)
}
}
}
代码解释例如如下:
) -> Void, 它会被转换成一个 Promise。
在 wrap 的块中,调用了这个函数,将完毕块參数传入。
这是一种新的 promise 的使用方法。假设建立承诺时使用一个已经兑现的值。将立刻调用 then 块。这样,假设图片已经在本地,它会立刻返回。
这样的方式既可以建立一个承诺去异步运行某件事情(比方从网络载入),也能同步运行某件事情(比方使用一个内存中的值)。这在你有本地缓存时是实用的,比方这里的图片。
要让上述代码可以工做,咱们必须在获取到图片时对它进行缓存。在前面的方法后面加入方法:
func getIconFromNetwork(named iconName: String) -> Promise<UIImage> {
let urlString = "http://openweathermap.org/img/w/\(iconName).png"
let url = URL(string: urlString)!
let request = URLRequest(url: url)
let session = URLSession.shared
let dataPromise: URLDataPromise = session.dataTask(with: request)
return dataPromise.then(on: DispatchQueue.global(qos: .background)) { data -> Promise<UIImage> in
return firstly { Void in
return wrap { self.saveFile(named: iconName, data: data, completion: $0)}
}.then { Void -> Promise<UIImage> in
let image = UIImage(data: data)!
return Promise(value: image)
}
}
}
和先前的 getIcon(named:)方法同样。但是在 dataPromise 的 then 块中调用了 saveFile 方法,这种方法进行了和 getFile 方法同样的封装。
这里用到了一个新结构,firstly。
firstly 是一个语法糖。简单滴运行它的承诺。事实上仅仅是加入了一层封装以便更易读。因为 saveFile 方法调用是载入图标后的一个附带功能。用 firstly 可以确保运行的顺序以便咱们可以对这个承诺更有信心一点。
当你第一次请求图片时会是这个样子:
假设你运行 app,不会有什么不一样,但经过文件系统你可以看到图片都被保存了。你可以在控制台中搜索 Save iamge to:,它会显示文件保存的 URL 地址,你可以在硬盘上找到这个文件:
看过了 PromiseKit 的语法,你可能会问:既然有 then 和 catch,那么有 finally 吗(比方运行一些清理),确保某些动做老是会发生,而无论是否成功?答案是:always。
在 WeatherViewController.swift 中改动handleLocation(city:state:latitude:longitude:),当从server抓取天气数据时。在状态栏中显示一个小菊花。
在调用 weatherAPI.getWeather… 以前插入代码:
UIApplication.shared.isNetworkActivityIndicatorVisible = true
而后。在 catch 块后面加入:
.always {
UIApplication.shared.isNetworkActivityIndicatorVisible = false
}
而后,为了让编译器再也不报“unused result”警告。将整个表达式赋给一个 _。
这是 always 的一个常规使用方法。无论是载入成功仍是出错,以及网络活动是否完毕,网络活动状态都应当隐藏。
类似的,可以用 always 关闭 socket。数据库链接或者断开硬件服务。
有一种例外状况。即承诺会在数据有效并通过某个固定时间周期以后才会兑现。
当前。当天气信息载入后就不会刷新了。咱们可以改动它,让它每隔一个小时就更新一次。
在 updateWithCurrentLocation() 方法最后加入代码:
_ = after(interval: oneHour).then {
self.updateWithCurrentLocation()
}
.after(interval:) 建立一个承诺,以指定时间间隔兑现。不幸的是,这是一个一次性的定时器。要每小时刷新一次,需要在 updateWithCurrentLocation() 中递归。
当前的所有承诺都是孤立或者以某种顺序链式运行。PromiseKit 也提供了一个功能,将多个承诺同一时候兑现。
有 3 个函数用于等待多个承诺。第一个 race。当一堆承诺兑现时返回第一个承诺。
也就是,第一个完毕的胜出。
另外两个函数是 when 和 join。它们都是在指定的承诺被兑现以后调用。仅仅是 rejected 块有所不一样。join 在拒绝以前老是等待所有的承诺完毕。看它们之中是否有被拒绝的。
而 when(fulfilled:) 仅仅要有不论什么一个承诺被拒绝它就拒绝。另外。when(resolved:) 会等待所有的承诺完毕,但 then 块总会调用,catch 块永远不会调用。
注:对于所有的聚合函数,所有的单一承诺都会继续指导它们要么兑现要么拒绝,无论聚合函数的行为是什么。好比,假设三个承诺使用了 race 函数,当第一个承诺完毕时,then 块被调用。
但是其余两个未知足的承诺仍然会继续运行,一直到它们也被解决。
以随机显示随意城市的天气为例。因为用户不知道会显示什么城市,app 一次会抓取多个城市,但它仅仅处理第一个城市。
这会形成一种随机的假象。
将 showRandomWeather(_:) 替换为:
@IBAction func showRandomWeather(_ sender: AnyObject) {
let weatherPromises = randomCities.map { weatherAPI.getWeather(latitude: $0.2, longitude: $0.3) }
_ = race(promises: weatherPromises).then { weather -> Void in
self.placeLabel.text = weather.name
self.updateUIWithWeather(weather: weather)
self.iconImageView.image = nil
}
}
这里咱们建立了多个承诺去抓取城市列表中的天气。这些承诺用 race(promises:) 函数造成进行竞争关系。仅仅有第一个被知足的承诺会调用 then 块。理论上,这是一种随机选择,因为server情况是不定的,但这个样例的说服力不是很是强。注意所有的承诺都会继续运行,所以仍然会有 5 个网络调用,固然仅仅有一个承诺会被关心。
运行 app。当 app 启动,点击 Random Weather。
关于天气图标的刷新和错误处理就留给读者练练手了 ;]
在这里下载最后完毕项目。
请在阅读 PromiseKit 文档:http://promisekit.org/,虽然它看起来很是难。FAQ http://promisekit.org/faq/ 对于调试信息很是有帮助。
PromiseKit 是一个活跃的 pod,为了在本身的项目中安装 cocoapods,并保持它的更新,你可能需要研究一下 CocoaPods 的使用方法。
最后说一句,Promise 还有其余 Swift 实现。当中一个流行的实现就是 BrightFutures。 假设你有不论什么建议、问题和评论,请在如下留言。