第24章:下载数据¶
大多数应用程序以某种方式访问互联网,下载数据来显示或保持用户产生的数据在设备间同步。你的RWFreeView
应用程序需要创建和发送HTTP
请求,并处理HTTP
响应。下载的数据通常是JSON
格式的,你的应用程序需要将其解码为自己的数据模型。
如果你的应用程序从你自己的服务器上下载数据,你也许能够确保JSON
结构与你的应用程序的数据模型相匹配。但是RWFreeView
需要与raywenderlich.com
的API
和它的JSON
结构一起工作,它是深度嵌套的。所以在本章中,你将学习两种处理嵌套JSON
的方法。
开始¶
打开启动器文件夹中的Networking playground
,并打开Episode playground
页面。如果编辑器窗口是空白的,显示项目导航器(Command-1
)并在那里选择Episode
。
Playground
对于在将代码移入你的应用程序之前进行探索和研究是非常有用的。你可以快速检查由方法和操作产生的值,而不需要建立一个用户界面或搜索大量的调试控制台信息。
起步阶段的Playground
包含两个Playground
页面以及对DateFormatter
和URLComponents
的扩展。
异步函数¶
URLSession
是苹果的HTTP
消息框架。它的大部分方法都涉及到网络通信,所以你无法预测它们需要多长时间来完成。在此期间,系统必须继续与用户互动。
为了实现这一点,URLSession
方法是异步的:它们将工作分派到另一个队列,并立即将控制权返回给主队列,这样它就可以对用户界面事件做出反应。当你调用该方法时,你提供一个完成处理程序。这个处理程序在网络任务完成时运行,以处理来自服务器的响应。
Note
URLSession
和更广泛的并发主题有自己的视频课程,在bit.ly/3x6Z8hN,也有一本书,《教程中的并发》,在bit.ly/3n0BSgF。
因为异步任务看起来是立即完成的,所以Episode
和VideoURL 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
上,你将为免费的iOS
和Swift
剧集创建这个REST
请求,按流行程度排序。你的方法将是灵活的,所以你可以轻松地改变查询参数值。
许多查询参数名称,如filter[domain_ids][]
包含括号,必须将其URL编码为%5B
和%5D
。你需要在你的应用程序中创建这样的URL
,而且你肯定不想自己做URL
编码!幸运的是,你可以把这项工作交给你的应用程序。幸运的是,你可以把这项工作交给URLComponents
和URLQueryItem
。
URLComponents
¶
URLComponents
结构使你能够从URL的各个部分构建一个URL
,同时也能够访问URL
的各个部分。组件包括scheme
, host
, port
, path
, query
和 queryItems
。url
本身可以让你访问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
箭头:
Note
点击某行代码旁边的箭头,只运行到该行的游戏。
侧边栏显示一些行的数值,并有快速查看和显示结果的按钮。
➤ 单击最后一行代码的显示结果按钮,调整出现在该代码行下面的显示窗口的大小:
"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
会尽力打开该URL
。
你可以从一个String
创建一个URL
,如果String
有所有正确的部分。然后,你可以访问这些部分作为URL
实例的属性:host
、baseURL
、path
、lastPathComponent
、query
等等。
如果你试图从一个在浏览器中无法使用的String
中创建一个URL
,初始化器会返回nil
。这就是为什么urlComponents.url
是一个选项
,并且在最后一行代码中有一个url?
。如果url
是nil
,它就没有absoluteString
属性。
➤ 点击它们的隐藏结果按钮来关闭结果窗口。
Note
你也可以使用print(urlComponents.url?.absolutString)
在下面的调试区看到打印的值。如果不能看到调试区,请点击运行/停止按钮旁边的按钮或按Shift-Command-C
。
URLComponents
帮助方法¶
URLQueryItem
使得在请求URL
中添加查询参数名称和值变得很容易,但是你的应用程序为用户提供了很多选项来定制下载内容。而且几乎每一个选择和取消选择都需要一个新的请求。你将会写很多代码来添加查询项。
URLQueryItem
的name
和value
参数看起来像字典中的key
和value
项,所以很容易创建一个参数名称和值的字典,然后将这个字典转化为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
的结果:
现在你已经准备好向raywenderlich.com
的API
服务器发送请求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
- 你把
urlComponents
的url
属性分配给contentsURL
。在这个Playground
上,你知道这是一个有效的URL
,所以强制解包是安全的。当这段代码在一个方法中时,你会在一个guard
语句中做这个赋值,如果值是nil
就退出。 - 你用
contentsURL
创建了一个dataTask
。对于像这样的简单请求,默认配置的shared
会话可以正常工作。你可以创建一个自定义配置的会话。例如,以下是你如何创建一个等待300
秒的网络连接的会话:
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 300
let session = URLSession(configuration: config)
- 当
dataTask
处理程序完成时,defer
语句会停止Playground
的执行。当代码是在一个Playground
页面中最后执行的时候,这很方便。 - 你提供一个完成处理程序。当任务完成时,这个处理程序收到三个参数。你通常把它们命名为
data
,response
和error
。这三个参数都是选项,所以你必须把它们拆开。完成处理程序通常检查response.statusCode
然后解码data
。 - 你打印
error
,如果它存在的话,或者打印“Unknown error”.
。一个常见的“Unknown error”.
的来源是对data
解码失败。 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()
你创建查询URL
和dataTask
代码来发送它,然后打印响应状态代码。然后,你需要"解码响应并显示它"。
这个查询的JSON
响应比contents
查询要简单,所以你要先对其进行解码。
解码JSON
¶
如果你的数据模型和JSON
值之间有很好的匹配,那么JSONDecoder
的默认init(from:)
就是诗意的运动,让你在一行代码中解码数组和字典的复杂结构。不幸的是,api.raywenderlich.com
发送了一个深度嵌套的JSON
结构,你可能不想在你的应用程序中复制。所以,现在是时候了解更多关于CodingKey
枚举和自定义init(from:)
方法了。
Note
通过我们的教程《Swift
中的编码和解码》深入了解JSON
bit.ly/3bqsrBY。
解码JSON
,(几乎)符合你的数据模型¶
在第19章"保存文件"中,你看到将Team
结构编码和解码为JSON
是多么容易,因为它的所有属性都是Codable
的。你正在保存和加载你的应用程序自己的数据模型,所以JSON
格式的项目名称和结构与你的Team
结构完全匹配。
由现实世界的API
发送的JSON
值很少与你想命名或结构你的应用程序的数据的方式相匹配。
如果JSON
结构与你的应用程序的数据模型相匹配,但JSON
名称使用snake_case
,而你的属性名称使用camelCase
,你只需告诉解码器进行翻译:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
这负责将JSON
名称如released_at
和video_identifier
翻译成属性名称releasedAt
和videoIdentifier
。
如果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
进行解码:
- 定义你的数据模型来反映
JSON
值。 - 将
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
, attributes
和url
属性创建独立的结构。这些结构之间的关系与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
}
- 你不需要为这个任务配置
JSONDecoder
,所以你只需在内联创建一个。 - 你对顶层的
ResponseData
进行解码,这样你就可以访问它的data.attributes.url
属性。 - 在主队列上运行
print
语句。这在Playground
中并不是真正必要的,但在应用程序中是必不可少的。你通常在dataTask
处理程序中做一些事情来更新用户界面,而所有的UI更新都必须在主队列中运行。
➤ 运行Playground
:
URL
字符串出现在调试区,Playground
执行停止。
Note
如果你在URLSession
代码下面添加了ResponseData
和其他结构,你必须运行整个Playground
。如果你点击resume()
行上的运行按钮,它们是不"可见"的。
➤ 选项点击url
:
它的类型是String
,正如你所期望的那样。
➤ 在VideoAttributes
中,改变url
的类型:
let url: URL
➤ 再次运行Playground
,然后检查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"
看成是顶级容器里面的一个容器。
➤ 注释掉ResponseData
、Video
和VideoAttributes
结构,然后添加这段代码:
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
值中,使用编码键来创建容器并访问其内容。
- 首先,你得到顶级容器。在
CodingKeys
中的case data
与这个顶级容器中的东西匹配。 - 接下来,你抓取嵌套的容器,与
CodingKeys
中的case data
相匹配。DataKeys
中的case attributes
与dataContainer
中的内容相匹配。 - 然后,你对
dataContainer
中的attributes
项进行解码,将其映射到VideoAttributes
结构。所以attr
是VideoAttributes
的一个实例。 - 最后,你到达了
JSON
层次结构中的"url"
值! 你把它分配给这个VideoURLString
实例的顶级urlString
属性。
这比在你的数据模型结构中反映JSON
层次结构的工作要多,但它有两个优点。
- 你的数据模型结构更扁平,更容易理解。它们与你的应用程序的数据的心理模型相匹配。
- 如果
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
:
当你把contents
的JSON
值平铺到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
值。之后,你将使用episodeDateFormatter
在EpisodeView
中显示这个日期的月份和年份。
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
。
由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
}
- 声明你要从
JSON
响应中映射的属性。擷取的項目如果不是集數,就沒有"difficulty"
值,所以這是可選的。 - 这些属性大多与
"attributes"
容器中的项目相匹配,但域"id"
值被嵌套在"relationships"
字典的深处。 - 你将创建一个
VideoURL
对象,它将发送一个请求来获取urlString
。 - 如果你想使用
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
枚举DataKeys
、AttrsKeys
和RelKeys
,并创建一个结构来容纳你关心的"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] ?? ""
}
}
}
- 这里,顶层容器是
"data"
。它包含在DataKeys
中命名的项目:"id"
、"attributes"
和"relationships"
。 - 你抓取与
DataKeys
中的case attributes
相匹配的嵌套容器,然后解码你想存储在Episode
中的六个值。 -
对于
releasedAt
编码键,你对String
进行解码,然后用你的毫秒处理的iso8601
格式化将其转换为Date
。 -
同样地,你得到
"relationships"
容器,并对"domains"
项目进行解码。 - 最后,你得到一个
domainId
值并将其转换为平台名称。"domains"
项是一个数组,因为一个情节可能与一个以上的域相关。你取第一个数组项。这是一个可选项,因为一个数组可以是空的。"id"
键的值也是一个可选项,以防没有这个键。如果你能解开这些选项,你在domainDictionary
中查找匹配的平台名称,并将其分配给domain
属性。
你将使用这些解码的值来初始化每个Episode
。对于大多数属性,你只需将解码后的值分配给该属性。但是你要把releaseDate
日期转换成String
值,你需要一种方法来使用videoIdentifier
来获取视频URL
字符串。
VideoURL
类¶
你很快就会从contents
响应中解码video_identifier
并使用它来创建一个VideoURL
对象。
➤ 在VideoURL
的Playground
页面中,删除下面的代码 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
类中,并做了一些小改动:
- 现在你在一个方法中,如果出现问题,你可以退出,所以你在
guard
语句中安全地创建queryURL
,而不是强制解开URL
。 - 将解码后的
JSON
分配给这个VideoURL
对象的urlString
属性,然后打印该值。
➤ 现在用这个来替换let videoId = 3021
:
VideoURL(videoId: 3021)
你创建了一个VideoURL
对象来测试你的新类。
➤ 运行Playground
并检查它是否仍在获取视频URL
:
➤ 将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
的自动操作。所以你必须初始化每一个属性,即使你只是给一个属性分配一个解码的值。
有两个属性需要做更多的工作,而不是仅仅给属性分配本地值:
- 你使用
DateFormatter.plisodeDateFormatter
将releaseDate
日期转换成self.release
字符串,你将在EpisodeView
中显示。 - 如果你解码了一个
videoIdentifier
值,你就用这个值初始化一个VideoURL
实例,并将其urlString
属性分配给self.videoURLString
。
➤ 现在滚动到URLSession
的代码,编译器正在抱怨Episode
没有成员attributes
。将dataTask
处理程序的主队列闭包中的所有代码替换为以下内容:
print(decodedResponse.episodes[0].released)
print(decodedResponse.episodes[0].domain)
你打印一些你新解码的属性。
➤ 运行Playground
。
➤ 滚动查看,init(from:)
运行了20
次:
你的JSON
解码都在工作,这是你在Playground
上所能测试的。你已经准备好将所有这些代码复制并改编到你的应用程序中,使一切都能正常工作!
关键点¶
Playground
对于解决代码问题非常有用。你可以快速检查由方法和操作产生的值。URLComponents
查询项可以帮助你为REST
请求创建URL
编码的URLs
。- 使用
URLSession dataTask
来发送HTTP
请求并处理HTTP
响应。 - 对嵌套的
JSON
值进行解码,可以在你的应用程序的数据模型中反映JSON
结构,或者将JSON
结构平铺到你的数据模型中。 - 使用日期格式化器,如
ISO8601DateFormatter
,将日期字符串转换为Date
值。