版本记录
版本号 | 时间 |
---|---|
V1.0 | 2018.12.13 星期四 |
前言
PromiseKit(GitHub地址) 只是 Promise 设计模式的一种实现方式。并不是我们项目中必须采用的一种方式,但是它可以增强代码的可读性和维护性,让代码更加的优雅。接下来我们就一起看一下这个框架。感兴趣的看下面几篇文章。
1. PromiseKit框架详细解析(一) —— 基本概览(一)
开始
首先看下写作环境
Swift 4.2, iOS 12, Xcode 10
异步编程可能是真正痛苦。 除非你非常小心,否则它很容易导致庞大的代理,凌乱的完成处理程序和漫长的夜晚调试代码! 但有更好的方法:promises
。 通过让您将代码编写为基于事件的一系列操作来让promises
处理异步性。 这对于必须按特定顺序发生的操作尤其有效。 在这个PromiseKit
教程中,您将学习如何使用第三方PromiseKit
来清理异步代码。
通常,iOS编程涉及许多代理和回调。 您可能已经在这些方面看到了很多代码:
- Y manages X.
- Tell Y to get X.
- Y notifies its delegate when X is available.
Promises
试图简化这个混乱让其看起来更像这样:
When X is available, do Y.
这看起来不令人愉快吗?Promises
还允许您分离错误和成功处理,这使得编写处理许多不同条件的干净代码变得更容易。 它们非常适用于复杂的多步骤工作流程,例如登录Web服务,执行经过身份验证的SDK调用,处理和显示图像等等!
Promises
变得越来越普遍,有许多可用的解决方案和实现。 在本教程中,您将通过使用名为PromiseKit的流行的第三方Swift库来了解promises
。
本教程的项目WeatherOrNot
是一个简单的当前天气应用程序。它使用OpenWeatherMap作为其天气API。您可以将用于访问此API的模式和概念转换为任何其他Web服务。
起始项目已经使用CocoaPods
捆绑了PromiseKit
,所以不需要自己安装它。
打开WeatherOrNot.xcworkspace
,你会发现项目非常简单。它只有五个.swift
文件:
- AppDelegate.swift:自动生成的应用程序代理文件。
- BrokenPromise.swift:一个占位符
promise
,用于存根启动项目的某些部分。 - WeatherViewController.swift:用于处理所有用户交互的主视图控制器。这将是
promise
的主要使用者。 - LocationHelper.swift:
CoreLocation
的包装器。 - WeatherHelper.swift:用于包装天气数据提供程序的最后一个帮助程序。
1. The OpenWeatherMap API
说到天气数据,WeatherOrNot
使用OpenWeatherMap来获取天气信息。 与大多数第三方API一样,这需要开发人员API
密钥才能访问该服务。 别担心;有一个免费的层,足以完成本教程。
您需要获取应用的API
密钥。 您可以在http://openweathermap.org/appid上找到一个。 完成注册后,您可以在https://home.openweathermap.org/api_keys找到您的API密钥。
复制您的API
密钥并将其粘贴到WeatherHelper.swift
顶部的appID
常量中。
2. Trying It Out
构建并运行应用程序。 如果一切顺利,你应该看到雅典当前的天气。
好吧,也许......该应用程序实际上有一个错误(你很快就会解决它!),所以用户界面显示起来可能有点慢。
Understanding Promises - 了解Promises
你已经知道日常生活中的“承诺”是什么。例如,您可以在完成本教程时向自己承诺冷饮。该声明包含一个动作(“喝冷饮”),该动作将在动作完成时发生(“你完成本教程”)。使用promises
进行编程是类似的,因为人们期望在将来某些数据可用时会发生某些事情。
Promises
是关于管理异步性。与传统方法(如回调或代理)不同,您可以轻松地将promises
链接在一起以表示一系列异步操作。 Promise
也像操作一样,它们具有执行生命周期,因此您可以随意轻松取消它们。
当您创建PromiseKit Promise
时,您将提供自己的异步代码来执行。异步工作完成后,您将使用一个值来兑现(fulfill)
Promise
,这将导致Promise
的then
块执行。如果then
然后从该块返回另一个promise
,它也将执行,用它自己的值来实现,依此类推。如果在此过程中出现错误,则会执行可选的catch
块。
例如,上面的promise
,改为PromiseKit Promise
,如下所示:
doThisTutorial()
.then { haveAColdOne() }
.catch { postToForum(error) }
1. What PromiseKit… Promises
PromiseKit
是Swift实现的promises
。 虽然它不是唯一的,但它是最受欢迎的之一。 除了提供用于构造promises
的基于块的结构之外,PromiseKit
还包括许多常见iOS SDK
类的包装器以及简单的错误处理。
要查看action
中的promise
,请查看BrokenPromise.swift
中的函数:
func brokenPromise(method: String = #function) -> Promise {
return Promise() { seal in
let err = NSError(
domain: "WeatherOrNot",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "'\(method)' not yet implemented."])
seal.reject(err)
}
}
这将返回一个新的通用Promise
,它是PromiseKit
提供的主类。 它的构造函数采用一个带有一个参数的简单执行块,即seal
,它支持三种可能的结果之一:
-
seal.fulfill
:在期望值准备就绪时履行承诺。 -
seal.reject
:如果发生错误,则拒绝承诺错误。 -
seal.resolve
:使用错误或值来解析promise
。 在某种程度上,fulfill
和reject
是在resolve
有效的助手。
对于brokenPromise(method:)
,代码总是返回错误。 您可以使用此helper
函数来指示在您充实应用程序时仍有工作要做。
Making Promises
访问远程服务器是最常见的异步任务之一,直接的网络调用是一个很好的起点。
在WeatherHelper.swift
中查看getWeatherTheOldFashionedWay(coordinate:completion :)
。 该方法获取给定纬度,经度的天气数据和completion
闭包。
但是,completion
闭包会在成功和失败时执行。 这导致了一个复杂的闭包,因为你需要代码来处理错误和成功。
最令人震惊的是,应用程序在后台线程上处理数据任务completion
,这导致(意外)在后台更新UI!
使用promises
有帮助吗? 当然!
在getWeatherTheOldFashionedWay(coordinate:completion:)
之后添加以下内容:
func getWeather(
atLatitude latitude: Double,
longitude: Double
) -> Promise {
return Promise { seal in
let urlString = "http://api.openweathermap.org/data/2.5/weather?" +
"lat=\(latitude)&lon=\(longitude)&appid=\(appID)"
let url = URL(string: urlString)!
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data,
let result = try? JSONDecoder().decode(WeatherInfo.self, from: data) else {
let genericError = NSError(
domain: "PromiseKitTutorial",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Unknown error"])
seal.reject(error ?? genericError)
return
}
seal.fulfill(result)
}.resume()
}
}
此方法也使用像getWeatherTheOldFashionedWay
那样的URLSession
,但不是completion
闭包,而是将您的网络包装在Promise
中。
在dataTask
的completion
处理程序中,如果您获得成功的JSON
响应,则将其解码为WeatherInfo
并fulfill
您的承诺。
如果您收到网络请求的错误,则根据该错误reject
该promise
,如果发生任何其他类型的失败,则会回退到一般错误。
接下来,在WeatherViewController.swift
中,将handleLocation(city:state:coordinate :)
替换为以下内容:
private func handleLocation(
city: String?,
state: String?,
coordinate: CLLocationCoordinate2D
) {
if let city = city,
let state = state {
self.placeLabel.text = "\(city), \(state)"
}
weatherAPI.getWeather(
atLatitude: coordinate.latitude,
longitude: coordinate.longitude)
.done { [weak self] weatherInfo in
self?.updateUI(with: weatherInfo)
}
.catch { [weak self] error in
guard let self = self else { return }
self.tempLabel.text = "--"
self.conditionLabel.text = error.localizedDescription
self.conditionLabel.textColor = errorColor
}
}
太好了! 使用promise
就像提供done
和catch
闭包一样简单!
handleLocation
的这个新实现优于前一个。 首先,completion
处理现在分为两个易于阅读的闭包:done for success and catch for errors
。 其次,默认情况下,PromiseKit
在主线程上执行这些闭包,因此不会在后台线程上意外更新UI。
Using PromiseKit Wrappers - 使用PromiseKit Wrappers
这非常好,但PromiseKit
可以做得更好。 除了Promise
的代码之外,PromiseKit
还包括可以表示为promises
的常见iOS SDK
方法的扩展。 例如,URLSession
数据任务方法返回promise
而不是使用完成块。
在WeatherHelper.swift
中,使用以下代码替换新的getWeather(atLatitude:longitude :)
:
func getWeather(
atLatitude latitude: Double,
longitude: Double
) -> Promise {
let urlString = "http://api.openweathermap.org/data/2.5/weather?lat=" +
"\(latitude)&lon=\(longitude)&appid=\(appID)"
let url = URL(string: urlString)!
return firstly {
URLSession.shared.dataTask(.promise, with: url)
}.compactMap {
return try JSONDecoder().decode(WeatherInfo.self, from: $0.data)
}
}
看看使用PromiseKit
包装器有多容易? 更干净! 打破它:
PromiseKit
提供了一个新的URLSession.dataTask(_:with:)
,它返回一个表示URL请求的专用Promise
。 请注意,数据promise
会自动启动其基础数据任务。
接下来,PromiseKit
的compactMap
被链接以将数据解码为WeatherInfo
对象并从闭包中返回它。 compactMap
负责将此结果包装到Promise
中,因此您可以继续链接其他与promise
相关的方法。
Adding Location
既然网络是bullet-proofed
的,请查看定位功能。 除非你有幸访问雅典,否则该应用程序不会向您提供特别相关的数据。 更改代码以使用设备的当前位置。
在WeatherViewController.swift
中,将updateWithCurrentLocation()
替换为以下内容:
private func updateWithCurrentLocation() {
locationHelper.getLocation()
.done { [weak self] placemark in // 1
self?.handleLocation(placemark: placemark)
}
.catch { [weak self] error in // 2
guard let self = self else { return }
self.tempLabel.text = "--"
self.placeLabel.text = "--"
switch error {
case is CLError where (error as? 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
}
}
}
详细看一下上面代码:
- 1) 您使用帮助程序类来处理
Core Location
。 你马上就会实现它。getLocation()
的结果是获取当前位置的地标的promise
。 - 2) 此
catch
块演示了如何在单个catch
块中处理不同的错误。 在这里,当用户未授予位置权限与其他类型的错误时,您使用简单的switch
来提供不同的消息。
接下来,在LocationHelper.swift
中用以下替换getLocation()
:
func getLocation() -> Promise {
// 1
return CLLocationManager.requestLocation().lastValue.then { location in
// 2
return self.coder.reverseGeocode(location: location).firstValue
}
}
这利用了已经讨论过的两个PromiseKit
概念:SDK包装和链接。
在上面的代码中:
- 1)
CLLocationManager.requestLocation()
返回当前位置的promise
。 - 2) 一旦当前位置可用,您的链将其发送到
CLGeocoder.reverseGeocode(location :)
,它还返回Promise
以提供反向编码的位置。
使用promises
,您可以在三行代码中链接两个不同的异步操作! 这里不需要显式的错误处理,因为调用者的catch
块处理所有错误。
构建并运行。 接受位置权限后,应用程序会显示您(模拟)位置的当前温度。
Searching for an Arbitrary Location
这一切都很好,但如果用户想知道其他地方的温度怎么办?
在WeatherViewController.swift
中,将textFieldShouldReturn(_ :)
替换为以下内容(暂时忽略有关丢失方法的编译器错误):
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
guard let text = textField.text else { return false }
locationHelper.searchForPlacemark(text: text)
.done { placemark in
self.handleLocation(placemark: placemark)
}
.catch { _ in }
return true
}
这使用与所有其他promises
相同的模式:找到地标,并在完成后更新UI。
接下来,将以下内容添加到LocationHelper.swift
中,位于getLocation()
下面:
func searchForPlacemark(text: String) -> Promise {
return coder.geocode(text).firstValue
}
就这么简单! PromiseKit
已经为CLGeocoder
提供了一个扩展程序,用于查找带有地标的promise
的地标。
构建并运行。 这次,在顶部的搜索字段中输入城市名称,然后按Return
键。 然后,应该找到该名称最佳匹配的天气。
Threading
到目前为止,你已经把一件事视为理所当然:所有then
块都在主线程上执行。 这是一个很棒的功能,因为视图控制器中的大多数工作都会更新UI。 但是,最好在后台线程上处理长时间运行的任务,以免使应用程序响应用户的操作变慢。
接下来,您将从OpenWeatherMap
添加天气图标,以说明当前的天气状况。 但是,将原始Data
解码为UIImage
是一项繁重的任务,您不希望在主线程上执行该任务。
回到WeatherHelper.swift
,在getWeather(atLatitude:longitude :)
之后添加以下方法:
func getIcon(named iconName: String) -> Promise {
let urlString = "http://openweathermap.org/img/w/\(iconName).png"
let url = URL(string: urlString)!
return firstly {
URLSession.shared.dataTask(.promise, with: url)
}
.then(on: DispatchQueue.global(qos: .background)) { urlResponse in
Promise.value(UIImage(data: urlResponse.data)!)
}
}
在这里,您通过on
参数提供给then(on:execute:)
的DispatchQueue
后台队列,从中加载的Data
构建UIImage
。 PromiseKit
然后在提供的队列上执行then
块。
现在,您的promise
在后台队列上运行,因此调用者需要确保主队列上的UI更新。
回到WeatherViewController.swift
,用handleMocation(city:state:coordinate :)
替换对getWeather(atLatitude:longitude :)
的调用:
// 1
weatherAPI.getWeather(
atLatitude: coordinate.latitude,
longitude: coordinate.longitude)
.then { [weak self] weatherInfo -> Promise in
guard let self = self else { return brokenPromise() }
self.updateUI(with: weatherInfo)
// 2
return self.weatherAPI.getIcon(named: weatherInfo.weather.first!.icon)
}
// 3
.done(on: DispatchQueue.main) { icon in
self.iconImageView.image = icon
}
.catch { error in
self.tempLabel.text = "--"
self.conditionLabel.text = error.localizedDescription
self.conditionLabel.textColor = errorColor
}
这个调用有三个微妙的变化:
- 1) 首先,你改变
getWeather(atLatitude:longitude :)
的then
block,然后返回Promise
而不是Void
。这意味着,当getWeather
的promise
完成时,您将返回一个新的promise
。 - 2) 您可以使用刚添加的
getIcon
方法创建新的promise
来获取图标。 - 3) 您向链中添加一个新的
done
闭包,当getIcon
promise完成时,它将在主队列上执行。
注意:您实际上不需要为
done
块指定DispatchQueue.main
。默认情况下,所有内容都在主队列上运行。它包含在这里是为了强化这一事实。
因此,您可以将promises
链接到一系列连续执行的步骤中。在履行一个promise
之后,下一个promise
将执行,依此类推,直到最后done
或发生错误并执行catch
。这种方法优于嵌套completions
的两大优点是:
- 1) 您可以在单个链中编写
promise
,这很容易阅读和维护。每个then / done
块都有自己的上下文,保持逻辑和状态不会相互渗透。如果没有不断加深的缩进,一列块就更容易阅读。 - 2) 您可以在一个位置处理所有错误。例如,在用户登录等复杂的工作流程中,如果任何步骤失败,则可以显示单个重试错误对话框。
构建并运行。图像图标现在应该加载!
Wrapping in a Promise
使用内置没有PromiseKit
支持的现有代码,SDK或第三方库怎么样? 好吧,为此,PromiseKit
带有一个promise
包装器。
举个例子,这个应用程序。 由于天气条件有限,因此无需每次都从网上获取条件图标;它效率低,而且成本高昂。
在WeatherHelper.swift
中,已经存在用于从本地缓存目录保存和加载图像文件的辅助函数。 这些函数在后台线程上执行文件I / O,并在操作完成时使用异步completion
块。 这是一种常见的模式,因此PromiseKit
有一种内置的处理方式。
将WeatherHelper
中的getIcon(named:)
替换为以下内容(同样,暂时忽略有关缺少方法的编译器错误):
func getIcon(named iconName: String) -> Promise {
return Promise {
getFile(named: iconName, completion: $0.resolve) // 1
}
.recover { _ in // 2
self.getIconFromNetwork(named: iconName)
}
}
以下是此代码的工作原理:
- 1) 你像以前一样构建一个
Promise
,只有一个小的区别 - 你使用Promise
的resolve
方法而不是fulfill
和reject
。由于getFile(named:completion :)
的completion
闭包的签名与resolve
方法的签名匹配,因此传递对它的引用将自动处理所提供的completion
闭包的所有结果情况。 - 2) 这里,如果图标在本地不存在,则执行
recover
闭包,并使用另一个承诺通过网络获取它。
如果未fulfilled
使用值创建的promise
,PromiseKit
将调用其recover
闭包。否则,如果图像已经加载并准备就绪,则可以立即返回,而无需调用recover
。这种模式是你如何创建一个可以异步(比如从网络加载)或同步(比如使用内存中的值)做一些事情的promise
。当您具有本地缓存的值(例如图像)时,这非常有用。
要使其工作,您必须在图像进入时将图像保存到缓存中。在上一个方法的下方添加以下内容:
func getIconFromNetwork(named iconName: String) -> Promise {
let urlString = "http://openweathermap.org/img/w/\(iconName).png"
let url = URL(string: urlString)!
return firstly {
URLSession.shared.dataTask(.promise, with: url)
}
.then(on: DispatchQueue.global(qos: .background)) { urlResponse in
return Promise {
self.saveFile(named: iconName, data: urlResponse.data, completion: $0.resolve)
}
.then(on: DispatchQueue.global(qos: .background)) {
return Promise.value(UIImage(data: urlResponse.data)!)
}
}
}
这与之前的getIcon(named:)
类似,只是在dataPromise
的then
块中,调用saveFile
,就像在getFile
中一样。
这使用了一个名为firstly
的结构。firstly
只是履行其promise
。除了为可读性添加一个间接层之外,它并没有真正做任何其他事情。由于对saveFile
的调用只是加载图标的副作用,因此此处使用firstly
强制执行一些排序。
总而言之,这是您第一次申请图标时会发生什么:
- 1) 首先,为图标发出
URLSession
请求。 - 2) 完成后,将数据保存到文件中。
- 3) 在本地保存图像后,将数据转换为图像并将其发送到链中。
如果您现在构建并运行,您应该看不到应用程序功能的任何差异,但您可以检查文件系统以查看图像是否已在本地保存。为此,请在控制台输出中搜索Saved image to:
:这将显示新文件的URL,您可以使用该URL查找其在磁盘上的位置。
Ensuring Actions
看看PromiseKit
语法,你可能会问:如果有一个then
和一个catch
,有没有办法共享代码并确保一个action
总是运行(如清理任务),无论成功与否? 好吧,有:它被称为finally
。
在WeatherViewController.swift
更新handleLocation(city:state:coordinate :)
以在使用Promise
从服务器获取天气时在状态栏中显示网络活动指示器。
在调用weatherAPI.getWeather...
之前插入以下行:
UIApplication.shared.isNetworkActivityIndicatorVisible = true
然后,将以下链接到catch
闭包的末尾:
.finally {
UIApplication.shared.isNetworkActivityIndicatorVisible = false
}
这是何时使用finally
的规范示例。 无论天气是否完全加载或是否存在错误,负责网络活动的Promise
都将结束,因此您应该始终关闭活动指示器。 同样,您可以使用它来关闭sockets
,database connections
或断开与硬件服务的连接。
Implementing Timers
一个特殊情况,不是在某些数据准备就绪时,但在一定时间间隔之后fulfilled
承诺。 目前,应用程序加载天气后,它永远不会刷新。 改变它来每小时更新一次天气。
在updateWithCurrentLocation()
中,将以下代码添加到方法的末尾:
after(seconds: oneHour).done { [weak self] in
self?.updateWithCurrentLocation()
}
.after(seconds :)
创建一个在指定的秒数过后完成的promise
。 不幸的是,这是一次性计时器。 为了每小时进行一次更新,它是onupdateWithCurrentLocation()
的递归。
Using Parallel Promises
到目前为止,这里讨论的所有promises
要么是独立的,要么按顺序链接在一起。 PromiseKit
还提供了并行处理多个promises
的功能。等待多个promises
有两个函数。第一个- race
- 返回一个promise
,当一组promise
中的第一个fulfilled
时,履行承诺。从本质上讲,第一个完成的是胜利者。
另一个函数是when
。它在满足所有指定的promises
后履行。when(fulfilled:)
以任何一个promise
做出的拒绝结束。还有一个when(resolved:)
等待所有promises
完成,但总是调用then
块而不是catch
。
注意:对于所有这些分组函数,无论组合函数的行为如何,所有单个
promise
将一直持续到它们满足或拒绝为止。例如,如果您在race
中使用三个promises
,则在第一个promise
完成后race
的then
闭包被执行。然而,其他两个未实现的承诺继续执行,直到他们也解决。
以一个人为的例子来展示一个“随机”城市的天气。由于用户不关心它将显示哪个城市,该应用程序可以尝试获取多个城市的天气,但只需处理第一个完成的城市。这给出了随机性的错觉。
用以下内容替换showRandomWeather(_ :)
:
@IBAction func showRandomWeather(_ sender: AnyObject) {
randomWeatherButton.isEnabled = false
let weatherPromises = randomCities.map {
weatherAPI.getWeather(atLatitude: $0.2, longitude: $0.3)
}
UIApplication.shared.isNetworkActivityIndicatorVisible = true
race(weatherPromises)
.then { [weak self] weatherInfo -> Promise in
guard let self = self else { return brokenPromise() }
self.placeLabel.text = weatherInfo.name
self.updateUI(with: weatherInfo)
return self.weatherAPI.getIcon(named: weatherInfo.weather.first!.icon)
}
.done { icon in
self.iconImageView.image = icon
}
.catch { error in
self.tempLabel.text = "--"
self.conditionLabel.text = error.localizedDescription
self.conditionLabel.textColor = errorColor
}
.finally {
UIApplication.shared.isNetworkActivityIndicatorVisible = false
self.randomWeatherButton.isEnabled = true
}
}
在这里,您创建了一系列promises
来获取一系列城市的天气。 然后这些承诺在race(promises:)
中raced
。 只有在第一个promise
满足时才执行then
闭包。 done
块更新图像。 如果发生错误,catch
闭包将负责UI清理。 最后,剩下的finally
确保您的活动指示器被清除并重新启用按钮。
理论上,由于服务器条件的变化,这应该是一个随机选择,但它不是一个很好的例子。 另请注意,所有promises
仍将resolve
,因此仍然有五个网络调用,即使您只关心一个。
构建并运行。 加载应用后,点击Random Weather
。
您可以在http://promisekit.org/上阅读PromiseKit
的文档,尽管它并不全面。 常见问题解答http://promisekit.org/faq/ i对于调试信息非常有用。
最后,还有其他Swift实现的promise
。 一个流行的替代品是BrightFutures。
后记
本篇主要讲述了基于PromiseKit的天气应用的简单示例,感兴趣的给个赞或者关注~~~