在 Swift 5 和 SwiftUI 中通过 URLRequest 与 URLSession 请求 JSON 并反序列化

在 Swift 中,HTTP 请求可以通过 URLSession 发起;在这篇文章中,暂时不会涉及到新的 Async Await 特性来使用 URLSession

目前,我们所有的代码都会在 Xcode 的 Playground 类型项目中进行,后面将会简单介绍使用 @escaping 关键字的 Escaping Closure 在 SwiftUI 中的用法

Xcode Playground

请求数据

首先,要使用 URLSession,我们需要 import Foundation

接下来,创建 URLSession 的 DataTask 之前,我们还需要建立一个 URL 类型的数据

URL 类型将会返回 optional,即有可能不会返回一个 URL 类型的数据,可能是因为错误的 Url 所导致的。

在非函数内,可以使用 if let 来确保接收到了正确的 Url 数据

1
2
3
4
5
if let url = URL(string: "https://api.spacexdata.com/v3/missions") {
// Next step...
} else {
fatalError("Url error")
}

也可以使用 guard 来确保接收到了正确的 Url 数据

1
2
3
guard let url = URL(string: "https://api.spacexdata.com/v3/missions") else {
fatalError("Url error")
}

有了 Url 之后,我们就可以创建 URLSession 了;当然,URLSession 还可以使用 URLRequest 来创建,我们将会在后头提到

一般来说,我们可以使用系统提供给我们的共享的 URLSession 来节省资源。可以通过 URLSession.shared.dataTask 来调用

dataTask 中需要两个参数传递到方法中,一个是 with 可以跟着先前的 Url 数据或者一个 URLRequest 类型的数据

一个是 completionHandler 用来处理当请求完成时应该做的操作

completionHandler 是一个 Closure,在 Swift 中我们也可以这么写

1
2
3
let task = URLSession.shared.dataTask(with: url) { data, response, error in
// Next step
}

需要用 in 关键字来表明以下是闭包的代码部分,而不是参数部分

这三个都是 optional 的数据

  • data: 请求成功后会包含服务器返回的数据
  • response: 请求成功后会返回服务器返回的元数据,比如 HTTP Code,Headers 等
  • error: 请求错误后会返回报错信息
1
2
3
4
5
6
7
8
if let error = error {
// Error Handle...
fatalError("Error: \(error)")
}

guard let data = data else {
fatalError("Data error")
}

通过 guard let data = data 的语法,我们坚信已经取到了返回的数据了,接下来我们可以进行 print

哦,别忘了,这个 task 并不会执行,直到我们调用 task.resume()

我们修改下代码,让闭包内的代码看起来像是这样

1
2
3
4
5
6
7
8
9
10
if let error = error {
// Error Handle...
fatalError("Error: \(error)")
}

guard let data = data else {
fatalError("Data error")
}

print(data)

然后在外部调用 task.resume()

目前整个代码看起来像是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Foundation

guard let url = URL(string: "https://api.spacexdata.com/v3/missions") else {
fatalError("Url error")
}

let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
// Error Handle...
fatalError("Error: \(error)")
}

guard let data = data else {
fatalError("Data error")
}

print(data)
}

task.resume()

我们来执行一下看看控制台会输出何物

1
9180 bytes

image.png

控制台输出了服务器返回数据的大小

这说明 data 是拿到了,但是我们想输出的是服务器返回给我们的文本数据看看,这时需要使用 String() 来做到这点

把我们的 print 改成

1
print(String(data: data, encoding: .utf8) ?? "No data")

这下控制台就会输出满满当当的文本数据了

image.png

反序列化

服务器返回了一个有效的 JSON 数据

Swift 是一个静态类型的语言,在服务器返回 JSON 数据后反序列化前我们需要进行数据建模,我们先来分析一下 API 返回的 JSON 结构

image.png

Root 是我们 JSON 的起始花括号 {},并且 Root 的数据类型是一个 Array

因此返回的 Root 一定是 {[]}

所以,我们只需要着重建模数组内的单个数据模型即可

  • mission_name: string
  • mission_id: string
  • manufacturers: string[]
  • payload_ids: string[]
  • wikipedia: string
  • website: string
  • twitter: string
  • description: string

分析完毕后,我们在同一个 Playground 文件内建立一个新的结构体 struct

我们把这个 struct 叫做 SpacexMission

1
2
3
4
5
6
7
8
9
10
struct SpacexMission {
var mission_name: String
var mission_id: String
var manufacturers: [String]
var payload_ids: [String]
var wikipedia: String
var website: String
var twitter: String
var description: String
}

接下来我们使用 Swift 的 JSONDecoder 来对返回的数据反序列化

我们会利用 try! 来捕捉错误,但是我们暂时不想处理错误,如果出错就让程序先崩溃掉

1
let jsonData = try! JSONDecoder().decode(Decodable.Protocol, from: Data)

decode 里面的首个参数是一个遵循 Decodable 协议 (其他语言中的接口 Interface) 的结构体或者类

后面跟着一个 from 参数是我们从服务器获取到的数据

我们需要在我们的 SpacexMission 结构体声明这个结构体遵循 Decodable 协议,但是为了方便,我们可以遵循 Codable 协议;这样相当于既遵循 Decodable 也遵循 Encodable

于是我们把 Struct 改成

1
2
3
4
5
6
7
8
9
10
struct SpacexMission: Codable {
var mission_name: String
var mission_id: String
var manufacturers: [String]
var payload_ids: [String]
var wikipedia: String
var website: String
var twitter: String
var description: String
}

好,然后我们把 JSONDecoder 需要的参数填上

1
JSONDecoder().decode(SpacexMission.self, from: data)

等等,现在有点怪怪的

我们的数据结构不只是一个 SpacexMission 的数据,而是很多个 SpacexMission 组成的数据

所以现在我们其实应该往 JSONDecoder 里面使用 [SpacexMission].self 才对劲

最后我们处理 JSON 反序列化的代码应该像是这样的

1
let jsonData = try! JSONDecoder().decode([SpacexMission].self, from: data)

让我们跑一次看看

很不幸的是,我们的控制台抛出了一个错误

1
__lldb_expr_44/MyPlayground.playground:28: Fatal error: 'try!' expression unexpectedly raised an error: Swift.DecodingError.valueNotFound(Swift.String, Swift.DecodingError.Context(codingPath: [_JSONKey(stringValue: "Index 1", intValue: 1), CodingKeys(stringValue: "twitter", intValue: nil)], debugDescription: "Expected String value but found null instead.", underlyingError: nil))

这看起来是一个反序列化时候出现的错误,在我们的 JSON 中 twitter 这个键有些返回的数据可能是空的

我们回到服务器返回的数据看看

image.png

果不其然,这里有些 Twitter 的数据是 null,那我们需要在 struct 中让 Swift 知道这个 Twitter 是一个可选的

把我们的 Struct 改成这样

1
2
3
4
5
6
7
8
9
10
struct SpacexMission: Codable {
var mission_name: String
var mission_id: String
var manufacturers: [String]
var payload_ids: [String]
var wikipedia: String
var website: String
var twitter: String?
var description: String
}

然后再运行一次就不报错了

接下来我们就可以循环我们的数据了

1
2
3
for mission in jsonData {
print(mission.mission_name)
}

控制台就会成功输出

1
2
3
4
5
6
7
8
9
10
Thaicom
Telstar
Iridium NEXT
Commercial Resupply Services
SES
JCSAT
AsiaSat
Orbcomm OG2
ABS
Eutelsat

大功告成!

通过 URLRequest

在这里,我们会简单介绍一下 URLRequest

在 URLSession 中创建的 dataTask 默认都是 GET 方法

如果你需要执行其他的 HTTP 方法,往 Body 中加入数据,添加 Headers 等等操作,将会涉及到 URLRequest

我们将会发送一个简单的 POST 请求到 https://httpbin.org/post

稍微改造一下前面的代码成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import Foundation

struct User: Codable {
var Username: String
var Password: String
}

guard let url = URL(string: "https://httpbin.org/post") else {
fatalError("Url error")
}

var request = URLRequest(url: url)
var userData = User(Username: "Leader One<ADACLOSURECHURCH>", Password: "ADACLOSURECHURCHC")

request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(userData)

let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
// Error Handle...
fatalError("Error: \(error)")
}

guard let data = data else {
fatalError("Data error")
}

print(String(data: data, encoding: .utf8) ?? "No data")
}

task.resume()

HTTPBin 将会返回以下数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"args": {},
"data": "",
"files": {},
"form": {
"{\"Password\":\"ADACLOSURECHURCHC\",\"Username\":\"Leader One<ADACLOSURECHURCH>\"}": ""
},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Content-Length": "74",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "MyPlayground/1 CFNetwork/1327.0.4 Darwin/21.2.0",
"X-Amzn-Trace-Id": "Root=1-620cf86c-70383fd70ed08184114a80d3"
},
"json": null,
"origin": "103.144.149.138",
"url": "https://httpbin.org/post"
}

你会发现目前数据是按照 Form (application/x-www-form-urlencoded) 的形式提交的,如果你要提交的是 JSON,你可以复写 Content-Type 的 Header

1
request.addValue("application/json", forHTTPHeaderField: "Content-Type")

加入这段代码再次运行,回来的结果就会是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"args": {},
"data": "{\"Password\":\"ADACLOSURECHURCHC\",\"Username\":\"Leader One<ADACLOSURECHURCH>\"}",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Content-Length": "74",
"Content-Type": "application/json",
"Host": "httpbin.org",
"User-Agent": "MyPlayground/1 CFNetwork/1327.0.4 Darwin/21.2.0",
"X-Amzn-Trace-Id": "Root=1-620cf8d1-4c9c860a0750f4823afbe15f"
},
"json": {
"Password": "ADACLOSURECHURCHC",
"Username": "Leader One<ADACLOSURECHURCH>"
},
"origin": "103.144.149.138",
"url": "https://httpbin.org/post"
}

很好,达到目的了

SwiftUI

在 SwiftUI 中请求数据,因为操作是异步的,我们可能需要涉及到自己写一个闭包

@escaping 将会允许我们的闭包在调用的函数生命周期结束后继续运行

我们先来启动一个 SwiftUI 项目

这是我在上个月新建的一个项目

image

我们把上面的 SpaceX mission 的数据结构给拿过来用

image.png

添加一个新的 Group 取名叫做 Models

并且在里面新建一个新的 Swift 文件叫做 SpaceXDataProvider

image.png

我们先把上面的 SpacexMission 的数据结构拷贝过来

然后再写一个新的 Struct 叫做 SpaceXDataProvider

在这个结构体里面写一个新的方法叫做 loadMissionData

目前看起来像是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//
// SpaceXDataProvider.swift
// SpaceXDemo
//
// Created by Jimmy on 2/16/22.
//

import Foundation

struct SpacexMission: Codable {
var mission_name: String
var mission_id: String
var manufacturers: [String]
var payload_ids: [String]
var wikipedia: String
var website: String
var twitter: String?
var description: String
}

struct SpaceXDataProvider {
func loadMissionData() {

}
}

我们要在 loadMissionData() 这个函数里面加入一个参数叫做 completion

1
2
3
func loadMissionData(completion: @escaping ([SpacexMission]) -> ()) {
// Go on...
}

接下来先按照类似于上面的方法,创建 URLSession 的任务,并且请求数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func loadMissionData(completion: @escaping ([SpacexMission]) -> ()) {
guard let url = URL(string: "https://api.spacexdata.com/v3/missions") else {
fatalError("Url ERROR")
}

let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
fatalError("\(error)")
}

guard let data = data else {
fatalError("Data error")
}

let jsonData = try! JSONDecoder().decode([SpacexMission].self, from: data)

DispatchQueue.main.async {
completion(jsonData)
}
}

task.resume()
}

我们的代码将会在主线程上执行我们的闭包,因此加入 DispatchQueue.main.async 然后在里面运行 completion() 然后把我们反序列化好的数据放进去

让我们先回到 SwiftUI

我们先在 ContainView 下建立一个类型为 [SpacexMission] 的状态

1
2
3
4
5
6
7
struct ContentView: View {
@State private var spacexMissionData: [SpacexMission] = []

var body: some View {
Text("SwiftUI")
}
}

然后改一下下面的 UI

我希望这会是一个 NavigationView

我们的 body 目前会是这样

1
2
3
4
5
6
7
8
9
var body: some View {
NavigationView {
if !spacexMissionData.isEmpty {

} else {
ProgressView("Loading mission data")
}
}
}

这么写,可以让 SwiftUI 自动为我们侦测上面的 spacexMissionData 数组是否为空,如果为空就显示 Loading mission data 的转圈圈页面

image.png

我们再继续完善一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var body: some View {
NavigationView {
if !spacexMissionData.isEmpty {
List(spacexMissionData, id: \.self) { mission in
NavigationLink(destination: {
Text(mission.description)
.padding()
}) {
Text(mission.mission_name)
}
}
.navigationTitle("SpaceX App")
} else {
ProgressView("Loading mission data")
}
}
}

我们这里用到了 SwiftUI 的一个 List 组件,这里可能会报错,说 Initializer 'init(_:id:rowContent:)' requires that 'SpacexMission' conform to 'Hashable'

那我们到定义 SpacexMission 的地方让他遵循 Hashable 协议

1
2
3
4
5
6
7
8
9
10
struct SpacexMission: Codable, Hashable {
var mission_name: String
var mission_id: String
var manufacturers: [String]
var payload_ids: [String]
var wikipedia: String
var website: String
var twitter: String?
var description: String
}

这样报错就消失了

基本的逻辑我们已经完成了,现在,我们要怎么让 SwiftUI 在启动 App 的时候加载数据呢?

我们会通过 .onAppear 来做到

我们在 NavigationView 的下方输入 .onAppear

然后在 .onAppear 的代码作用域中调用前面我们写好的方法

现在,整个 SwiftUI 代码将会像是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ContentView: View {
@State private var spacexMissionData: [SpacexMission] = []

var body: some View {
NavigationView {
if !spacexMissionData.isEmpty {
List(spacexMissionData, id: \.self) { mission in
NavigationLink(destination: {
Text(mission.description)
.padding()
}) {
Text(mission.mission_name)
}
}
.navigationTitle("SpaceX App")
} else {
ProgressView("Loading mission data")
}
}
.onAppear {
SpaceXDataProvider().loadMissionData { missions in
spacexMissionData = missions
}
}
}
}

让我们来看看实现效果

image.png

image.png

非常棒,达到了我们的效果!