模仿 Apple 官方範例串接 JSON API,使用 async & await

Apple 的 Develop in Swift Data Collections 第二章 Guided Project: Restaurant 介紹適合初學者參考的 JSON API 串接寫法,接下來就讓我們一步步介紹如何模仿抄襲 Apple 大大的範例吧。

ps: Apple 的範例使用 async 版的 URLSession function,只支援 Xcode 13 & iOS 13 以上的版本。

下載 Develop in Swift Data Collections Teacher Guide

待會我們將參考 Develop in Swift Data Collections 裡提供的程式範例。

點選 Download teacher materials 下載範例程式

從第 9 頁點選 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 Restaurant 啟動 server。

從 Server App 的視窗畫面可進行後台資料的編輯,比方編輯 categories,menu items & 圖片。點選右下角的 Start 啟動後台,讓 App 可以連線抓取資料。

打開專案執行 App

切換到下載的範例資料夾,找到 OrderApp.xcodeproj。(2 — Working With the Web > Guided Project — Restaurant > resources > OrderApp > OrderApp.xcodeproj)

雙擊 OrderApp.xcodeproj 打開專案。

執行 App

我們可從 Server 抓取 Restaurant 的相關資料並訂購想買的商品。(ps: 別緊張,只是假的購買,不會花到錢)

Apple 範例程式解析

串接網路的程式統一定義在一個檔案

範例程式將跟後台溝通的相關程式統一定義在類別 MenuController 裡,方便 App 的各個畫面使用,比方將抓取商品類別的 API 定義成 function fetchCategories,上傳訂單的 API 定義成 function submitOrder。

func fetchCategories() async throws -> [String]
func fetchMenuItems(forCategory categoryName: String) async throws -> [MenuItem]
func submitOrder(forMenuIDs menuIDs: [Int]) async throws -> MinutesToPrepare
func fetchImage(from url: URL) async throws -> UIImage

使用 baseURL,URLComponents & URLQueryItem 產生 URL

baseURL 的宣告如下。

let baseURL = URL(string: "http://localhost:8080/")!

比方在 fetchMenuItems 裡利用 baseURL,URLComponents & URLQueryItem 產生 URL。

func fetchMenuItems(forCategory categoryName: String) async throws -> [MenuItem] {
let baseMenuURL = baseURL.appendingPathComponent("menu")
var components = URLComponents(url: baseMenuURL, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "category", value: categoryName)]
let menuURL = components.url!

相關說明可參考以下連結。

串接 API 的 function 加上 async & throws,成功時回傳抓到的資料,失敗時丟出錯誤,使用 try await 呼叫 URLSession.shared.data

以 function fetchMenuItems 為例。

func fetchMenuItems(forCategory categoryName: String) async throws -> [MenuItem] {
let baseMenuURL = baseURL.appendingPathComponent("menu")
var components = URLComponents(url: baseMenuURL, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "category", value: categoryName)]
let menuURL = components.url!
let (data, response) = try await URLSession.shared.data(from: menuURL)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MenuControllerError.menuItemsNotFound
}

let decoder = JSONDecoder()
let menuResponse = try decoder.decode(MenuResponse.self, from: data)
return menuResponse.items
}

說明

  • function 加上 async,表示它是一個非同步 function。
  • function 加上 throws,表示它有可能丟出錯誤。
  • 因為是非同步 function,所以可以回傳抓到的資料,-> [MenuItem]表示回傳的資料型別是 [MenuItem]。
  • 使用 try await 呼叫 URLSession.shared.data(from:),從 function data(from:) 回傳的 (data, response) 取得抓到的資料。
  • URLSession.shared.data(from:) 失敗,http status code 不是 200,或是 decode JSON 失敗時會丟出錯誤。

自訂錯誤的型別

enum MenuControllerError: Error, LocalizedError {
case categoriesNotFound
case menuItemsNotFound
case orderRequestFailed
case imageDataMissing
}

透過 static 定義的 shared 常數呼叫串接 API 的 function,呼叫時搭配 await & Task,從 catch 抓取錯誤

App 各個畫面的 view controller 可透過 MenuController.shared 呼叫 function,串接後台的 API。

class MenuController {
static let shared = MenuController()

比方以下程式利用 MenuController.shared.fetchMenuItems 取得 MenuItem 的 array。

override func viewDidLoad() {
super.viewDidLoad()
title = category.capitalized
Task {
do {
let menuItems = try await MenuController.shared.fetchMenuItems(forCategory: category)
updateUI(with: menuItems)
} catch {
displayError(error, title: "Failed to Fetch Menu Items for \(self.category)")
}
}
}

說明

  • fetchMenuItems 是 async function,因此呼叫時要加上 await,而且寫在 Task.init 的 { } 裡。
  • fetchMenuItems 失敗丟出錯誤時將執行 catch { } 的程式,利用另外定義的 function displayError 顯示錯誤的 alert。

在 view controller 加上 @MainActor

為了確保 view controller 呼叫 API 抓資料後能在 main thread 更新 UI,在 view controller 的 class 前加上 @MainActor。

@MainActor
class MenuTableViewController: UITableViewController {

ps: 由於 UIViewController & UITableViewController 都有加上 @MainActor,所以繼承它們的 controller 其實也可以不加 @MainActor。

JSON 對應的資料型別名字以 Response 結尾

struct MenuResponse: Codable {
let items: [MenuItem]
}
struct CategoriesResponse: Codable {
let categories: [String]
}
struct OrderResponse: Codable {
let prepTime: Int
enum CodingKeys: String, CodingKey {
case prepTime = "preparation_time"
}
}

在 App 啟動時增加 URLCache 的 cache size

class AppDelegate: UIResponder, UIApplicationDelegate {
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
}

利用 viewIfLoaded?.window 檢查是否該顯示 alert

func displayError(_ error: Error, title: String) {
guard let _ = viewIfLoaded?.window else { return }
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)
}

利用 indexPath & tableView(_:didEndDisplaying:forRowAt:) 確保 cell 顯示正確的圖片,不需要圖片時將抓圖的 task cancel

因為 cell 重覆利用的特性,我們必須做一些處理確保 cell 顯示正確的圖片。

@MainActor
class MenuTableViewController: UITableViewController {

var imageLoadTasks: [IndexPath: Task<Void, Never>] = [:]
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)

// cancel the image fetching tasks that are no longer needed
imageLoadTasks.forEach { key, value in
value.cancel()
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MenuItem", for: indexPath)
configure(cell, forItemAt: indexPath)
return cell
}
func configure(_ cell: UITableViewCell, forItemAt indexPath: IndexPath) {
guard let cell = cell as? MenuItemCell else { return }

let menuItem = menuItems[indexPath.row]

cell.itemName = menuItem.name
cell.price = menuItem.price
cell.image = nil
imageLoadTasks[indexPath] = Task.init {
if let image = try? await MenuController.shared.fetchImage(from: menuItem.imageURL) {
if let currentIndexPath = self.tableView.indexPath(for: cell),
currentIndexPath == indexPath {
cell.image = image
}
}
imageLoadTasks[indexPath] = nil
}
}
override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// cancel the image fetching task if we no longer need it
imageLoadTasks[indexPath]?.cancel()
}

說明

  • 將抓圖的 task 儲存在property imageLoadTasks。
  • 抓到圖時檢查 cell 目前的 index path,判斷 cell 還是原來的 indexPath 時才更新圖片。
  • 在 tableView(_:didEndDisplaying:forRowAt:) 時,cell 已經不在畫面上,因此取消它的抓圖 task。
  • 在 viewDidDisappear 時整個頁面都看不到了,因此取消全部的抓圖 task。

關於 collection view & table view 的網路圖片顯示問題,也可進一步參考以下說明。

宣告方便整個 App 存取的 property,property 改變時使用 NotificationCenter 通知畫面更新

class MenuController {

static let orderUpdatedNotification = Notification.Name("MenuController.orderUpdated")

var order = Order() {
didSet {
NotificationCenter.default.post(name: MenuController.orderUpdatedNotification, object: nil)
}
}

App 的任何地方都可利用 MenuController.shared.order 存取 order,例如 MenuItemDetailViewController 利用以下程式增加訂購的 menuItem。

MenuController.shared.order.menuItems.append(menuItem)

MenuController 的完整程式

import Foundation
import UIKit

class MenuController {
enum MenuControllerError: Error, LocalizedError {
case categoriesNotFound
case menuItemsNotFound
case orderRequestFailed
case imageDataMissing
}

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() async throws -> [String] {
let categoriesURL = baseURL.appendingPathComponent("categories")

let (data, response) = try await URLSession.shared.data(from: categoriesURL)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MenuControllerError.categoriesNotFound
}

let decoder = JSONDecoder()
let categoriesResponse = try decoder.decode(CategoriesResponse.self, from: data)
return categoriesResponse.categories
}
func fetchMenuItems(forCategory categoryName: String) async throws -> [MenuItem] {
let baseMenuURL = baseURL.appendingPathComponent("menu")
var components = URLComponents(url: baseMenuURL, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "category", value: categoryName)]
let menuURL = components.url!
let (data, response) = try await URLSession.shared.data(from: menuURL)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MenuControllerError.menuItemsNotFound
}

let decoder = JSONDecoder()
let menuResponse = try decoder.decode(MenuResponse.self, from: data)
return menuResponse.items
}

typealias MinutesToPrepare = Int

func submitOrder(forMenuIDs menuIDs: [Int]) async throws -> MinutesToPrepare {
let orderURL = baseURL.appendingPathComponent("order")
var request = URLRequest(url: orderURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

let menuIdsDict = ["menuIds": menuIDs]
let jsonEncoder = JSONEncoder()
let jsonData = try? jsonEncoder.encode(menuIdsDict)
request.httpBody = jsonData

let (data, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MenuControllerError.orderRequestFailed
}

let decoder = JSONDecoder()
let orderResponse = try decoder.decode(OrderResponse.self, from: data)
return orderResponse.prepTime
}

func fetchImage(from url: URL) async throws -> UIImage {
let (data, response) = try await URLSession.shared.data(from: url)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MenuControllerError.imageDataMissing
}
guard let image = UIImage(data: data) else {
throw MenuControllerError.imageDataMissing
}

return image
}
}

模仿 Apple 的範例串接 API

初學者撰寫串接後台的程式時可模仿範例的寫法,將跟後台溝通的相關程式統一定義在某個檔案,比方串接 TMBD API 開發電影 App 時,將相關的 API 定義成 NetworkController 或 MovieController 的 function,然後利用 static 宣告的變數 shared 得到物件,呼叫 async function 取得 API 回傳的資料。

定義抓資料物件的型別名稱時,不一定要以 controller 結尾,以下幾種都是常見的結尾:

  • Controller
  • Service
  • Client
  • Manager
  • Fetcher

串接 REST API,練習 http get,post,delete,put,以 Reqres API 為例

剛剛 Apple 的範例已經很完整,不過常見的 REST API 通常包含了 HTTP get,post,delete & put,因此接下來讓我們以 Reqres API 為例,試試串接 REST API。

model 資料型別

型別名 Response 結尾表示回傳的資料,型別名 Body 結尾表示上傳的資料。

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
case getUsersFailed
case createUserFailed
case deleteUserFailed
case updateUserFailed
}

串接 API 的 NetworkController

class NetworkController {

static let shared = NetworkController()

let baseURL = URL(string: "https://reqres.in/api")!

func getUsers(page: Int) async throws -> UsersResponse {
let queries = ["page": "\(page)"]
guard let url = baseURL.appendingPathComponent("users").withQueries(queries) else {
throw NetworkError.invalidURL
}

let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.getUsersFailed
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let usersResponse = try decoder.decode(UsersResponse.self, from: data)
return usersResponse
}

func createUser(_ user: CreateUserBody) async throws -> CreateUserResponse {
let url = baseURL.appendingPathComponent("users")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
let httpBody = try? encoder.encode(user)
request.httpBody = httpBody

let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 201 else {
throw NetworkError.createUserFailed
}
let decoder = JSONDecoder()
let createUserResponse = try decoder.decode(CreateUserResponse.self, from: data)
return createUserResponse
}

func deleteUser(userId: Int) async throws -> String {
let url = baseURL.appendingPathComponent("users/\(userId)")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"

let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 204 else {
throw NetworkError.deleteUserFailed
}
return "ok"
}

func updateUser(_ user: UpdateUserBody) async throws -> UpdateUserResponse {

let url = baseURL.appendingPathComponent("users")
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

let encoder = JSONEncoder()
let httpBody = try? encoder.encode(user)
request.httpBody = httpBody

let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.updateUserFailed
}
let decoder = JSONDecoder()
let updateUserResponse = try decoder.decode(UpdateUserResponse.self, from: data)
return updateUserResponse
}
}

呼叫 NetworkController 的 function 串接 API

class ViewController: UIViewController {

@IBAction func get(_ sender: Any) {

Task {
do {
let usersResponse = try await NetworkController.shared.getUsers(page: 1)
print(usersResponse)
} catch {
print(error)
}
}
}

@IBAction func post(_ sender: Any) {
let createUserBody = CreateUserBody(name: "Peter", job: "Writer")
Task {
do {
let createUserResponse = try await NetworkController.shared.createUser(createUserBody)
print(createUserResponse)
} catch {
print(error)
}
}
}


@IBAction func remove(_ sender: Any) {
Task {
do {
let result = try await NetworkController.shared.deleteUser(userId: 2)
print("delete ok", result)
} catch {
print(error)
}
}
}

@IBAction func put(_ sender: Any) {
let updateUserBody = UpdateUserBody(name: "Peter", job: "情歌王子")
Task {
do {
let updateUserResponse = try await NetworkController.shared.updateUser(updateUserBody)
print(updateUserResponse)
} catch {
print(error)
}
}
}
}

其它 API 串接範例

iTunes API

Apple 的 2.6 Lab iTunes Search — Develop in Swift Data Collections

import Foundation
import UIKit

class StoreItemController {
enum StoreItemError: Error, LocalizedError {
case itemsNotFound
case imageDataMissing
}

func fetchItems(matching query: [String: String]) async throws -> [StoreItem] {
var urlComponents = URLComponents(string: "https://itunes.apple.com/search")!
urlComponents.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
let (data, response) = try await URLSession.shared.data(from: urlComponents.url!)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw StoreItemError.itemsNotFound
}

let decoder = JSONDecoder()
let searchResponse = try decoder.decode(SearchResponse.self, from: data)
return searchResponse.results
}


func fetchImage(from url: URL) async throws -> UIImage {
let (data, response) = try await URLSession.shared.data(from: url)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw StoreItemError.imageDataMissing
}
guard let image = UIImage(data: data) else {
throw StoreItemError.imageDataMissing
}

return image
}
}

GitHub API

Sean Allen 的 iOS Dev Job Interview Practice — Take Home Project

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com