异步编程真的让人头疼。不管你怎样小心,总是easy出现臃肿的托付、混乱的完毕句柄以及长时间的代码调试!
幸运的是,如今有一个更好的办法:promise。Promise 能够让你以基于事件的方式编写一连串的动作来实现异步。对于须要以确定顺序运行的动作尤事实上用。在本教程中,你将学习怎样使用第三方框架 PromiseKit 来让你的异步代码和头脑同一时候保持清晰。
通常,iOS 开发中都会有很多托付和回调。
你可能写过很多相似这样的代码:
- Y 负责管理 X。
- 告诉 Y 去抓取 X。
- 当 X 可用的时候,Y 通知它的托付对象。
Promise 将这样的如同乱麻的关系理清成这个样子:
当 X 可用时,运行 Y。
是不是优雅多了?Promise 还能够将错误处理和成功代码分离开来,导致代码在处理各种条件的时候更加清晰。它们能够非常好滴解决复杂的、步骤繁多的工作流,比方web登录,运行 SDK 登录认证、处理和显示图片等。
Promise 是比較常见的,它的实现方式也非常多,但在这篇教程中,我们将学习一个比較时髦的第三方 Swift 框架,叫做 PromiseKit。
開始
本文的演示样例项目是 WeatherOrNot,它是一个简单的实时天气应用。
它的天气 API 用的是 OpenWeatherMap。訪问这个 API 的模式和概念能够用到随意 web 服务上。
从这里下载開始项目。PromiseKit 通过 cocoapods 公布,但開始项目中已经包括了这个 pod。假设你曾经没用过 CocoaPods,请參考这篇教程。
否则请直接在 CocoaPods 中安装 PromiseKit。除此之外。本教程不须要不论什么其他 CocoaPods 知识。
打开 PromiseKitTutorial.xcworkspace,你会看到项目结构非常easy。仅仅有 5 个 swift 文件:
- AppDelegate.swift: 自己主动生成的 app delegate 文件。
- BrokenPromise.swift: 这个文件创建了一个空的 promise,用于临时构成開始项目的一个部分。
- WeatherViewController.swift: 处理全部与用户交互的 view controller。
这也是 Promise 的主要消费者。
- LocationHelper.swift: 一个辅助文件,用于实现 CoreLocation。
- WeatherHelper.swift: 一个辅助文件。用于包装天气数据提供者。
OpenWeatherMap API
关于天气数据,这个 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
在日常生活中的承诺(promise)你肯定知道。
比如,你能够许诺自己在完毕本程之后来一本冷饮。
这个叙述中包括了一个动作(“来一杯饮料”),这个动作会在还有一个动作完毕(“完毕这篇教程”)之后发生。变成中的承诺与此相似,即期望某些事情在未来当某些数据到达之后被运行。
承诺被用于实现异步。和传统方法。比方通过完毕块或选择器进行回调不同,承诺可能被简单地进行链式连接。从而表达一连串异步动作。承诺和 Operation 有点像。也有一个运行生命周期并能被取消。
一个 PromiseKit 中的承诺会运行一个代码块。这个代码块应当用一个值来满足(或兑现)。
假设这个值被兑现了,代码块就会被运行。假设这个块返回了一个承诺,则这个承诺也会运行(某个值被兑现),以此类推。假设在这个过程中发生错误,一个可选的 catch 块将替代这个块运行。
比如。一个 Promisekit 承诺的口语化描写叙述是这个样子:
doThisTutorial().then { haveAColdOne() }.catch { postToForum(error) }
关于 PromiseKit 中的承诺
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 中的核心类。它的构造函数使用一个块參数,这个块有两个參数:
- fulfill: 一个函数,当这个承诺所需的值被兑现时,调用这个函数。
- reject: 一个函数。当发生错误时,调用这个函数。
对于 BrokePromise 来说,代码仅仅会返回一个错误。这个辅助对象用于在你真正实现这个 app 时提示你仍然有功能须要实现。
创建 promise 对象
訪问远程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 的发生错误。
PromiseKit Wrapper
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 就这么简单!解释一下上述代码:
- PromiseKit 提供了一个 URLSession.dataTask(with:) 方法的重载,让它返回一个 URLDataPromise,这是一个类型化的 Promise。注意,data promise 自己主动启动它所包括的 data task。
- 所返回的这个 dataPromise 有一个便利方法 asDictionary(),这种方法会为你处理 JSON 的序列化,能够大大节省你的代码!
- 由于已经解析好 dictionary,能够直接用它创建一个 result 对象。我们用 guard let 语句确保从 dictionary 创建 Weather 对象一定成功。假设不,创建一个 NSError 并调用 reject 函数,和前面一样。否则,用 result 对象调用 fulfill 函数。
- 在这个过程中。有可能网络请求失败。或者 JSON 序列化失败。
在之前的方法中我们必须分别检查这两种情况。而这里。仅仅须要一个 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 个地方与之前有细微的差别:
- 首先,getWeather(latitude:longitude:) 的 then 块的返回值由原来的 Void 改动 promise。这意味着当 getWeather 的承诺兑现时, 又给出了还有一个承诺。
- 用 getIco 方法创建一个新的承诺…以得到一个图标。
- 在承诺链中再加一个 then 块,当 getIcon 被兑现时,这个块要在主线程中运行。
这样,承诺以一种顺序运行的步骤链接在一起。当一个承诺兑现。下一个承诺会被运行时,以此类推直到最后一个 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!)
}
}
}
代码解释例如以下:
- wrap(body:) 能够将跟在多个完毕块后面的某个函数封装成一个承诺。
- getFile(named: completion:) 有一个完毕块參数 @escaping (UIImage?
) -> Void, 它会被转换成一个 Promise。
在 wrap 的块中,调用了这个函数,将完毕块參数传入。
- 假设图片未缓存到本地。返回一个承诺,从网络抓取图片。
- 假设图片缓存有效,返回一个值承诺(value promise)。
这是一种新的 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 能够确保运行的顺序以便我们能够对这个承诺更有信心一点。
当你第一次请求图片时会是这个样子:
- 首先,载入一个 URLRequest。
- 载入成功后。数据保存到文件里。
- 保存完后,将数据转换成图片传递给下个承诺链。
假设你运行 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。
假设你有不论什么建议、问题和评论,请在以下留言。