模仿 Apple 官方範例串接 JSON API,定義 function 型別的 completion 參數 & 使用 Result type
Apple 的 Develop in Swift Data Collections
第二章 Guided Project: Restaurant
介紹適合初學者參考的 JSON API 串接寫法,接下來就讓我們一步步介紹如何模仿抄襲 Apple 大大的範例吧。
ps: Apple 官方範例舊版採用的是沒有 Result type 的寫法,有興趣的朋友也可以參考。
下載 Develop in Swift Data Collections Teacher Guide
待會我們將參考 Develop in Swift Data Collections
裡提供的程式範例。
點選 Download teacher materials 下載範例程式
啟動 server 程式 Open Restaurant
Apple 的範例需要從 server 抓取資料,因此我們必須先啟動 server 程式。Apple 已經幫我們將 server 程式寫好了,請從下載的範例資料夾找到 Open Restaurant
。(2 — Working With the Web > Guided Project — Restaurant > resources > Open Restaurant )
點選後從右鍵選單點擊 Open。
再次點擊 Open。
Cool,Server 程式的視窗出現了。
從 Server App 的視窗畫面可進行後台資料的編輯,點選右下角的 Start 啟動後台,讓 App 可以連線抓取資料。
打開專案執行 App
切換到 Develop in Swift Data Collections 下載的資料夾,找到 OrderApp 資料夾。(2 — Working With the Web > Guided Project — Restaurant > resources > OrderApp)
雙擊 OrderApp.xcodeproj 打開專案。
找到 MenuController.swift,將 baseURL 連結的 port 改成 8080。(因為 Server App 設定的 port 是 8080)
let baseURL = URL(string: "http://localhost:8080/")!
執行 App 後,我們可從 Server 抓取 Restaurant 的相關資料並訂購想買的商品。(ps: 別緊張,只是假的購買,不會花到錢)
研究模仿 Apple 範例程式
- 串接網路的程式統一定義在一個檔案
範例程式將跟後台溝通的相關程式統一定義在 MenuController 裡,方便 App 的各個畫面使用,比方將抓取商品類別的 API 定義成 function fetchCategories,上傳訂單的 API 定義成 function submitOrder。
- 使用 baseURL,URLComponents & URLQueryItem 產生 URL
baseURL 的宣告如下。
let baseURL = URL(string: "http://localhost:8080/")!
比方在 fetchMenuItems 裡利用 baseURL,URLComponents & URLQueryItem 產生 URL。
func fetchMenuItems(forCategory categoryName: String, completion: @escaping (Result<[MenuItem], Error>) -> Void) {
let baseMenuURL = baseURL.appendingPathComponent("menu")
var components = URLComponents(url: baseMenuURL, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "category", value: categoryName)]
let menuURL = components.url!
相關說明可參考以下連結。
- 透過 static 定義的 shared 常數呼叫串接後台的 function
App 各個畫面的 view controller 可透過 MenuController.shared 呼叫 function,串接後台的 API。
class MenuController {
static let shared = MenuController()
比方以下程式利用 MenuController.shared.fetchCategories 取得 categories。
override func viewDidLoad() {
super.viewDidLoad()
MenuController.shared.fetchCategories { result in
switch result {
case .success(let categories):
self.updateUI(with: categories)
case .failure(let error):
self.displayError(error, title: "Failed to Fetch Categories")
}
}
}
- 後台回傳的資料帶在加上 @escaping 的 function 型別參數裡,function 型別的參數是 Result type。
比方呼叫 function fetchMenuItems 時,它的參數 completion 型別為 (Result<[MenuItem], Error>) -> Void
,資料的型別為 [MenuItem],成功時將呼叫 completion 回傳資料,失敗時也會呼叫 completion 回傳錯誤。
func fetchMenuItems(forCategory categoryName: String, completion: @escaping (Result<[MenuItem], Error>) -> Void) {
let baseMenuURL = baseURL.appendingPathComponent("menu")
var components = URLComponents(url: baseMenuURL, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "category", value: categoryName)]
let menuURL = components.url!
let task = URLSession.shared.dataTask(with: menuURL) { data, response, error in
if let data = data {
do {
let jsonDecoder = JSONDecoder()
let menuResponse = try jsonDecoder.decode(MenuResponse.self, from: data)
completion(.success(menuResponse.items))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}
task.resume()
}
- 利用 switch 判斷串接 API 的結果是 success 還是 failure,從 case success 的 associated value 取得資料。成功時呼叫 updateUI 更新畫面,失敗時呼叫 displayError 顯示錯誤。
MenuController.shared.fetchCategories { result in
switch result {
case .success(let categories):
self.updateUI(with: categories)
case .failure(let error):
self.displayError(error, title: "Failed to Fetch Categories")
}
}
- JSON 對應的資料型別名字以 Response 結尾。
比方 JSON 內容如下。
{"categories": ["appetizers", "salads", "soups", "entrees", "desserts", "sandwiches"]}
對應的型別為 CategoriesResponse。
struct CategoriesResponse: Codable {
let categories: [String]
}
- 在 App 啟動時增加 URLCache 的 cache size
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let temporaryDirectory = NSTemporaryDirectory()
let urlCache = URLCache(memoryCapacity: 25_000_000, diskCapacity: 50_000_000, diskPath: temporaryDirectory)
URLCache.shared = urlCache
return true
}
- 檢查 indexPath,判斷 cell 還是原來的 indexPath 時才更新圖片
MenuController.shared.fetchImage(url: menuItem.imageURL) { image in
guard let image = image else { return }
DispatchQueue.main.async {
if let currentIndexPath = self.tableView.indexPath(for: cell),
currentIndexPath != indexPath {
return
}
cell.imageView?.image = image
cell.setNeedsLayout()
}
}
不過此方法在 collection view 測試時可能會有問題,有興趣的朋友可另外參考以下連結的解法。
- 更新畫面 & 顯示錯誤時利用 DispatchQueue.main.async 切換到 main thread。
func updateUI(with categories: [String]) {
DispatchQueue.main.async {
self.categories = categories
self.tableView.reloadData()
}
}
func displayError(_ error: Error, title: String) {
DispatchQueue.main.async {
let alert = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil))
self.present(alert, animated: true, completion: nil)
}
}
- MenuController 的完整程式
import Foundation
import UIKit
class MenuController {
static let orderUpdatedNotification = Notification.Name("MenuController.orderUpdated")
static let shared = MenuController()
let baseURL = URL(string: "http://localhost:8080/")!
var order = Order() {
didSet {
NotificationCenter.default.post(name: MenuController.orderUpdatedNotification, object: nil)
}
}
func fetchCategories(completion: @escaping (Result<[String], Error>) -> Void) {
let categoriesURL = baseURL.appendingPathComponent("categories")
let task = URLSession.shared.dataTask(with: categoriesURL) { data, response, error in
if let data = data {
do {
let jsonDecoder = JSONDecoder()
let categoriesResponse = try jsonDecoder.decode(CategoriesResponse.self, from: data)
completion(.success(categoriesResponse.categories))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}
task.resume()
}
func fetchMenuItems(forCategory categoryName: String, completion: @escaping (Result<[MenuItem], Error>) -> Void) {
let baseMenuURL = baseURL.appendingPathComponent("menu")
var components = URLComponents(url: baseMenuURL, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "category", value: categoryName)]
let menuURL = components.url!
let task = URLSession.shared.dataTask(with: menuURL) { data, response, error in
if let data = data {
do {
let jsonDecoder = JSONDecoder()
let menuResponse = try jsonDecoder.decode(MenuResponse.self, from: data)
completion(.success(menuResponse.items))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}
task.resume()
}
typealias MinutesToPrepare = Int
func submitOrder(forMenuIDs menuIDs: [Int], completion: @escaping (Result<MinutesToPrepare, Error>) -> Void) {
let orderURL = baseURL.appendingPathComponent("order")
var request = URLRequest(url: orderURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let data = ["menuIds": menuIDs]
let jsonEncoder = JSONEncoder()
let jsonData = try? jsonEncoder.encode(data)
request.httpBody = jsonData
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
do {
let jsonDecoder = JSONDecoder()
let orderResponse = try jsonDecoder.decode(OrderResponse.self, from: data)
completion(.success(orderResponse.prepTime))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}
task.resume()
}
func fetchImage(url: URL, completion: @escaping (UIImage?) -> Void) {
print("image", url)
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data,
let image = UIImage(data: data) {
completion(image)
} else {
completion(nil)
}
}
task.resume()
}
}
初學者撰寫串接後台的程式時可模仿範例的寫法,將跟後台溝通的相關程式統一定義在某個檔案,比方串接 TMBD API 開發電影 App 時,將相關的 API 定義成 NetworkController 或 MovieController 的 function,然後利用 static 宣告的變數 shared 產生物件呼叫,再從 function 裡的 Result type 參數取得回傳的資料。
以下幾種都是網路抓資料常見的檔名結尾。
- Controller
- Service
- Client
- Manager
- Fetcher
利用 capture list 解決 closure 可能產生的記憶體問題
呼叫 API 時,我們也可以在 closure 裡加上 capture list,它的好處可參考以下連結的說明。
MenuController.shared.fetchCategories {[weak self] (result) in
guard let self = self else { return }
switch result {
case .success(let categories):
self.updateUI(with: categories)
case .failure(let error):
self.displayError(error, title: "Failed to Fetch Categories")
}
}
串接 REST API,練習 http get,post,delete,put,以 Reqres API 為例
剛剛 Apple 的範例已經很完整,不過常見的 REST API 通常包含了 HTTP get,post,delete & put,因此接下來讓我們以 Reqres API 為例,試試串接 REST API。
- model 資料型別
struct UsersResponse: Decodable {
let page: Int
let perPage: Int
let total: Int
let totalPages: Int
let data: [User]
}
struct User: Decodable {
let id: Int
let email: String
let firstName: String
let lastName: String
let avatar: URL
}
struct CreateUserResponse: Decodable {
let name: String
let job: String
let id: String
}
struct UpdateUserResponse: Decodable {
let name: String
let job: String
}
struct CreateUserBody: Encodable {
let name: String
let job: String
}
struct UpdateUserBody: Encodable {
let name: String
let job: String
}
- 定義設定網址參數的 URL extension
extension URL {
func withQueries(_ queries: [String: String]) -> URL? {
var components = URLComponents(url: self, resolvingAgainstBaseURL: true)
components?.queryItems = queries.map {
URLQueryItem(name: $0.key, value: $0.value)
}
return components?.url
}
}
- 定義錯誤型別 NetworkError
enum NetworkError: Error{
case invalidURL
}
- 串接 API 的 NetworkController
class NetworkController {
static let shared = NetworkController()
let baseURL = URL(string: "https://reqres.in/api")!
func getUsers(page: Int, completion: @escaping (Result<UsersResponse, Error>) -> Void) {
let queries = ["page": "\(page)"]
guard let url = baseURL.appendingPathComponent("users").withQueries(queries) else {
completion(.failure(NetworkError.invalidURL))
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let usersResponse = try decoder.decode(UsersResponse.self, from: data)
completion(.success(usersResponse))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}.resume()
}
func createUser(_ user: CreateUserBody, completion: @escaping (Result<CreateUserResponse, Error>) -> Void) {
let url = baseURL.appendingPathComponent("users")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
let data = try? encoder.encode(user)
request.httpBody = data
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
do {
let decoder = JSONDecoder()
let createUserResponse = try decoder.decode(CreateUserResponse.self, from: data)
completion(.success(createUserResponse))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}.resume()
}
func deleteUser(userId: Int, completion: @escaping (Result<String, Error>) -> Void) {
let url = baseURL.appendingPathComponent("users/\(userId)")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
URLSession.shared.dataTask(with: request) { data, response, error in
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 204 {
completion(.success("ok"))
} else if let error = error {
completion(.failure(error))
}
}.resume()
}
func updateUser(_ user: UpdateUserBody, completion: @escaping (Result<UpdateUserResponse, Error>) -> Void) {
let url = baseURL.appendingPathComponent("users")
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
let data = try? encoder.encode(user)
request.httpBody = data
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
do {
let decoder = JSONDecoder()
let updateUserResponse = try decoder.decode(UpdateUserResponse.self, from: data)
completion(.success(updateUserResponse))
} catch {
completion(.failure(error))
}
} else if let error = error {
completion(.failure(error))
}
}.resume()
}
}
- 呼叫 NetworkController 定義的 function
import UIKit
class ViewController: UIViewController {
@IBAction func get(_ sender: Any) {
NetworkController.shared.getUsers(page: 1) { result in
switch result {
case .success(let usersResponse):
print(usersResponse)
case .failure(let error):
print(error)
}
}
}
@IBAction func post(_ sender: Any) {
let createUserBody = CreateUserBody(name: "Peter", job: "Writer")
NetworkController.shared.createUser(createUserBody) { result in
switch result {
case .success(let createUserResponse):
print(createUserResponse)
case .failure(let error):
print(error)
}
}
}
@IBAction func remove(_ sender: Any) {
NetworkController.shared.deleteUser(userId: 2) { result in
switch result {
case .success(_):
print("delete ok")
case .failure(let error):
print(error)
}
}
}
@IBAction func put(_ sender: Any) {
let updateUserBody = UpdateUserBody(name: "Peter", job: "情歌王子")
NetworkController.shared.updateUser(updateUserBody) { result in
switch result {
case .success(let updateUserResponse):
print(updateUserResponse)
case .failure(let error):
print(error)
}
}
}
}
精簡程式的 Generic 寫法
如果有很多 API,需要將 JSON 轉成多種不同的 Codable 型別,也可以將處理 reponse 的程式變成搭配 generic 的 function 來簡化程式。比方定義以下的 function handleResponse,利用 URLSession.shared.dataTask 得到資料後,只要呼叫 handleResponse 處理即可。(ps: 此方法只適合 JSONDecoder 解析的型別和 Result 成功時回傳的資料型別一致的 case,若不一樣需用另外的寫法。)
private func handleResponse<T>(decoder: JSONDecoder, data: Data?, error: Error?, completion: @escaping (Result<T, Error>) -> Void) where T: Decodable {
if let data {
do {
let response = try decoder.decode(T.self, from: data)
completion(.success(response))
} catch {
completion(.failure(error))
}
} else if let error {
completion(.failure(error))
}
}
其它 API 串接範例
iTunes API
Apple 的 2.6 Lab iTunes Search — Develop in Swift Data Collections
LBTA 的 AppStore JSON APIs。
TMDB API
學會了基本的 API 串接寫法後,有興趣的朋友可再研究其它大大的寫法,比方以下串接 TMDB API 的 App 範例。
https://developers.themoviedb.org/3
- 範例程式。
- 安裝套件。
pod install
- MovieClient.swift
GitHub API
Sean Allen 的 iOS Dev Job Interview Practice — Take Home Project