PromiseKit框架详细解析(二) —— 基于PromiseKit的天气应用的简单示例(一)

版本记录

版本号 时间
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.swiftCoreLocation的包装器。
  • 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,这将导致Promisethen块执行。如果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。 在某种程度上,fulfillreject是在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中。

dataTaskcompletion处理程序中,如果您获得成功的JSON响应,则将其解码为WeatherInfofulfill您的承诺。

如果您收到网络请求的错误,则根据该错误rejectpromise,如果发生任何其他类型的失败,则会回退到一般错误。

接下来,在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就像提供donecatch闭包一样简单!

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会自动启动其基础数据任务。

接下来,PromiseKitcompactMap被链接以将数据解码为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构建UIImagePromiseKit然后在提供的队列上执行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。这意味着,当getWeatherpromise完成时,您将返回一个新的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,只有一个小的区别 - 你使用Promiseresolve方法而不是fulfillreject。由于getFile(named:completion :)completion闭包的签名与resolve方法的签名匹配,因此传递对它的引用将自动处理所提供的completion闭包的所有结果情况。
  • 2) 这里,如果图标在本地不存在,则执行recover闭包,并使用另一个承诺通过网络获取它。

如果未fulfilled使用值创建的promisePromiseKit将调用其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:)类似,只是在dataPromisethen块中,调用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都将结束,因此您应该始终关闭活动指示器。 同样,您可以使用它来关闭socketsdatabase 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完成后racethen闭包被执行。然而,其他两个未实现的承诺继续执行,直到他们也解决。

以一个人为的例子来展示一个“随机”城市的天气。由于用户不关心它将显示哪个城市,该应用程序可以尝试获取多个城市的天气,但只需处理第一个完成的城市。这给出了随机性的错觉。

用以下内容替换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的天气应用的简单示例,感兴趣的给个赞或者关注~~~

你可能感兴趣的:(PromiseKit框架详细解析(二) —— 基于PromiseKit的天气应用的简单示例(一))