在 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 | if let url = URL(string: "https://api.spacexdata.com/v3/missions") { |
也可以使用 guard
来确保接收到了正确的 Url 数据
1 | guard let url = URL(string: "https://api.spacexdata.com/v3/missions") else { |
有了 Url 之后,我们就可以创建 URLSession 了;当然,URLSession 还可以使用 URLRequest 来创建,我们将会在后头提到
一般来说,我们可以使用系统提供给我们的共享的 URLSession 来节省资源。可以通过 URLSession.shared.dataTask
来调用
dataTask
中需要两个参数传递到方法中,一个是 with
可以跟着先前的 Url 数据或者一个 URLRequest 类型的数据
一个是 completionHandler
用来处理当请求完成时应该做的操作
completionHandler
是一个 Closure,在 Swift 中我们也可以这么写
1 | let task = URLSession.shared.dataTask(with: url) { data, response, error in |
需要用 in
关键字来表明以下是闭包的代码部分,而不是参数部分
这三个都是 optional 的数据
- data: 请求成功后会包含服务器返回的数据
- response: 请求成功后会返回服务器返回的元数据,比如 HTTP Code,Headers 等
- error: 请求错误后会返回报错信息
1 | if let error = error { |
通过 guard let data = data
的语法,我们坚信已经取到了返回的数据了,接下来我们可以进行 print
了
哦,别忘了,这个 task 并不会执行,直到我们调用 task.resume()
我们修改下代码,让闭包内的代码看起来像是这样
1 | if let error = error { |
然后在外部调用 task.resume()
目前整个代码看起来像是这样
1 | import Foundation |
我们来执行一下看看控制台会输出何物
1 | 9180 bytes |
控制台输出了服务器返回数据的大小
这说明 data 是拿到了,但是我们想输出的是服务器返回给我们的文本数据看看,这时需要使用 String()
来做到这点
把我们的 print
改成
1 | print(String(data: data, encoding: .utf8) ?? "No data") |
这下控制台就会输出满满当当的文本数据了
反序列化
服务器返回了一个有效的 JSON 数据
Swift 是一个静态类型的语言,在服务器返回 JSON 数据后反序列化前我们需要进行数据建模,我们先来分析一下 API 返回的 JSON 结构
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 | struct SpacexMission { |
接下来我们使用 Swift 的 JSONDecoder 来对返回的数据反序列化
我们会利用 try!
来捕捉错误,但是我们暂时不想处理错误,如果出错就让程序先崩溃掉
1 | let jsonData = try! JSONDecoder().decode(Decodable.Protocol, from: Data) |
decode 里面的首个参数是一个遵循 Decodable 协议 (其他语言中的接口 Interface) 的结构体或者类
后面跟着一个 from 参数是我们从服务器获取到的数据
我们需要在我们的 SpacexMission 结构体声明这个结构体遵循 Decodable 协议,但是为了方便,我们可以遵循 Codable 协议;这样相当于既遵循 Decodable 也遵循 Encodable
于是我们把 Struct 改成
1 | struct SpacexMission: Codable { |
好,然后我们把 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 这个键有些返回的数据可能是空的
我们回到服务器返回的数据看看
果不其然,这里有些 Twitter 的数据是 null,那我们需要在 struct 中让 Swift 知道这个 Twitter 是一个可选的
把我们的 Struct 改成这样
1 | struct SpacexMission: Codable { |
然后再运行一次就不报错了
接下来我们就可以循环我们的数据了
1 | for mission in jsonData { |
控制台就会成功输出
1 | Thaicom |
大功告成!
通过 URLRequest
在这里,我们会简单介绍一下 URLRequest
在 URLSession 中创建的 dataTask 默认都是 GET 方法
如果你需要执行其他的 HTTP 方法,往 Body 中加入数据,添加 Headers 等等操作,将会涉及到 URLRequest
我们将会发送一个简单的 POST 请求到 https://httpbin.org/post
稍微改造一下前面的代码成
1 | import Foundation |
HTTPBin 将会返回以下数据
1 | { |
你会发现目前数据是按照 Form (application/x-www-form-urlencoded) 的形式提交的,如果你要提交的是 JSON,你可以复写 Content-Type 的 Header
1 | request.addValue("application/json", forHTTPHeaderField: "Content-Type") |
加入这段代码再次运行,回来的结果就会是这样
1 | { |
很好,达到目的了
SwiftUI
在 SwiftUI 中请求数据,因为操作是异步的,我们可能需要涉及到自己写一个闭包
@escaping
将会允许我们的闭包在调用的函数生命周期结束后继续运行
我们先来启动一个 SwiftUI 项目
这是我在上个月新建的一个项目
我们把上面的 SpaceX mission 的数据结构给拿过来用
添加一个新的 Group 取名叫做 Models
并且在里面新建一个新的 Swift 文件叫做 SpaceXDataProvider
我们先把上面的 SpacexMission 的数据结构拷贝过来
然后再写一个新的 Struct 叫做 SpaceXDataProvider
在这个结构体里面写一个新的方法叫做 loadMissionData
目前看起来像是这样
1 | // |
我们要在 loadMissionData()
这个函数里面加入一个参数叫做 completion
1 | func loadMissionData(completion: @escaping ([SpacexMission]) -> ()) { |
接下来先按照类似于上面的方法,创建 URLSession 的任务,并且请求数据
1 | func loadMissionData(completion: @escaping ([SpacexMission]) -> ()) { |
我们的代码将会在主线程上执行我们的闭包,因此加入 DispatchQueue.main.async
然后在里面运行 completion()
然后把我们反序列化好的数据放进去
让我们先回到 SwiftUI
我们先在 ContainView 下建立一个类型为 [SpacexMission]
的状态
1 | struct ContentView: View { |
然后改一下下面的 UI
我希望这会是一个 NavigationView
我们的 body 目前会是这样
1 | var body: some View { |
这么写,可以让 SwiftUI 自动为我们侦测上面的 spacexMissionData 数组是否为空,如果为空就显示 Loading mission data 的转圈圈页面
我们再继续完善一下
1 | var body: some View { |
我们这里用到了 SwiftUI 的一个 List 组件,这里可能会报错,说 Initializer 'init(_:id:rowContent:)' requires that 'SpacexMission' conform to 'Hashable'
那我们到定义 SpacexMission 的地方让他遵循 Hashable
协议
1 | struct SpacexMission: Codable, Hashable { |
这样报错就消失了
基本的逻辑我们已经完成了,现在,我们要怎么让 SwiftUI 在启动 App 的时候加载数据呢?
我们会通过 .onAppear 来做到
我们在 NavigationView
的下方输入 .onAppear
然后在 .onAppear 的代码作用域中调用前面我们写好的方法
现在,整个 SwiftUI 代码将会像是这样的
1 | struct ContentView: View { |
让我们来看看实现效果
非常棒,达到了我们的效果!