如何用 Siesta 编写 RESTful app

原文:How to make a RESTful app with Siesta
作者:Sanket Firodiya
译者:kmyhy

通过网络获取数据是移动应用程序中最常见的一种任务。因此,像 afnetwork 和 Alamofire 这样的网络库在iOS开发者中大受欢迎,也就不奇怪了。

即使是这样,你仍然要在 app 中编写和管理大量重复代码,以便从网络获取和显示数据。其中一些任务包括:

  1. 管理重复的请求。
  2. 当不再需要时取消请求,比如用户离开页面时。
  3. 在后台线程中抓取和处理数据,在主线程中更新 UI。
  4. 将响应解析和转换成模型。
  5. 显示、隐藏加载进度。
  6. 收到数据时显示数据。

Siesta 是一个网络库,它自动完成这些任务并简化抓取和显示网络数据的代码。

Siesta 采用了以资源为中心而不是以请求为中心的策略,提供了一种在 app 范围内的可观察 RESTFul 资源状态的模型。

注:本教程假设你懂得用基本的 URLSession 进行网络请求。 如果你不明白,请阅读我们的 URLSession 教程:入门教程

开始

在本教程中,你将编写一个”披萨猎手” app,允许用户搜索附近的披萨店。

警告:当本教程结束,你可能会有点饿!

使用本教程顶部或页尾的 Download Materials 按钮下载开始项目。

打开 PizzaHunter.xcworkspace 项目,Build & run。你会看到:

app 包含了两个 View controller:

  • RestaurantsListViewController: 显示某个位置附近的披萨店清单。
  • RestaurantDetailsViewController: 显示某个披萨店的详情。

因为视图控制器还没有和数据源进行连接,app 现在显示的是空白。

注:写到这里的时候 Siesta 当前版本是 1.3,它还没有升级至 Swift 4.1。编译项目时你会看到几个 deprecation 警告。不用管它们,你的项目可以正常工作。

Yelp API

你将用 Yelp API 来搜索某个城市中的披萨店。

这是获取披萨店列表的 API:

GET https://api.yelp.com/v3/businesses/search

返回的数据是:

{
  "businesses": [
    {
      "id": "tonys-pizza-napoletana-san-francisco",
      "name": "Tony's Pizza Napoletana",
      "image_url": "https://s3-media2.fl.yelpcdn.com/bphoto/d8tM3JkgYW0roXBygLoSKg/o.jpg",
      "review_count": 3837,
      "rating": 4,
      ...
    },
    {
      "id": "golden-boy-pizza-san-francisco",
      "name": "Golden Boy Pizza",
      "image_url": "https://s3-media3.fl.yelpcdn.com/bphoto/FkqH-CWw5-PThWCF5NP2oQ/o.jpg",
      "review_count": 2706,
      "rating": 4.5,
      ...
    }
  ]
}

使用 Siesta 发起网络请求

首先创建一个 YelpAPI 类。
选择 File ▸ New ▸ File 菜单,选择 Swift file 然后点 Next。文件名为 YelpAPI.swift,然后 Create。编辑文件内容为:

import Siesta

class YelpAPI {

}

这样就导入了 Siesta 并创建了一个空的 YelpAPI 类。

Siesta Service

现在来编写发起 API 请求的代码。在 YelpAPI 类中添加:

static let sharedInstance = YelpAPI()

// 1
private let service = Service(baseURL: "https://api.yelp.com/v3", standardTransformers: [.text, .image, .json])

private init() {

  // 2
  LogCategory.enabled = [.network, .pipeline, .observers]

  service.configure("**") {

    // 3
    $0.headers["Authorization"] =
    "Bearer B6sOjKGis75zALWPa7d2dNiNzIefNbLGGoF75oANINOL80AUhB1DjzmaNzbpzF-b55X-nG2RUgSylwcr_UYZdAQNvimDsFqkkhmvzk6P8Qj0yXOQXmMWgTD_G7ksWnYx"

    // 4
    $0.expirationTime = 60 * 60 // 60s * 60m = 1 hour
  }
}

上述代码分成了几个步骤:

  1. 每个 API Service 都是一个 Siesta 中的 Service 类。因为披萨猎手只需要和唯一的 API——Yelp 打交道,所以你只需要一个 Service 类。
  2. 告诉 Siesta 你需要在控制台中输出的粒度。
  3. Yelp API 需要客户端在每个 HTTP 请求头中发送 token 进行验证。每个账号的 token 都是唯一的。对于本教程,你可以用自己的 token 替代。
  4. 超时时间设置为 1 小时,因为店铺数据的变化不是那么经常。

然后,为 YelpAPI 创建一个工具方法,返回一个 Resource 对象:

func restaurantList(for location: String) -> Resource {
  return service
    .resource("/businesses/search")
    .withParam("term", "pizza")
    .withParam("location", location)
}

Resource 对象会根据指定的位置获取一个披萨店的数组,并将之转换为对订阅者有效的 any 对象。RestaurantListViewController 会用这个 Resource 在 UITableView 中显示出披萨店列表。你现在就把它们拼接起来,就能看到 Siesta 的效果。

Resource 和 ResourceObserver

打开 RestaurantListViewController.swift 导入 Siesta:

import Siesta

然后在类中增加一个实例变量 restaurantListResource:

var restaurantListResource: Resource? {
  didSet {
    // 1
    oldValue?.removeObservers(ownedBy: self)

    // 2
    restaurantListResource?
      .addObserver(self)
      // 3
      .loadIfNeeded()
  }
}

当对 restaurantListResource 属性赋值时, 你做这些事情:

  1. 删除已有的观察者。
  2. 将 RestaurantListViewController 添加为观察者。
  3. 告诉 Siesta ,是否要从 Resource 加载数据(根据缓存超时时间)。

因为 RestaurantListViewController 被添加为观察者,它必须实现 ResourceObserver 协议。添加如下扩展:

// MARK: - ResourceObserver
extension RestaurantListViewController: ResourceObserver {
  func resourceChanged(_ resource: Resource, event: ResourceEvent) {
    restaurants = resource.jsonDict["businesses"] as? [[String: Any]] ?? []
  }
}

如何实现了 ResourceObserver 协议的对象都会收到 Resource 更新通知。

这些通知会调用 resourceChanged(_:event:), 参数是发生改变的 Resource 对象。你可以检索 event 参数,进一步了解是什么发生了改变。

现在可以调用 restaurantList(for:) 了。

当用户从下拉框中选择新的地点,RestaurantListViewController 的 currentLocation 会发生改变。

这时,你应该用新选择的地址去刷新 restaurantListResource。这需要修改当前的 currentLocation 定义:

var currentLocation: String! {
  didSet {
    restaurantListResource = YelpAPI.sharedInstance.restaurantList(for: currentLocation)
  }
}

如果现在运行 app,Siesta 会在控制台中打印如下消息:

Siesta:networkGET https://api.yelp.com/v3/businesses/search?location=Atlanta&term=pizza
Siesta:observersResource(…/businesses/search?location=Atlanta&term=pizza)[L] sending requested event to 1 observer
Siesta:observers      │   ↳ requested → <PizzaHunter.RestaurantListViewController: 0x7ff8bc4087f0>
Siesta:networkResponse:  200GET https://api.yelp.com/v3/businesses/search?location=Atlanta&term=pizza
Siesta:pipeline       │ [thread ᎰᏮᏫᎰ]  ├╴Transformer ⟨*/json */*+json⟩ DataJSONConvertible [transformErrors: true] matches content type "application/json"
Siesta:pipeline       │ [thread ᎰᏮᏫᎰ]  ├╴Applied transformer: DataJSONConvertible [transformErrors: true] 
Siesta:pipeline       │ [thread ᎰᏮᏫᎰ]  │ ↳ success: { businesses = ( { categories = ( { alias = pizza; title = Pizza; } ); coordinat…
Siesta:pipeline       │ [thread ᎰᏮᏫᎰ]  └╴Response after pipeline: success: { businesses = ( { categories = ( { alias = pizza; title = Pizza; } ); coordinat…
Siesta:observersResource(…/businesses/search?location=Atlanta&term=pizza)[D] sending newData(network) event to 1 observer
Siesta:observers      │   ↳ newData(network) → <PizzaHunter.RestaurantListViewController: 0x7ff8bc4087f0>

这些消息可以让你了解到 Siesta 正在做些什么:

  • 发起 GET 请求搜索 Atlanta 的披萨店。
  • 通知观察者,也就是 RestaurantListViewController 关于这个请求。
  • 返回 200 响应码。
  • 将原始数据转换成 JSON。
  • 发送 JSON 给 RestaurantListViewController。

你可以在 RestaurantListViewController 的resourceChanged(_:event:) 方法中打个断点,然后在控制台中输入命令:

po resource.jsonDict["businesses"]

以查看 JSON 数据。你必须跳过当观察者第一次被添加,但数据还没有进来之前的那次 resourceChanged 调用。

要在 table view 中显示披萨店列表,你必须在 restaurant 属性被修改时刷新 table view。在 RestaurantListViewController 修改 restaurants 属性定义:

private var restaurants: [[String: Any]] = [] {
  didSet {
    tableView.reloadData()
  }
}

Build & run,你会看到:

哇!你已经给自己找了一些美味的披萨了。:]

添加小菊花

当某个地方的披萨店列表正在加载时,还没有向用户显示一个小菊花呢!

Siesta 使用了 ResourceStatusOverlay, 它是一个自带的小菊花控件,当 app 正在加载网络数据时他自动显示。

要使用 ResourceStatusOverlay, 首先在 RestaurantListViewController 中声明一个它的变量:

private var statusOverlay = ResourceStatusOverlay()

现在在 viewDidLoad() 中,将它添加到视图树中:

statusOverlay.embed(in: self)

它必须在 view 每次布局 subview 时正确布局。要确保这一点,在 viewDidLoad() 方法下面添加这个方法:

override func viewDidLayoutSubviews() {
  super.viewDidLayoutSubviews()
  statusOverlay.positionToCoverParent()
}

最后,将它添加为 restaurantListResource 的观察者,让 Siesta 自动显示和隐藏它。在 restaurantListResource 的 didSet 方法的 .addObserver(self) 和 .loadIfNeeded() 之间加入此句:

.addObserver(statusOverlay, owner: self)

Build & run ,看看小菊花的效果:

你可能注意到了,当你选到同一个城市时,第二次基本上是立即就显示了结果。这是因为第一次加载是从 API 加载的。但 Siesta 会将响应进行缓存,当后续请求到同一个城市时,响应是从内存缓冲中返回的:

Siesta 转换器

对于任何生产性 app,最好将响应表示为定义良好的模型对象而不是非类型化的字典和数组。Siesta 提供了轻松将原始 JSON 转换为对象模型的钩子。

Restaurant Model

披萨猎手保存了每个披萨店的 id、name、url。现在,它是从 Yelp 返回的 JSON 中直接检索数据。让 Restaurant 实现 Codable 可以让你自动实现一个清晰的、类型安全的 JSON 解码。

打开 Restaurant.swift 将结构体定义为:

struct Restaurant: Codable {
  let id: String
  let name: String
  let imageUrl: String

  enum CodingKeys: String, CodingKey {
    case id
    case name
    case imageUrl = "image_url"
  }
}

注:如果你不知道 Codable 和 CodingKey,请阅读我们的 Swift 4 教程:编码、解码和序列化

如果你回去看一眼你从 API 中返回的 JSON,披萨店列表是被包裹在一个 businesses 字典中的:

{
  "businesses": [
    {
      "id": "tonys-pizza-napoletana-san-francisco",
      "name": "Tony's Pizza Napoletana",
      "image_url": "https://s3-media2.fl.yelpcdn.com/bphoto/d8tM3JkgYW0roXBygLoSKg/o.jpg",
      "review_count": 3837,
      "rating": 4,
      ...
    },

你还需要一个结构体,将 API 的响应解包为一个 businesses 数组。在 Restaurant.swift 中添加代码:

struct SearchResults<T: Decodable>: Decodable {
  let businesses: [T]
}

模型映射

打开 YelpAPI.swift 在 init() 方法中添加代码:

let jsonDecoder = JSONDecoder()

service.configureTransformer("/businesses/search") {
  try jsonDecoder.decode(SearchResults<Restaurant>.self, from: $0.content).businesses
}

这个转换器使用 API 端点 /business/search 的资源作为参数,将响应 JSON 传递给 SearchResults 的初始化方法。也就是说你可以创建一个资源,返回一个 Restaurant 对象的数组。

另外一个不起眼但很重要的地方是从 Service 的标准 transformers 中去掉 .json。修改 service 的属性定义:

private let service = Service(baseURL: "https://api.yelp.com/v3", standardTransformers: [.text, .image])

这会让 Siesta 知道不要在 JSON 类型的响应中使用标准 transformer,而使用你提供的自定义的 transform。

RestaurantListViewController

现在修改 RestaurantListViewController 以便它能够处理模型对象,而不是原始 JSON。

打开 RestaurantListViewController.swift 修改 restaurants 的类型为 Restaurant 数组:

private var restaurants: [Restaurant] = [] {
  didSet {
    tableView.reloadData()
  }
}

修改 tableView(_:cellForRowAt:) 方法为使用 Restaurant 模型。将下面代码:

cell.nameLabel.text = restaurant["name"] as? String
cell.iconImageView.imageURL = restaurant["image_url"] as? String

替换为:

cell.nameLabel.text = restaurant.name
cell.iconImageView.imageURL = restaurant.imageUrl

最后,修改 resourceChanged(_:event:) 方法,从 resource 中抽取类型化的模型对象而不是 JSON 字典:

// MARK: - ResourceObserver
extension RestaurantListViewController: ResourceObserver {
  func resourceChanged(_ resource: Resource, event: ResourceEvent) {
    restaurants = resource.typedContent() ?? []
  }
}

typedContent() 是一个便利方法,如果值不为空,返回这个 Resource 的最新结果的类型化的值,否则返回空。

Build & run,你会看到没有任何改变。但是,因为使用了强类型,代码更健壮和安全了。

实现披萨店详情

如果你到达这一步,那么接下来的这部分就轻松了。你使用类似的步骤来抓取披萨店详情,并使用 RestaurantDetailsViewController 进行显示。

RestaurantDetails 模型

首先,需要让 RestaurantDetails 和 Location 结构体实现 Codable,以便能够使用强类型的模型。

打开 RestaurantDetails.swift ,让 RestaurantDetails 和 Location 实现 Codable :

struct RestaurantDetails: Codable {
struct Location: Codable {

然后,让 RestaunantDetails 实现下列 CodingKey,就像我们在 Restaurant 中所做的一样。在 RestaurantDetails 中添加下列代码:

enum CodingKeys: String, CodingKey {
  case name
  case imageUrl = "image_url"
  case rating
  case reviewCount = "review_count"
  case price
  case displayPhone = "display_phone"
  case photos
  case location
}

最后,为 Location 添加 CodingKey:

enum CodingKeys: String, CodingKey {
  case displayAddress = "display_address"
}

模型映射

在 YelpAPI 的 init() 方法中,你可以重用之前创建并添加给 transformer 的 jsonDecoder,告诉 Siesta 将披萨店详情 JSON 转换成 RestaurantDetials。打开 YelpAPI.swift 在之前的 service.configureTransformer 一句之上添加:

service.configureTransformer("/businesses/*") {
  try jsonDecoder.decode(RestaurantDetails.self, from: $0.content)
}

另外在 YelpAPI 中加一个工具函数,创建一个用于查询披萨店详情的 Resource 对象:

func restaurantDetails(_ id: String) -> Resource {
  return service
    .resource("/businesses")
    .child(id)
}

到目前为止还算顺利。现在,准备进入试图控制器,使用新模型。

在 RestaurantDetailsViewController 中设置 Siesta

RestaurantDetailsViewController 是用户点击披萨店列表时显示的 view controller。打开 RestaurantDetailsViewController.swift 在 restaurantDetail 下面添加代码:

// 1
private var statusOverlay = ResourceStatusOverlay()

override func viewDidLoad() {
  super.viewDidLoad()

  // 2
  YelpAPI.sharedInstance.restaurantDetails(restaurantId)
    .addObserver(self)
    .addObserver(statusOverlay, owner: self)
    .loadIfNeeded()

  // 3
  statusOverlay.embed(in: self)
}

override func viewDidLayoutSubviews() {
  super.viewDidLayoutSubviews()
  // 4
  statusOverlay.positionToCoverParent()
}
  1. 和之前一样,当加载内容时,显示一个 statusOverlay。
  2. 然后在 viewDidLoad 中用指定的 restaurantId 请求披萨店详情。同时将 self 和 spinner 添加为观察者,监听网络请求状态,以便网络响应返回时进行处理。
  3. 和之前一样,在 view controller 中添加 spinner。
  4. 最后,如果布局改变,将 spinner 放在正确的地方。

导航到 RestaurantDetialsViewController 页面

你可能注意到了,app 还不能跳转到披萨店详情页面。要解决这个问题,打开 RestaurantListViewController.swift 找到如下扩展:

extension RestaurantListViewController: UITableViewDelegate {

在这个扩展中增加委托方法:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  guard indexPath.row <= restaurants.count else {
    return
  }

  let detailsViewController = UIStoryboard(name: "Main", bundle: nil)
    .instantiateViewController(withIdentifier: "RestaurantDetailsViewController") 
      as! RestaurantDetailsViewController
  detailsViewController.restaurantId = restaurants[indexPath.row].id
  navigationController?.pushViewController(detailsViewController, animated: true)
  tableView.deselectRow(at: indexPath, animated: true)
}

这里,你简单构造了一个详情页面,将选择的披萨店传给它,然后将它 push 到导航栈中。

Build & run。选择点击列表中的披萨店,搞定!

如果你返回,再次点击同一家披萨店,你会看到详情页面刷的一下就出来了。这是 Siesta 的本地缓存的另外一个例子,提供了良好的用户体验:

这样,你就用 Yelp API 和 Siesta 框架实现了一个披萨店搜索 app。

接下来去哪里?

通过底部的 Download Materials 按钮下载完整的项目代码。

如果你需要阅读 Siesta 文档,那么它的 GitHub 页是一个很好的资源。

要更进一步学习 Siesta,请参考下列资源:

  1. Security and authentication options in Siesta
  2. Transform pipeline for fine grain control of JSON to model transformation
  3. Siesta API Documentation

希望本文对你有所帮助。请在论坛中留言或提问。

Download Materials

猜你喜欢

转载自blog.csdn.net/kmyhy/article/details/82081894