如何用 Siesta 編寫 RESTful app
原文:How to make a RESTful app with Siesta
作者:Sanket Firodiya
譯者:kmyhy
通過網路獲取資料是移動應用程式中最常見的一種任務。因此,像 afnetwork 和 Alamofire 這樣的網路庫在iOS開發者中大受歡迎,也就不奇怪了。
即使是這樣,你仍然要在 app 中編寫和管理大量重複程式碼,以便從網路獲取和顯示資料。其中一些任務包括:
- 管理重複的請求。
- 當不再需要時取消請求,比如使用者離開頁面時。
- 在後臺執行緒中抓取和處理資料,在主執行緒中更新 UI。
- 將響應解析和轉換成模型。
- 顯示、隱藏載入進度。
- 收到資料時顯示資料。
Siesta 是一個網路庫,它自動完成這些任務並簡化抓取和顯示網路資料的程式碼。
Siesta 採用了以資源為中心而不是以請求為中心的策略,提供了一種在 app 範圍內的可觀察 RESTFul 資源狀態的模型。
注:本教程假設你懂得用基本的 URLSession 進行網路請求。 如果你不明白,請閱讀我們的 URLSession 教程:入門教程。
開始
在本教程中,你將編寫一個”披薩獵手” app,允許使用者搜尋附近的披薩店。
警告:當本教程結束,你可能會有點餓!
使用本教程頂部或頁尾的 Download Materials 按鈕下載開始專案。
開啟 PizzaHunter.xcworkspace 專案,Build & run。你會看到:
app 包含了兩個 View controller:
- RestaurantsListViewController: 顯示某個位置附近的披薩店清單。
- RestaurantDetailsViewController: 顯示某個披薩店的詳情。
因為檢視控制器還沒有和資料來源進行連線,app 現在顯示的是空白。
注:寫到這裡的時候 Siesta 當前版本是 1.3,它還沒有升級至 Swift 4.1。編譯專案時你會看到幾個 deprecation 警告。不用管它們,你的專案可以正常工作。
Yelp API
你將用 Yelp API 來搜尋某個城市中的披薩店。
這是獲取披薩店列表的 API:
GET https://api.yelp.com/v3/businesses/search
返回的資料是:
{
"businesses": [
{
"id": "tonys-pizza-napoletana-san-francisco",
"name": "Tony's Pizza Napoletana",
"image_url": "https://s3-media2.fl.yelpcdn.com/bphoto/d8tM3JkgYW0roXBygLoSKg/o.jpg",
"review_count": 3837,
"rating": 4,
...
},
{
"id": "golden-boy-pizza-san-francisco",
"name": "Golden Boy Pizza",
"image_url": "https://s3-media3.fl.yelpcdn.com/bphoto/FkqH-CWw5-PThWCF5NP2oQ/o.jpg",
"review_count": 2706,
"rating": 4.5,
...
}
]
}
使用 Siesta 發起網路請求
首先建立一個 YelpAPI 類。
選擇 File ▸ New ▸ File 選單,選擇 Swift file 然後點 Next。檔名為 YelpAPI.swift,然後 Create。編輯檔案內容為:
import Siesta
class YelpAPI {
}
這樣就匯入了 Siesta 並建立了一個空的 YelpAPI 類。
Siesta Service
現在來編寫發起 API 請求的程式碼。在 YelpAPI 類中新增:
static let sharedInstance = YelpAPI()
// 1
private let service = Service(baseURL: "https://api.yelp.com/v3", standardTransformers: [.text, .image, .json])
private init() {
// 2
LogCategory.enabled = [.network, .pipeline, .observers]
service.configure("**") {
// 3
$0.headers["Authorization"] =
"Bearer B6sOjKGis75zALWPa7d2dNiNzIefNbLGGoF75oANINOL80AUhB1DjzmaNzbpzF-b55X-nG2RUgSylwcr_UYZdAQNvimDsFqkkhmvzk6P8Qj0yXOQXmMWgTD_G7ksWnYx"
// 4
$0.expirationTime = 60 * 60 // 60s * 60m = 1 hour
}
}
上述程式碼分成了幾個步驟:
- 每個 API Service 都是一個 Siesta 中的 Service 類。因為披薩獵手只需要和唯一的 API——Yelp 打交道,所以你只需要一個 Service 類。
- 告訴 Siesta 你需要在控制檯中輸出的粒度。
- Yelp API 需要客戶端在每個 HTTP 請求頭中傳送 token 進行驗證。每個賬號的 token 都是唯一的。對於本教程,你可以用自己的 token 替代。
- 超時時間設定為 1 小時,因為店鋪資料的變化不是那麼經常。
然後,為 YelpAPI 建立一個工具方法,返回一個 Resource 物件:
func restaurantList(for location: String) -> Resource {
return service
.resource("/businesses/search")
.withParam("term", "pizza")
.withParam("location", location)
}
Resource 物件會根據指定的位置獲取一個披薩店的陣列,並將之轉換為對訂閱者有效的 any 物件。RestaurantListViewController 會用這個 Resource 在 UITableView 中顯示出披薩店列表。你現在就把它們拼接起來,就能看到 Siesta 的效果。
Resource 和 ResourceObserver
開啟 RestaurantListViewController.swift 匯入 Siesta:
import Siesta
然後在類中增加一個例項變數 restaurantListResource:
var restaurantListResource: Resource? {
didSet {
// 1
oldValue?.removeObservers(ownedBy: self)
// 2
restaurantListResource?
.addObserver(self)
// 3
.loadIfNeeded()
}
}
當對 restaurantListResource 屬性賦值時, 你做這些事情:
- 刪除已有的觀察者。
- 將 RestaurantListViewController 新增為觀察者。
- 告訴 Siesta ,是否要從 Resource 載入資料(根據快取超時時間)。
因為 RestaurantListViewController 被新增為觀察者,它必須實現 ResourceObserver 協議。新增如下擴充套件:
// MARK: - ResourceObserver
extension RestaurantListViewController: ResourceObserver {
func resourceChanged(_ resource: Resource, event: ResourceEvent) {
restaurants = resource.jsonDict["businesses"] as? [[String: Any]] ?? []
}
}
如何實現了 ResourceObserver 協議的物件都會收到 Resource 更新通知。
這些通知會呼叫 resourceChanged(_:event:), 引數是發生改變的 Resource 物件。你可以檢索 event 引數,進一步瞭解是什麼發生了改變。
現在可以呼叫 restaurantList(for:) 了。
當用戶從下拉框中選擇新的地點,RestaurantListViewController 的 currentLocation 會發生改變。
這時,你應該用新選擇的地址去重新整理 restaurantListResource。這需要修改當前的 currentLocation 定義:
var currentLocation: String! {
didSet {
restaurantListResource = YelpAPI.sharedInstance.restaurantList(for: currentLocation)
}
}
如果現在執行 app,Siesta 會在控制檯中列印如下訊息:
Siesta:network │ GET https://api.yelp.com/v3/businesses/search?location=Atlanta&term=pizza
Siesta:observers │ Resource(…/businesses/search?location=Atlanta&term=pizza)[L] sending requested event to 1 observer
Siesta:observers │ ↳ requested → <PizzaHunter.RestaurantListViewController: 0x7ff8bc4087f0>
Siesta:network │ Response: 200 ← GET https://api.yelp.com/v3/businesses/search?location=Atlanta&term=pizza
Siesta:pipeline │ [thread ᎰᏮᏫᎰ] ├╴Transformer ⟨*/json */*+json⟩ Data → JSONConvertible [transformErrors: true] matches content type "application/json"
Siesta:pipeline │ [thread ᎰᏮᏫᎰ] ├╴Applied transformer: Data → JSONConvertible [transformErrors: true]
Siesta:pipeline │ [thread ᎰᏮᏫᎰ] │ ↳ success: { businesses = ( { categories = ( { alias = pizza; title = Pizza; } ); coordinat…
Siesta:pipeline │ [thread ᎰᏮᏫᎰ] └╴Response after pipeline: success: { businesses = ( { categories = ( { alias = pizza; title = Pizza; } ); coordinat…
Siesta:observers │ Resource(…/businesses/search?location=Atlanta&term=pizza)[D] sending newData(network) event to 1 observer
Siesta:observers │ ↳ newData(network) → <PizzaHunter.RestaurantListViewController: 0x7ff8bc4087f0>
這些訊息可以讓你瞭解到 Siesta 正在做些什麼:
- 發起 GET 請求搜尋 Atlanta 的披薩店。
- 通知觀察者,也就是 RestaurantListViewController 關於這個請求。
- 返回 200 響應碼。
- 將原始資料轉換成 JSON。
- 傳送 JSON 給 RestaurantListViewController。
你可以在 RestaurantListViewController 的resourceChanged(_:event:) 方法中打個斷點,然後在控制檯中輸入命令:
po resource.jsonDict["businesses"]
以檢視 JSON 資料。你必須跳過當觀察者第一次被新增,但資料還沒有進來之前的那次 resourceChanged 呼叫。
要在 table view 中顯示披薩店列表,你必須在 restaurant 屬性被修改時重新整理 table view。在 RestaurantListViewController 修改 restaurants 屬性定義:
private var restaurants: [[String: Any]] = [] {
didSet {
tableView.reloadData()
}
}
Build & run,你會看到:
哇!你已經給自己找了一些美味的披薩了。:]
新增小菊花
當某個地方的披薩店列表正在載入時,還沒有向用戶顯示一個小菊花呢!
Siesta 使用了 ResourceStatusOverlay, 它是一個自帶的小菊花控制元件,當 app 正在載入網路資料時他自動顯示。
要使用 ResourceStatusOverlay, 首先在 RestaurantListViewController 中宣告一個它的變數:
private var statusOverlay = ResourceStatusOverlay()
現在在 viewDidLoad() 中,將它新增到檢視樹中:
statusOverlay.embed(in: self)
它必須在 view 每次佈局 subview 時正確佈局。要確保這一點,在 viewDidLoad() 方法下面新增這個方法:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
statusOverlay.positionToCoverParent()
}
最後,將它新增為 restaurantListResource 的觀察者,讓 Siesta 自動顯示和隱藏它。在 restaurantListResource 的 didSet 方法的 .addObserver(self) 和 .loadIfNeeded() 之間加入此句:
.addObserver(statusOverlay, owner: self)
Build & run ,看看小菊花的效果:
你可能注意到了,當你選到同一個城市時,第二次基本上是立即就顯示了結果。這是因為第一次載入是從 API 載入的。但 Siesta 會將響應進行快取,當後續請求到同一個城市時,響應是從記憶體緩衝中返回的:
Siesta 轉換器
對於任何生產性 app,最好將響應表示為定義良好的模型物件而不是非型別化的字典和陣列。Siesta 提供了輕鬆將原始 JSON 轉換為物件模型的鉤子。
Restaurant Model
披薩獵手儲存了每個披薩店的 id、name、url。現在,它是從 Yelp 返回的 JSON 中直接檢索資料。讓 Restaurant 實現 Codable 可以讓你自動實現一個清晰的、型別安全的 JSON 解碼。
開啟 Restaurant.swift 將結構體定義為:
struct Restaurant: Codable {
let id: String
let name: String
let imageUrl: String
enum CodingKeys: String, CodingKey {
case id
case name
case imageUrl = "image_url"
}
}
注:如果你不知道 Codable 和 CodingKey,請閱讀我們的 Swift 4 教程:編碼、解碼和序列化。
如果你回去看一眼你從 API 中返回的 JSON,披薩店列表是被包裹在一個 businesses 字典中的:
{
"businesses": [
{
"id": "tonys-pizza-napoletana-san-francisco",
"name": "Tony's Pizza Napoletana",
"image_url": "https://s3-media2.fl.yelpcdn.com/bphoto/d8tM3JkgYW0roXBygLoSKg/o.jpg",
"review_count": 3837,
"rating": 4,
...
},
你還需要一個結構體,將 API 的響應解包為一個 businesses 陣列。在 Restaurant.swift 中新增程式碼:
struct SearchResults<T: Decodable>: Decodable {
let businesses: [T]
}
模型對映
開啟 YelpAPI.swift 在 init() 方法中新增程式碼:
let jsonDecoder = JSONDecoder()
service.configureTransformer("/businesses/search") {
try jsonDecoder.decode(SearchResults<Restaurant>.self, from: $0.content).businesses
}
這個轉換器使用 API 端點 /business/search 的資源作為引數,將響應 JSON 傳遞給 SearchResults 的初始化方法。也就是說你可以建立一個資源,返回一個 Restaurant 物件的陣列。
另外一個不起眼但很重要的地方是從 Service 的標準 transformers 中去掉 .json。修改 service 的屬性定義:
private let service = Service(baseURL: "https://api.yelp.com/v3", standardTransformers: [.text, .image])
這會讓 Siesta 知道不要在 JSON 型別的響應中使用標準 transformer,而使用你提供的自定義的 transform。
RestaurantListViewController
現在修改 RestaurantListViewController 以便它能夠處理模型物件,而不是原始 JSON。
開啟 RestaurantListViewController.swift 修改 restaurants 的型別為 Restaurant 陣列:
private var restaurants: [Restaurant] = [] {
didSet {
tableView.reloadData()
}
}
修改 tableView(_:cellForRowAt:) 方法為使用 Restaurant 模型。將下面程式碼:
cell.nameLabel.text = restaurant["name"] as? String
cell.iconImageView.imageURL = restaurant["image_url"] as? String
替換為:
cell.nameLabel.text = restaurant.name
cell.iconImageView.imageURL = restaurant.imageUrl
最後,修改 resourceChanged(_:event:) 方法,從 resource 中抽取型別化的模型物件而不是 JSON 字典:
// MARK: - ResourceObserver
extension RestaurantListViewController: ResourceObserver {
func resourceChanged(_ resource: Resource, event: ResourceEvent) {
restaurants = resource.typedContent() ?? []
}
}
typedContent() 是一個便利方法,如果值不為空,返回這個 Resource 的最新結果的型別化的值,否則返回空。
Build & run,你會看到沒有任何改變。但是,因為使用了強型別,程式碼更健壯和安全了。
實現披薩店詳情
如果你到達這一步,那麼接下來的這部分就輕鬆了。你使用類似的步驟來抓取披薩店詳情,並使用 RestaurantDetailsViewController 進行顯示。
RestaurantDetails 模型
首先,需要讓 RestaurantDetails 和 Location 結構體實現 Codable,以便能夠使用強型別的模型。
開啟 RestaurantDetails.swift ,讓 RestaurantDetails 和 Location 實現 Codable :
struct RestaurantDetails: Codable {
struct Location: Codable {
然後,讓 RestaunantDetails 實現下列 CodingKey,就像我們在 Restaurant 中所做的一樣。在 RestaurantDetails 中新增下列程式碼:
enum CodingKeys: String, CodingKey {
case name
case imageUrl = "image_url"
case rating
case reviewCount = "review_count"
case price
case displayPhone = "display_phone"
case photos
case location
}
最後,為 Location 新增 CodingKey:
enum CodingKeys: String, CodingKey {
case displayAddress = "display_address"
}
模型對映
在 YelpAPI 的 init() 方法中,你可以重用之前建立並新增給 transformer 的 jsonDecoder,告訴 Siesta 將披薩店詳情 JSON 轉換成 RestaurantDetials。開啟 YelpAPI.swift 在之前的 service.configureTransformer 一句之上新增:
service.configureTransformer("/businesses/*") {
try jsonDecoder.decode(RestaurantDetails.self, from: $0.content)
}
另外在 YelpAPI 中加一個工具函式,建立一個用於查詢披薩店詳情的 Resource 物件:
func restaurantDetails(_ id: String) -> Resource {
return service
.resource("/businesses")
.child(id)
}
到目前為止還算順利。現在,準備進入試圖控制器,使用新模型。
在 RestaurantDetailsViewController 中設定 Siesta
RestaurantDetailsViewController 是使用者點選披薩店列表時顯示的 view controller。開啟 RestaurantDetailsViewController.swift 在 restaurantDetail 下面新增程式碼:
// 1
private var statusOverlay = ResourceStatusOverlay()
override func viewDidLoad() {
super.viewDidLoad()
// 2
YelpAPI.sharedInstance.restaurantDetails(restaurantId)
.addObserver(self)
.addObserver(statusOverlay, owner: self)
.loadIfNeeded()
// 3
statusOverlay.embed(in: self)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 4
statusOverlay.positionToCoverParent()
}
- 和之前一樣,當載入內容時,顯示一個 statusOverlay。
- 然後在 viewDidLoad 中用指定的 restaurantId 請求披薩店詳情。同時將 self 和 spinner 新增為觀察者,監聽網路請求狀態,以便網路響應返回時進行處理。
- 和之前一樣,在 view controller 中新增 spinner。
- 最後,如果佈局改變,將 spinner 放在正確的地方。
導航到 RestaurantDetialsViewController 頁面
你可能注意到了,app 還不能跳轉到披薩店詳情頁面。要解決這個問題,開啟 RestaurantListViewController.swift 找到如下擴充套件:
extension RestaurantListViewController: UITableViewDelegate {
在這個擴充套件中增加委託方法:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard indexPath.row <= restaurants.count else {
return
}
let detailsViewController = UIStoryboard(name: "Main", bundle: nil)
.instantiateViewController(withIdentifier: "RestaurantDetailsViewController")
as! RestaurantDetailsViewController
detailsViewController.restaurantId = restaurants[indexPath.row].id
navigationController?.pushViewController(detailsViewController, animated: true)
tableView.deselectRow(at: indexPath, animated: true)
}
這裡,你簡單構造了一個詳情頁面,將選擇的披薩店傳給它,然後將它 push 到導航棧中。
Build & run。選擇點選列表中的披薩店,搞定!
如果你返回,再次點選同一家披薩店,你會看到詳情頁面刷的一下就出來了。這是 Siesta 的本地快取的另外一個例子,提供了良好的使用者體驗:
這樣,你就用 Yelp API 和 Siesta 框架實現了一個披薩店搜尋 app。
接下來去哪裡?
通過底部的 Download Materials 按鈕下載完整的專案程式碼。
如果你需要閱讀 Siesta 文件,那麼它的 GitHub 頁是一個很好的資源。
要更進一步學習 Siesta,請參考下列資源:
- Security and authentication options in Siesta
- Transform pipeline for fine grain control of JSON to model transformation
- Siesta API Documentation
希望本文對你有所幫助。請在論壇中留言或提問。