本文譯自 Enum-Driven TableView Development,原作者是 Keegan Rush
UITableView 是 iOS 開發裡最基本的東西,一個簡單而又整潔的控制元件。但 UITableView 的背後還隱藏了很多複雜性:在正確的時間顯示等待小菊花、處理 error、等待服務回撥並在得到結果的時候顯示結果。
在這篇教程裡,你會學習如何用列舉來驅動 TableView 的開發以便應對上述問題。
為了更好地學習這門技術,你需要重構一個現有的 app,這個 app 叫做 Chirper。在這個過程中,你會學習如下內容:
- 如何用列舉來管理
ViewController
的狀態。 - 在檢視中反映狀態的重要性。
- 狀態定義欠缺的危險性。
- 如何使用屬性觀察者來持續更新檢視。
- 如何利用分頁來模擬無限滑動的搜尋結果。
本教程需要你對
UITableView
和 Swift 列舉(enum)有所瞭解。否則可以先參考 iOS 和 Swift tutorials。
開始
需要我們重構的 Chirper app 顯示了一列鳥類叫聲,這個列表支援搜尋,其資料來自 xeno-canto public API。
如果在 app 內搜尋某種鳥類,它會為你顯示一列匹配搜尋關鍵詞的錄音。點選每行的按鈕就可以播放對應的錄音。
使用教程頂端或底端的材料下載 按鈕來下載初始專案。下載好之後,在 Xcode 裡開啟這個初始專案。
不同的狀態
一個設計良好的 table view 有四種不同狀態:
- Loading - 載入:app 正在獲取新資料。
- Error - 錯誤:服務呼叫或其他操作失敗了。
- Empty - 空白:服務呼叫沒有返回資料。
- Populated - 填充:app 已經取得了需要顯示的資料。
填充是最常見的狀態,但其他狀態也一樣關鍵。應該始終讓使用者瞭解 app 的狀態,也就是說在載入狀態的時候顯示等待小菊花、在沒有資料的時候提醒使用者該如何操作以及在出錯的時候顯示友好的錯誤訊息。
先開啟 MainViewController.swift 看一下程式碼。這個 view controller 基於屬性狀態做了一些很重要的事:
isLoading
設定為true
時顯示 loading indicator。error
不為nil
時告訴使用者出錯了。- 如果
recordings
陣列為nil
或空陣列,view 會顯示訊息提示使用者搜尋其他關鍵詞。 - 如果上述情況都不成立,view 就會顯示結果陣列。
- 根據當前狀態將
tableView.tableFooterView
設定為正確的 view。
在修改程式碼時不僅要將上面這些東西牢記於心,未來給 app 增加功能時這個模式還會變得更加複雜。
狀態定義欠缺
如果在 MainViewController.swift 裡搜尋,你會發現在這個檔案中並沒有出現 state。
state 就在那裡,但定義不明確。欠缺定義的狀態會讓人費解,這段程式碼是在做什麼?屬性變化之後應該如何迴應?
無效狀態
如果 isLoading
是 true
,app 就應該顯示載入狀態。如果 error
不是 nil,app 就應該顯示錯誤狀態。但如果這兩種情況同時存在呢?app 此時就會處於無效狀態。
MainViewController
的狀態定義不清晰,會導致其在無效或不確定狀態時出現 bug。
更好的方案
MainViewController
需要更好地管理狀態,管理狀態的方式應該是:
- 易於理解
- 易於維護
- 不受 bug 的影響
下面我們會重構 MainViewController
,用 enum
來管理狀態。
重構為列舉狀態
在 MainViewController.swift 裡,把下面這段程式碼新增到類宣告的上方:
enum State {
case loading
case populated([Recording])
case empty
case error(Error)
}
複製程式碼
這個列舉可以清晰地定義 view controller 的狀態。下面給 MainViewController
新增一個屬性來設定狀態:
var state = State.loading
複製程式碼
構建並執行 app,看看是否還能正常執行。由於我們還沒有對行為進行修改,所以應該還和原來一摸一樣。
重構載入狀態
我們要做的第一個改動就是移除 isLoading
屬性,變為使用狀態列舉。在 loadRecordings()
裡面,isLoading
屬性被設定為 true
,tableView.tableFooterView
被設定為 loading view。在 loadRecordings
的開頭移除這兩行:
isLoading = true
tableView.tableFooterView = loadingView
複製程式碼
將其替換為:
state = .loading
複製程式碼
然後,移除 fetchRecordings
回撥 block 中的 self.isLoading = false
。loadRecordings()
應該如下所示:
@objc func loadRecordings() {
state = .loading
recordings = []
tableView.reloadData()
let query = searchController.searchBar.text
networkingService.fetchRecordings(matching: query, page: 1) { [weak self] response in
guard let self = self else {
return
}
self.searchController.searchBar.endEditing(true)
self.update(response: response)
}
}
複製程式碼
現在可以移除 MainViewController 的 isLoading
屬性了,後面不會再用到。
構建並執行 app,應該如下所示:
雖然我們設定了 state
屬性,但還沒有用到它。tableView.tableFooterView
需要反映當前狀態。在 MainViewController
裡建立一個叫做setFooterView()
的新方法。
func setFooterView() {
switch state {
case .loading:
tableView.tableFooterView = loadingView
default:
break
}
}
複製程式碼
現在回到 loadRecordings()
,在設定 state 為 .loading
的後面新增如下程式碼:
setFooterView()
複製程式碼
構建並執行 app。
現在如果將 state 更改為 loading,serFooterView()
就會被呼叫,等待小菊花也會顯示出來。幹得不錯!
重構錯誤狀態
loadRecordings()
從 NetworkingService
抓取了 recordings。它會獲得 networkingService.fetchRecordings()
的 response 並且呼叫 update(response:)
來更新 app 狀態。
在 update(response:)
裡面,如果 response 有 error,就把 errorLabel
設定為 error 的 description。tableFooterView
也會被設定為 errorView
,其中包含了 errorLabel
。在 update(response:)
裡面找到下面兩行:
errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView
複製程式碼
替換為:
state = .error(error)
setFooterView()
複製程式碼
在 setFooterView()
裡,為 error
狀態新增一個新的 case:
case .error(let error):
errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView
複製程式碼
view controller 不再需要 error: Error?
屬性了,現在可以移除掉它。還需要在 update(response:)
裡面移除對 error
屬性的引用:
error = response.error
複製程式碼
移除掉上面那行之後,構建並執行 app。
可以看到,載入狀態仍然在正常顯示。但怎麼測試錯誤狀態呢?最簡單的方式是斷開裝置與網路的連線;如果你是在 Mac 上執行模擬器,就斷開 Mac 與網路的連線。再次嘗試載入資料,就會看到如下介面:
重構空白和填充狀態
在 update(response:)
的開頭有一長串 if-else
。我們需要把它整理一下,將 update(response:)
替換為如下內容:
func update(response: RecordingsResult) {
if let error = response.error {
state = .error(error)
setFooterView()
tableView.reloadData()
return
}
recordings = response.recordings
tableView.reloadData()
}
複製程式碼
雖然這樣破壞了填充和空白狀態,但不用擔心,我們馬上就會修復它們!
設定正確的狀態
在 if let error = response.error
block 的下方新增如下程式碼:
guard let newRecordings = response.recordings,
!newRecordings.isEmpty else {
state = .empty
setFooterView()
tableView.reloadData()
return
}
複製程式碼
在更新狀態時不要忘記呼叫 setFooterView()
和 tableView.reloadData()
,否則就看不到變動了。
下面找到 update(response:)
裡這行程式碼:
recordings = response.recordings
複製程式碼
將其替換為:
state = .populated(newRecordings)
setFooterView()
複製程式碼
這樣就重構了 update(response:)
——根據 view controller 的 state 屬性進行操作。
設定 Footer View
下面我們需要根據當前狀態設定正確的 table footer view。給 setFooterView()
中的 switch 新增如下兩個 case:
case .empty:
tableView.tableFooterView = emptyView
case .populated:
tableView.tableFooterView = nil
複製程式碼
現在不再需要 default
case 了,移除掉它。
構建並執行 app,看看有什麼變化:
從 State 中獲取資料
app 現在不顯示資料了。view controller 的 recordings
屬性用於填充 table view,但這個屬性並沒有被設定。現在 table view 需要從 state
屬性了獲取資料。在 State
enum 的宣告中新增下面這個計算屬性:
var currentRecordings: [Recording] {
switch self {
case .populated(let recordings):
return recordings
default:
return []
}
}
複製程式碼
可以使用這個屬性來填充 table view。如果 state 是 populated
,就使用 populated 的 recordings,否則就返回空陣列。
在 tableView(_:numberOfRowsInSection:)
,移除下面這行:
return recordings?.count ?? 0
複製程式碼
將其替換為:
return state.currentRecordings.count
複製程式碼
接下來,在 tableView(_:cellForRowAt:)
中移除這一塊程式碼:
if let recordings = recordings {
cell.load(recording: recordings[indexPath.row])
}
複製程式碼
將其替換為:
cell.load(recording: state.currentRecordings[indexPath.row])
複製程式碼
不需要再用可選型了!
MainViewController
的 recordings
屬性也用不到了,刪掉它以及 loadRecordings()
中對它的引用。
構建並執行 app。
所有狀態現在都應該可以正常執行了。我們移除了 isLoading
、error
、以及 recordings
屬性,替換為唯一的定義清晰的 state
屬性。幹得漂亮!
使用屬性觀察者保持同步
現在我們已經從 view controller 中移除了定義不明確的屬性,可以根據 state 屬性來輕易辨別檢視的行為。同時,也不會出現既是載入狀態又是錯誤狀態的情況了——也就意味著不會再出現無效狀態。
但是,仍然有一個問題存在。在更新 state 屬性的時候,必須要記得呼叫 setFooterView()
和 tableView.reloadData()
。否則 view 無法更新並反映當前狀態。如果在 state 被改變時可以自動重新整理多好?
這種情況非常適合使用屬性觀察者 didSet
。屬性觀察者用於響應屬性值的變更。如果每次 state
屬性被設定後都希望 reload table view 並設定 footer view,就需要新增一個 didSet
屬性觀察者。
把 var state = State.loading
替換為如下程式碼:
var state = State.loading {
didSet {
setFooterView()
tableView.reloadData()
}
}
複製程式碼
state
的值被改變後,didSet
屬性觀察期就會啟動。它會呼叫 setFooterView()
和 tableView.reloadData()
來更新 view。
移除 setFooterView()
和 tableView.reloadData()
的其他所有呼叫(各四個)。可以在 loadRecordings()
和 update(response:)
中找到它們。它們不會再被用到了。
構建並執行 app,檢視是否正常:
新增分頁
使用 app 進行搜尋時,雖然 API 會返回很多結果,但一次並不會返回全部結果。 例如,使用 Chirper 搜尋某種常見的鳥(比如 parrot),一般會返回很多結果:
但是不對啊,只有 50 條鸚鵡的記錄?
xeno-canto API 的限制是每次 500 條。這個專案在 NetworkingService.swift
中將其限制為 50 條,以便示範。
即便接收了前 500 條資料,後面的結果怎麼獲取呢?這個 API 通過分頁(pagination) 來實現這一點。
API 是如何支援分頁的
當我們在 NetworkingService
中呼叫 xeno-canto API 時,URL 如下所示:
http://www.xeno-canto.org/api/2/recordings?query=parrot
複製程式碼
如上呼叫返回的結果被限制為前 500 條資料,也被稱為第一頁,包含了 1-500 條資料。接下來的 500 條結果被稱作第二頁。利用 query 引數來指定想要的頁碼:
http://www.xeno-canto.org/api/2/recordings?query=parrot&page=2
複製程式碼
注意最後的 &page=2
;這一小段程式碼會告訴 API 我們想要第二頁(包含 501-1000 條資料)。
讓 Table View 支援分頁
看一下 MainViewController.loadRecordings()
,在它呼叫 networkingService.fetchRecordings()
時 page
引數被寫死為 1
。我們需要如下操作:
- 新增一個叫做
paging
的新狀態。 - 如果
networkingService.fetchRecordings
的 response 表示還有更多頁結果,就把 state 設定為.paging
。 - 在 table view 即將顯示最後一個 cell 時,如果 state 是
.paging
就載入下一頁結果。 - 把呼叫服務得到的新紀錄新增到 recordings 陣列。
使用者滑動到底部時,app 會抓取更多結果。這樣就給人一種無限滾動列表的感覺——就像社交軟體那樣。酷吧?
新增新的 Paging 狀態
先給 state enum 新增一個新 case:
case paging([Recording], next: Int)
複製程式碼
它需要追蹤用於顯示的 recordings 陣列,和 .populated
狀態一樣。還需要追蹤 API 需要抓取的下一頁頁碼。
嘗試構建並執行專案,你會發現編譯不通過。setFooterView
裡面的 switch 語句需要是詳盡的,也就是包含每一種 case,並且不包含 default
case。這樣的好處是確保新增新狀態時能即時更新 switch 語句。將如下程式碼新增到 switch 語句:
case .paging:
tableView.tableFooterView = loadingView
複製程式碼
如果 app 處於 paging 狀態,就會在 table view 的末尾顯示 loading indicator。
然而 state 的計算屬性 currentRecordings
還不夠詳盡,給其 currentRecordings 裡面的 switch 語句新增一個新 case:
case .paging(let recordings, _):
return recordings
複製程式碼
把狀態設定為 .paging
在 update(response:)
裡面,將 state = .populated(newRecordings
替換為如下程式碼:
if response.hasMorePages {
state = .paging(newRecordings, next: response.nextPage)
} else {
state = .populated(newRecordings)
}
複製程式碼
response.hasMorePages
會判斷 API 擁有的總頁數是否小於當前頁碼。如果還有頁面需要抓取,就將 state 設定為 .paging
。如果當前頁就是最後一頁或唯一一頁,就將 state 設定為 .populated
。
構建並執行 app:
如果搜尋結果有多頁,app 就會在底部顯示 loading indicator。但如果搜尋結果只有一頁,就會和之前一樣得到 .populated
狀態,沒有 loading indicator。
可以看到有多個待載入頁面時,app 並不會去載入它們。現在我們要修復這個問題。
載入下一頁
我們希望在使用者快要滑到列表底部時,app 能夠開始載入下一頁。首先,建立一個空方法,叫做 loadPage
:
func loadPage(_ page: Int) {
}
複製程式碼
後面如果希望從 NetworkingService
載入某特定頁的結果,就需要呼叫這個方法。
還記得 loadRecordings()
是如何預設載入第一頁的嗎?把 loadRecordings
裡面所有程式碼移動到 loadPage(_:)
中,除了第一行(把 state 設定為 .loading
)。
下面更新 fetchRecordings(matching: query, page: 1)
以便使用 page 引數,如下所示:
networkingService.fetchRecordings(matching: query, page: page)
複製程式碼
loadRecordings()
現在看起來少了點什麼,改一下讓它呼叫 loadPage(_:)
,將 page 指定為 1:
@objc func loadRecordings() {
state = .loading
loadPage(1)
}
複製程式碼
構建並執行 app:
如果什麼都沒有發生,那就對咯!
把下面程式碼新增到 tableView(_: cellForRowAt:)
裡 return
語句的前面。
if case .paging(_, let nextPage) = state,
indexPath.row == state.currentRecordings.count - 1 {
loadPage(nextPage)
}
複製程式碼
如果當前 state 是 .paging
,並且當前要顯示的行數和 currentRecordings
資料的最後一個結果索引相同,就載入下一頁。
構建並執行 app:
Exciting!loading indicator 進入 view 的時候,app 就會抓取下一頁資料。但目前並不是把資料附加上去——而是把當前記錄替換為新載入的記錄。
附加記錄
在 update(response:)
裡,newRecordings
陣列目前被用於 view 的新狀態。在 if response.hasMorePages
語句前面,新增如下程式碼:
var allRecordings = state.currentRecordings
allRecordings.append(contentsOf: newRecordings)
複製程式碼
獲取當前記錄陣列,然後把新紀錄附加到該陣列上。現在更新一下 if response.hasMorePages
語句,用 allRecordings
替代 newRecordings
:
if response.hasMorePages {
state = .paging(allRecordings, next: response.nextPage)
} else {
state = .populated(allRecordings)
}
複製程式碼
可見有了 state 列舉的幫助,修改變得輕鬆愜意。構建並執行 app 可以看到區別:
後續
如果希望下載最終完成的專案,使用教程頂部或底部的材料下載按鈕。
在這篇教程裡你重構了 app,用更加清晰的方式來處理複雜度。用簡單、清晰的 Swift 列舉替換了一大堆容易出錯、定義不明確的狀態。我們甚至新增了一個新功能來測試我們用列舉驅動的 table view:分頁。
在重構程式碼時記得要進行測試,以便確保沒有對某些功能造成破壞,最好是單元測試。如果想進一步學習,可以看看這篇教程 iOS Unit Testing and UI Testing。
現在你已經學習瞭如何在 app 中使用分頁 API,下面你還可以學習如何構建一個實際的 API。Server Side Swift with Vapor 視訊課程是一個不錯的起點。
喜歡這篇教程嗎?希望對你以後 app 的狀態管理有所幫助!如果有任何問題或想法,歡迎在下方留言。