跳转至

第24章:下载数据

大多数应用程序以某种方式访问互联网,下载数据来显示或保持用户产生的数据在设备间同步。你的RWFreeView应用程序需要创建和发送HTTP请求,并处理HTTP响应。下载的数据通常是JSON格式的,你的应用程序需要将其解码为自己的数据模型。

如果你的应用程序从你自己的服务器上下载数据,你也许能够确保JSON结构与你的应用程序的数据模型相匹配。但是RWFreeView需要与raywenderlich.comAPI和它的JSON结构一起工作,它是深度嵌套的。所以在本章中,你将学习两种处理嵌套JSON的方法。

开始

打开启动器文件夹中的Networking playground,并打开Episode playground页面。如果编辑器窗口是空白的,显示项目导航器(Command-1)并在那里选择Episode

Open Episode playground page.

Playground对于在将代码移入你的应用程序之前进行探索和研究是非常有用的。你可以快速检查由方法和操作产生的值,而不需要建立一个用户界面或搜索大量的调试控制台信息。

起步阶段的Playground包含两个Playground页面以及对DateFormatterURLComponents的扩展。

异步函数

URLSession是苹果的HTTP消息框架。它的大部分方法都涉及到网络通信,所以你无法预测它们需要多长时间来完成。在此期间,系统必须继续与用户互动。

为了实现这一点,URLSession方法是异步的:它们将工作分派到另一个队列,并立即将控制权返回给主队列,这样它就可以对用户界面事件做出反应。当你调用该方法时,你提供一个完成处理程序。这个处理程序在网络任务完成时运行,以处理来自服务器的响应。

Note

URLSession和更广泛的并发主题有自己的视频课程,在bit.ly/3x6Z8hN,也有一本书,《教程中的并发》,在bit.ly/3n0BSgF

因为异步任务看起来是立即完成的,所以EpisodeVideoURL Playground页面包含以下代码,这样Playground就不会在异步任务完成之前停止执行:

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

创建一个REST请求

一个REST请求是一个带有查询参数的URL。在上一章中,你看到了一个典型的contents查询的URL编码:

https://api.raywenderlich.com/api/contents?filter%5Bsubscription_types%5D%5B%5D=free&filter%5Bdomain_ids%5D%5B%5D=1&filter%5Bcontent_types%5D%5B%5D=episode&sort=-popularity

这个URL?分隔符后列出了查询参数名称和值。

在这个Playground上,你将为免费的iOSSwift剧集创建这个REST请求,按流行程度排序。你的方法将是灵活的,所以你可以轻松地改变查询参数值。

许多查询参数名称,如filter[domain_ids][]包含括号,必须将其URL编码为%5B%5D。你需要在你的应用程序中创建这样的URL,而且你肯定不想自己做URL编码!幸运的是,你可以把这项工作交给你的应用程序。幸运的是,你可以把这项工作交给URLComponentsURLQueryItem

URLComponents

URLComponents结构使你能够从URL的各个部分构建一个URL,同时也能够访问URL的各个部分。组件包括scheme, host, port, path, queryqueryItemsurl本身可以让你访问URL组件,如lastPathComponent

➤ 将这段代码添加到Episode playground中:

let baseURLString = "https://api.raywenderlich.com/api/"
var urlComponents = URLComponents(
  string: baseURLString + "contents/")!
urlComponents.queryItems = [
  URLQueryItem(
    name: "filter[subscription_types][]", value: "free"),
  URLQueryItem(
    name: "filter[content_types][]", value: "episode")
]
urlComponents.url
urlComponents.url?.absoluteString

你为API的基本端点设置URL字符串,并添加contents端点,以创建一个URLComponents实例。然后,你创建一个URLQueryItem值数组。URLQueryItem参数是你在前一章用来构建REST请求的参数名称和值。

最后一行显示侧边栏中的最终URL字符串。上面那行显示的是最终的URL。有什么不同呢?是时候找出答案了!

Note

Playground中,可以将表达式写在自己的行中,以显示其值。

➤ 单击最后一行编号或Playground底部的执行Playground箭头:

Execute-Playground arrows on the code line and in the bottom bar

Note

点击某行代码旁边的箭头,只运行到该行的游戏。

侧边栏显示一些行的数值,并有快速查看和显示结果的按钮。

➤ 单击最后一行代码的显示结果按钮,调整出现在该代码行下面的显示窗口的大小:

Show Result of the code line.

"https://api.raywenderlich.com/api/contents/?filter%5Bsubscription_types%5D%5B%5D=free&filter%5Bcontent_types%5D%5B%5D=episode"

由于urlComponents,你的查询被安全地进行了URL编码并附加到基本的URL中。

现在看看上面那行的url。注意它没有加引号,因为它不是一个String。事实上,它是一个Optional。点击它的显示结果按钮:

Playground trying to display a URL

Playground会尽力打开该URL

你可以从一个String创建一个URL,如果String有所有正确的部分。然后,你可以访问这些部分作为URL实例的属性:hostbaseURLpathlastPathComponentquery等等。

如果你试图从一个在浏览器中无法使用的String中创建一个URL,初始化器会返回nil。这就是为什么urlComponents.url是一个选项,并且在最后一行代码中有一个url?。如果urlnil,它就没有absoluteString属性。

➤ 点击它们的隐藏结果按钮来关闭结果窗口。

Note

你也可以使用print(urlComponents.url?.absolutString)在下面的调试区看到打印的值。如果不能看到调试区,请点击运行/停止按钮旁边的按钮或按Shift-Command-C

URLComponents帮助方法

URLQueryItem使得在请求URL中添加查询参数名称和值变得很容易,但是你的应用程序为用户提供了很多选项来定制下载内容。而且几乎每一个选择和取消选择都需要一个新的请求。你将会写很多代码来添加查询项。

URLQueryItemnamevalue参数看起来像字典中的keyvalue项,所以很容易创建一个参数名称和值的字典,然后将这个字典转化为queryItems数组。尤其是Alfian Losari已经在bit.ly/3pRtT6t中做到了这一点。 :] 它在Networking/Source/URLComponentsExtension.swift这个Playground上。

➤ 将urlComponents.queryItems定义替换为:

var baseParams = [
  "filter[subscription_types][]": "free",
  "filter[content_types][]": "episode",
  "sort": "-popularity",
  "page[size]": "20",
  "filter[q]": ""
]
urlComponents.setQueryItems(with: baseParams)

你创建一个字典,其键是查询参数名称。前两个是固定的:你总是想下载免费的剧集。

你包括排序、页面大小和q(搜索词),因为这些是单值选项。它们有默认值,而这个字典可以让你轻松地改变它们的值。比如说:

// when user changes page size
baseParams["page[size]"] = "30"
// when user enters a search term
baseParams["filter[q]"] = "json"

URLComponents扩展中定义的setQueryItems(with:)辅助方法为每个字典项目创建一个URLQueryItem,并设置URLComponents实例的queryItems数组。在下一章中,你将向这个数组追加其他查询项。

➤ 用这一行替换urlComponents.url

urlComponents.queryItems! += 
  [URLQueryItem(name: "filter[domain_ids][]", value: "1")]

你只请求"iOS & Swift"域中的情节。

你不在baseParams中包含这个查询参数,因为你可以在一个RESTED请求的URL中添加一个以上的domain_id查询项。

queryItems数组是一个可选项,以防urlComponents没有任何queryItems组件。但是,你刚刚创建了queryItems组件,所以强行解包是安全的。

➤ 执行Playground并显示absoluteString的结果:

Result of adding domain query item

现在你已经准备好向raywenderlich.comAPI服务器发送请求URL了。

URLSessionDataTask

➤ 在absoluteString行下面添加这段代码:

let contentsURL = urlComponents.url!  // 1

// 2
URLSession.shared
  .dataTask(with: contentsURL) { data, response, error in
    defer { PlaygroundPage.current.finishExecution() }  // 3
    if let data = data, 
      let response = response as? HTTPURLResponse {  // 4
      print(response.statusCode)
      // Decode data and display it
    }
    // 5
    print(
      "Contents fetch failed: " +
        "\(error?.localizedDescription ?? "Unknown error")")
  }
  .resume()  // 6
  1. 你把urlComponentsurl属性分配给contentsURL。在这个Playground上,你知道这是一个有效的URL,所以强制解包是安全的。当这段代码在一个方法中时,你会在一个guard语句中做这个赋值,如果值是nil就退出。
  2. 你用contentsURL创建了一个dataTask。对于像这样的简单请求,默认配置的shared会话可以正常工作。你可以创建一个自定义配置的会话。例如,以下是你如何创建一个等待300秒的网络连接的会话:
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 300
let session = URLSession(configuration: config)
  1. dataTask处理程序完成时,defer语句会停止Playground的执行。当代码是在一个Playground页面中最后执行的时候,这很方便。
  2. 你提供一个完成处理程序。当任务完成时,这个处理程序收到三个参数。你通常把它们命名为data, responseerror。这三个参数都是选项,所以你必须把它们拆开。完成处理程序通常检查response.statusCode然后解码data
  3. 你打印error,如果它存在的话,或者打印“Unknown error”.。一个常见的“Unknown error”.的来源是对data解码失败。
  4. URLSession任务是在暂停状态下创建的,所以你必须调用resume()方法来启动它们。这一步很容易忘记,即使是有经验的iOS开发者也是如此。]

在你打印状态代码后,那个解码数据并显示它的注释怎么办?本章剩下的大部分内容都能帮助你做到这一点。如果你现在运行这段代码,你会得到“Unknown error”.,因为你还没有对data进行解码。

获取一个视频URL

为了显示一个视频,你实际上需要运行两个下载请求。你刚刚添加到Episode playground的那个请求获取了一个contents项目的数组。

其中一个contents项目的属性是video_identifier。它是一个整数,如3021。你将使用它来获取项目的视频的URL字符串。

➤ 在VideoURL Playground页中,添加这几行代码:

let videoId = 3021
let baseURLString = "https://api.raywenderlich.com/api/videos/"
let queryURLString = baseURLString + String(videoId) + "/stream"
let queryURL = URL(string: queryURLString)!
URLSession.shared
  .dataTask(with: queryURL) { data, response, error in
    defer { PlaygroundPage.current.finishExecution() }
    if let data = data, 
      let response = response as? HTTPURLResponse {
      print("\(videoId) \(response.statusCode)")
      // Decode response and display it
    }
    print(
      "Videos fetch failed: " +
        "\(error?.localizedDescription ?? "Unknown error")")
  }
  .resume()

你创建查询URLdataTask代码来发送它,然后打印响应状态代码。然后,你需要"解码响应并显示它"。

这个查询的JSON响应比contents查询要简单,所以你要先对其进行解码。

解码JSON

如果你的数据模型和JSON值之间有很好的匹配,那么JSONDecoder的默认init(from:)就是诗意的运动,让你在一行代码中解码数组和字典的复杂结构。不幸的是,api.raywenderlich.com发送了一个深度嵌套的JSON结构,你可能不想在你的应用程序中复制。所以,现在是时候了解更多关于CodingKey枚举和自定义init(from:)方法了。

Note

通过我们的教程《Swift中的编码和解码》深入了解JSONbit.ly/3bqsrBY

解码JSON,(几乎)符合你的数据模型

在第19章"保存文件"中,你看到将Team结构编码和解码为JSON是多么容易,因为它的所有属性都是Codable的。你正在保存和加载你的应用程序自己的数据模型,所以JSON格式的项目名称和结构与你的Team结构完全匹配。

由现实世界的API发送的JSON值很少与你想命名或结构你的应用程序的数据的方式相匹配。

如果JSON结构与你的应用程序的数据模型相匹配,但JSON名称使用snake_case,而你的属性名称使用camelCase,你只需告诉解码器进行翻译:

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

这负责将JSON名称如released_atvideo_identifier翻译成属性名称releasedAtvideoIdentifier

如果JSON结构与你的应用程序的数据模型相匹配,但一些名称是不同的,你只需要定义一个CodingKey枚举,将JSON项目名称分配给你的数据模型属性。例如,Episode有一个description属性,但匹配的JSON项目的名称是description_plain_text

enum CodingKeys: String, CodingKey {
  case description = "description_plain_text"
  case id, uri, name, ...
}

不幸的是,只要你为一个属性名称创建一个CodingKey枚举,你就必须包括所有的属性名称,甚至那些已经匹配JSON项目名称的属性。

解读嵌套的JSON

大多数时候,API发送的JSON结构与你想组织你的应用程序的数据的方式非常不同。api.raywenderlich.com就是这种情况,你想存储在episode中的值被嵌套在JSON值中的一个或多个层次。而获取视频的URL字符串需要单独请求一个不同的端点。

即使是videos请求也有这个问题。你想要的只是url值,但它被埋在一个嵌套的JSON结构中。

➤ 用RESTed发送此请求,或使用raywenderlich.docs.apiary.io/videos/{video_id}/stream端点的尝试控制台。

https://api.raywenderlich.com/api/videos/3021/stream

你会得到这个响应体:

{
  "data": {
    "id": "32574",
    "type": "attachments",
    "attributes": {
      "url": "https://player.vimeo.com/external/357115704...",
      "kind": "stream"
    }
  }
}

JSON值包含一个名为"data"的字典。它的"attributes"键的值是另一个字典,而"url"键的值是你想存储在Episode实例中的URL字符串。

有两种方法可以对嵌套的JSON进行解码:

  1. 定义你的数据模型来反映JSON值。
  2. JSON值扁平化到你的数据模型中。

你会用第一种方法来做,看看自动JSON解码是多么的有趣。然后你会用第二种方法,因为你会在你的应用程序中这样做。作为对更多解码工作的交换,你将得到合理的数据结构,更容易、更自然地工作。

使数据模型适合于JSON

➤ 在VideoURL Playground页面中,设置这些结构以反映JSON值的层次结构:

struct ResponseData: Codable {
  let data: Video
}

struct Video: Codable {
  let attributes: VideoAttributes
}

struct VideoAttributes: Codable {
  let url: String
}

你用data, attributesurl属性创建独立的结构。这些结构之间的关系与JSON值的"data""attributes""url"的嵌套一致。

➤ 在queryURL dataTask的完成处理程序中,用此代码替换"解码响应并显示"注释:

if let decodedResponse = try? JSONDecoder().decode(  // 1
    ResponseData.self, from: data) {  // 2
  DispatchQueue.main.async {
    print(decodedResponse.data.attributes.url)  // 3
  }
  return
}
  1. 你不需要为这个任务配置JSONDecoder,所以你只需在内联创建一个。
  2. 你对顶层的ResponseData进行解码,这样你就可以访问它的data.attributes.url属性。
  3. 在主队列上运行print语句。这在Playground中并不是真正必要的,但在应用程序中是必不可少的。你通常在dataTask处理程序中做一些事情来更新用户界面,而所有的UI更新都必须在主队列中运行。

➤ 运行Playground

Playground execution stops after decoding URL string.

URL字符串出现在调试区,Playground执行停止。

Note

如果你在URLSession代码下面添加了ResponseData和其他结构,你必须运行整个Playground。如果你点击resume()行上的运行按钮,它们是不"可见"的。

➤ 选项点击url

View type of url property.

它的类型是String,正如你所期望的那样。

➤ 在VideoAttributes中,改变url的类型:

let url: URL

➤ 再次运行Playground,然后检查url的类型:

Type of url property is URL.

这是一个巧妙的JSONDecoder自动技巧之一。它从JSON字符串值中创建了一个URL

当你需要在你的应用程序中使用url时,这种方法的缺点就出现了。你并不希望为每个视频创建一个ResponseData实例。把它作为Episode的属性之一会更自然。

扁平化JSON响应到数据模型中

在第19章"保存文件"中,你写了一个CodingKey枚举和一个自定义的init(from:)来解码一个Double值,然后用来初始化一个Angle。在这一节中,你将使用CodingKey枚举和自定义的init(from:)来浏览JSON值的各个层次并提取你需要的项目。

这里又是JSON值:

{
  "data": {
    "id": "32574",
    "type": "attachments",
    "attributes": {
      "url": "https://player.vimeo.com/external/357115704...",
      "kind": "stream"
    }
  }
}

"data"看成是顶级容器里面的一个容器。

➤ 注释掉ResponseDataVideoVideoAttributes结构,然后添加这段代码:

struct VideoURLString {
  // data: attributes: url
  var urlString: String

  enum CodingKeys: CodingKey {
    case data
  }

  enum DataKeys: CodingKey {
    case attributes
  }
}

struct VideoAttributes: Codable {
  var url: String
}

这一次,你在为顶层和"data"容器创建的CodingKey枚举中反映了JSON的层次结构。这些情况是你所关心的JSON键。顶层容器中的"data""data"容器中的"attributes"

然后,你创建一个结构来保存你关心的"attributes"项目:url

为了使JSON结构扁平化以适应你的数据模型,你必须编写你自己的初始化器。

➤ 在VideoURLAttributes下面添加这段代码:

extension VideoURLString: Decodable {
  init(from decoder: Decoder) throws {
    let container = try decoder.container(  // 1
      keyedBy: CodingKeys.self)
    let dataContainer = try container.nestedContainer(
      keyedBy: DataKeys.self, forKey: .data)  // 2
    let attr = try dataContainer.decode(
      VideoAttributes.self, forKey: .attributes)  // 3
    urlString = attr.url  // 4
  }
}

Note

你总是把这个解码初始化器放在一个扩展中。如果你把它放在主VideoURLString结构中,你会失去Swift为结构创建的默认初始化器。

你深入到JSON值中,使用编码键来创建容器并访问其内容。

  1. 首先,你得到顶级容器。在CodingKeys中的case data与这个顶级容器中的东西匹配。
  2. 接下来,你抓取嵌套的容器,与CodingKeys中的case data相匹配。DataKeys中的case attributesdataContainer中的内容相匹配。
  3. 然后,你对dataContainer中的attributes项进行解码,将其映射到VideoAttributes结构。所以attrVideoAttributes的一个实例。
  4. 最后,你到达了JSON层次结构中的"url"值! 你把它分配给这个VideoURLString实例的顶级urlString属性。

这比在你的数据模型结构中反映JSON层次结构的工作要多,但它有两个优点。

  1. 你的数据模型结构更扁平,更容易理解。它们与你的应用程序的数据的心理模型相匹配。
  2. 如果JSON层次结构改变,你只需要修改CodingKey枚举和你的自定义init(from:)。你不需要重构你的数据模型结构。

➤ 改变JSONDecoder().decode代码以使用VideoURLString

if let decodedResponse = try? JSONDecoder().decode(
    VideoURLString.self, from: data) {
  DispatchQueue.main.async {
    print(decodedResponse.urlString)
  }
  return
}

➤ 再次运行Playground以检查它是否仍能解码url

Decoding url with flattened JSON.

当你把contentsJSON值平铺到Episode结构中时,你会使用这个VideoURLString解码结构。

contents响应进行解码

现在你已经准备好处理contents的查询响应了。下面是返回的一个项目:

{
  "data": [
    {
      "id": "5117655",
      "attributes": {
        "uri": "rw://betamax/videos/3021",
        "name": "SwiftUI vs. UIKit",
        "released_at": "2019-09-03T13:00:00.000Z",
        "difficulty": "beginner",
        "description_plain_text": "Learn about...\n",
        "video_identifier": 3021,
        ...
      },
      "relationships": {
        "domains": {
          "data": [
            {
              "id": "1",
              "type": "domains"
            }
          ]
        },
        ...
      }
      ...
    },
  ...
}

顶层的容器是一个字典。它的一个键是"data",它是一个字典数组。你需要为每个Episode实例存储的大部分数据都在"attributes"值中。

你将使用"video_identifier"值来创建一个VideoURLString对象来获取该集视频的URL字符串。

而剧集的域名(平台)是由"id""relationships"值中列出。

还有一个JSONDecoder的技巧

你肯定要把这个JSON平铺到你的Episode结构中,但首先,你得看看JSONDecoder能对Date字符串做什么。

➤ 在Episode playground页面,设置一个EpisodeStore结构来存储剧集和一个Episode结构来反映JSON值:

struct EpisodeStore: Decodable {
  var episodes: [Episode] = []

  enum CodingKeys: String, CodingKey {
    case episodes = "data"   // array of dictionary
  }
}

struct Episode: Decodable, Identifiable {
  let id: String
  let attributes: Attributes
}

struct Attributes: Codable {
  let name: String
  let released_at: Date
}

JSON值中,"released_at"是一个字符串,但你将设置一个JSONDecoder来自动将其转换为Date值。

FormatterExtension.swift包含两个DateFormatter类型的属性:

public extension DateFormatter {
  /// Convert /contents released_at String to Date
  static let apiDateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
    return formatter
  }()

  /// Format date to appear in EpisodeView and PlayerView
  static let episodeDateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "MMM yyyy"
    return formatter
  }()
}

apiDateFormatter中的dateFormat"leased_at"字符串的模板。

Note

这种日期格式是由ISO 8601[bit.ly/3aJJSwR](https://www.w3.org/QA/Tips/iso-date)定义的国际标准。您可以在bit.ly/3oZTuZu中找到日期格式模式和日期字段符号表。

➤ 在Episode playground页面中,在URLSession代码之前,创建一个JSONDecoder并设置其日期解码策略:

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(.apiDateFormatter)

这就是你需要做的,使解码器将"release_at"字符串转换成Date值。之后,你将使用episodeDateFormatterEpisodeView中显示这个日期的月份和年份。

Note

实际上有一个特定的ISO8601DateFormatter和一个JSON日期解码策略iso8601,但该策略不包括毫秒(SSS),也不让你实例化ISO8601DateFormatter来设置毫秒选项。你也不能使用.formatted来设置一个配置的ISO8601DateFormatter作为decoder的日期格式,因为它不是DateFormatter类型。这些限制只有在你使用编译器为你提供的标准解码时才会有问题。如果你写一个自定义的解码器--你很快就会这样做--你可以直接使用ISO8601DateFormatter

➤ 在dataTask的完成处理程序中,用这段代码替换"对数据进行解码并显示"的注释:

if let decodedResponse = try? decoder.decode(
    EpisodeStore.self, from: data) {
  DispatchQueue.main.async {
    let date = 
      decodedResponse.episodes[0].attributes.released_at
    DateFormatter.episodeDateFormatter.string(from: date)
  }
  return
}

你检查由decoder创建的Date值,将其转换为你将在EpisodeView中显示的String

➤ 运行Playground

released_at string converted to Date and back to shorter string.

decoder创建的日期是以默认的中等风格显示,然后episodeDateFormatter显示你将在EpisodeView显示的短字符串。

扁平化的内容响应

RWFreeView还需要几个Episode属性,其中一些需要自定义解码器。

➤ 在Episode播放页面中,用所有你需要的属性替换Episode结构的attributes属性:

// flatten attributes container
//1
let uri: String
let name: String
let released: String
let difficulty: String?
let description: String  // description_plain_text

// 2
var domain = ""  // relationships: domains: data: id

// send request to /videos endpoint with urlString
var videoURL: VideoURL?  // 3

// redirects to the real web page
var linkURLString: String {  // 4
  "https://www.raywenderlich.com/redirect?uri=" + uri
}
  1. 声明你要从JSON响应中映射的属性。擷取的項目如果不是集數,就沒有"difficulty"值,所以這是可選的。
  2. 这些属性大多与"attributes"容器中的项目相匹配,但域"id"值被嵌套在"relationships"字典的深处。
  3. 你将创建一个VideoURL对象,它将发送一个请求来获取urlString
  4. 如果你想使用Link打开浏览器,你从uri计算出linkURLString

Episode的大部分属性进行解码

Xcode正在抱怨Episode不符合Decodable,因为你没有告诉它这些新的属性将如何取值。马上就来!

➤ 首先,删除Attributes。你要把它平铺到Episode的顶层。

➤ 将这些CodingKey的枚举添加到Episode中:

enum DataKeys: String, CodingKey {
  case id
  case attributes
  case relationships
}

enum AttrsKeys: String, CodingKey {
  case uri, name, difficulty
  case releasedAt = "released_at"
  case description = "description_plain_text"
  case videoIdentifier = "video_identifier"
}

struct Domains: Codable {
  let data: [[String: String]]
}

enum RelKeys: String, CodingKey {
  case domains
}

你为"data""attributes""relationships"容器创建CodingKey枚举DataKeysAttrsKeysRelKeys,并创建一个结构来容纳你关心的"domains"项目。data

Note

Swift枚举情况下的标识符可以包括下划线字符,但raywenderlich.com代码使用SwiftLint github.com/realm/SwiftLint,它只允许字母和数字。而且convertFromSnakeCase键解码策略只在自动JSON解码时起作用。

➤ 也给Episode增加了这个类型属性:

static let domainDictionary = [
  "1": "iOS & Swift",
  "2": "Android & Kotlin",
  "3": "Unity",
  "5": "macOS",
  "8": "Server-Side Swift",
  "9": "Flutter"
]

你将使用它来将domains id值转换为平台名称。

➤ 最后,将这个扩展添加到FormatterExtension.swift中。

public extension Formatter {
  /// Creates ISO8601DateFormatter that formats milliseconds
  static let iso8601: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [
      .withInternetDateTime,
      .withFractionalSeconds
    ]
    return formatter
  }()
}

你创建了一个ISO8601DateFormatter并设置其选项包括withFractionalSeconds。使用这个日期格式器,你不需要输入日期格式字符串,在那里很容易犯错,破坏解码器。

为了将JSON结构扁平化为Episode,你必须在扩展中编写自己的初始化器。

➤ 首先对JSON项进行解码。在Episode Playground页面中添加这个扩展:

extension Episode {
  init(from decoder: Decoder) throws {
    let container = try decoder.container(  // 1
      keyedBy: DataKeys.self)
    let id = try container.decode(String.self, forKey: .id)

    let attrs = try container.nestedContainer(  // 2
      keyedBy: AttrsKeys.self, forKey: .attributes)
    let uri = try attrs.decode(String.self, forKey: .uri)
    let name = try attrs.decode(String.self, forKey: .name)
    let releasedAt = try attrs.decode(
      String.self, forKey: .releasedAt)
    let releaseDate = Formatter.iso8601.date(  // 3
      from: releasedAt)!
    let difficulty = try attrs.decode(
      String?.self, forKey: .difficulty)
    let description = try attrs.decode(
      String.self, forKey: .description)
    let videoIdentifier = try attrs.decode(
      Int?.self, forKey: .videoIdentifier)

    let rels = try container.nestedContainer(
      keyedBy: RelKeys.self, forKey: .relationships)  // 4
    let domains = try rels.decode(
      Domains.self, forKey: .domains)
    if let domainId = domains.data.first?["id"] {  // 5
      self.domain = Episode.domainDictionary[domainId] ?? ""
    }
  }
}
  1. 这里,顶层容器是"data"。它包含在DataKeys中命名的项目:"id""attributes""relationships"
  2. 你抓取与DataKeys中的case attributes相匹配的嵌套容器,然后解码你想存储在Episode中的六个值。
  3. 对于releasedAt编码键,你对String进行解码,然后用你的毫秒处理的iso8601格式化将其转换为Date

  4. 同样地,你得到"relationships"容器,并对"domains"项目进行解码。

  5. 最后,你得到一个domainId值并将其转换为平台名称。"domains"项是一个数组,因为一个情节可能与一个以上的域相关。你取第一个数组项。这是一个可选项,因为一个数组可以是空的。"id"键的值也是一个可选项,以防没有这个键。如果你能解开这些选项,你在domainDictionary中查找匹配的平台名称,并将其分配给domain属性。

你将使用这些解码的值来初始化每个Episode。对于大多数属性,你只需将解码后的值分配给该属性。但是你要把releaseDate日期转换成String值,你需要一种方法来使用videoIdentifier来获取视频URL字符串。

VideoURL

你很快就会从contents响应中解码video_identifier并使用它来创建一个VideoURL对象。

➤ 在VideoURLPlayground页面中,删除下面的代码 let videoId = 3021,但不包括结构和扩展,如果你在那里添加了它们。

➤ 现在,添加以下代码:

class VideoURL {
  var urlString = ""

  init(videoId: Int) {
    let baseURLString =
      "https://api.raywenderlich.com/api/videos/"
    let queryURLString =
      baseURLString + String(videoId) + "/stream"
    guard let queryURL = URL(string: queryURLString)  // 1
    else { return }
    URLSession.shared
      .dataTask(with: queryURL) { data, response, error in
        defer { PlaygroundPage.current.finishExecution() }
        if let data = data,
          let response = response as? HTTPURLResponse {
          print("\(videoId) \(response.statusCode)")
          if let decodedResponse = try? JSONDecoder().decode(
            VideoURLString.self, from: data) {
            DispatchQueue.main.async {
              self.urlString = decodedResponse.urlString  // 2
              print(self.urlString)
            }
            return
          }
        }
        print(
          "Videos fetch failed: " +
            "\(error?.localizedDescription ?? "Unknown error")")
      }
      .resume()
  }
}

这是将你之前的代码移到VideoURL类中,并做了一些小改动:

  1. 现在你在一个方法中,如果出现问题,你可以退出,所以你在guard语句中安全地创建queryURL,而不是强制解开URL
  2. 将解码后的JSON分配给这个VideoURL对象的urlString属性,然后打印该值。

➤ 现在用这个来替换let videoId = 3021

VideoURL(videoId: 3021)

你创建了一个VideoURL对象来测试你的新类。

➤ 运行Playground并检查它是否仍在获取视频URL

VideoURL(videoId: 3021)

➤ 将VideoURL、结构和扩展复制到Episode playground页面。将init(videoId:)中的defer语句注释掉或删除:

//defer { PlaygroundPage.current.finishExecution() }

你不希望在解码第一个视频URL后停止Playground的执行!

现在一切准备就绪,可以完成对Episode的初始化。

➤ 在Episode扩展中的init(from:)方法的末尾添加以下代码:

self.id = id
self.uri = uri
self.name = name
self.released = DateFormatter.episodeDateFormatter.string(   // 1
  from: releaseDate)
self.difficulty = difficulty
self.description = description
if let videoId = videoIdentifier {   // 2
  self.videoURL = VideoURL(videoId: videoId)
}

当你写你自己的解码器初始化器时,你会失去JSONDecoder的自动操作。所以你必须初始化每一个属性,即使你只是给一个属性分配一个解码的值。

有两个属性需要做更多的工作,而不是仅仅给属性分配本地值:

  1. 你使用DateFormatter.plisodeDateFormatterreleaseDate日期转换成self.release字符串,你将在EpisodeView中显示。
  2. 如果你解码了一个videoIdentifier值,你就用这个值初始化一个VideoURL实例,并将其urlString属性分配给self.videoURLString

➤ 现在滚动到URLSession的代码,编译器正在抱怨Episode没有成员attributes。将dataTask处理程序的主队列闭包中的所有代码替换为以下内容:

print(decodedResponse.episodes[0].released)
print(decodedResponse.episodes[0].domain)

你打印一些你新解码的属性。

➤ 运行Playground

Custom decoding of contents response

➤ 滚动查看,init(from:)运行了20次:

Episode init(from:) ran 20 times.

你的JSON解码都在工作,这是你在Playground上所能测试的。你已经准备好将所有这些代码复制并改编到你的应用程序中,使一切都能正常工作!

关键点

  • Playground对于解决代码问题非常有用。你可以快速检查由方法和操作产生的值。
  • URLComponents查询项可以帮助你为REST请求创建URL编码的URLs
  • 使用URLSession dataTask来发送HTTP请求并处理HTTP响应。
  • 对嵌套的JSON值进行解码,可以在你的应用程序的数据模型中反映JSON结构,或者将JSON结构平铺到你的数据模型中。
  • 使用日期格式化器,如ISO8601DateFormatter,将日期字符串转换为Date值。