用枚舉來驅動 TableView 開發

本文譯自 Enum-Driven TableView Development,原做者是 Keegan Rushios


材料下載git

UITableView 是 iOS 開發裏最基本的東西,一個簡單而又整潔的控件。但 UITableView 的背後還隱藏了不少複雜性:在正確的時間顯示等待小菊花、處理 error、等待服務回調並在獲得結果的時候顯示結果。github

在這篇教程裏,你會學習如何用枚舉來驅動 TableView 的開發以便應對上述問題。swift

爲了更好地學習這門技術,你須要重構一個現有的 app,這個 app 叫作 Chirper。在這個過程當中,你會學習以下內容:api

  • 如何用枚舉來管理 ViewController 的狀態。
  • 在視圖中反映狀態的重要性。
  • 狀態定義欠缺的危險性。
  • 如何使用屬性觀察者來持續更新視圖。
  • 如何利用分頁來模擬無限滑動的搜索結果。

本教程須要你對 UITableView 和 Swift 枚舉(enum)有所瞭解。不然能夠先參考 iOSSwift tutorials數組

開始

須要咱們重構的 Chirper app 顯示了一列鳥類叫聲,這個列表支持搜索,其數據來自 xeno-canto public API網絡

若是在 app 內搜索某種鳥類,它會爲你顯示一列匹配搜索關鍵詞的錄音。點擊每行的按鈕就能夠播放對應的錄音。app

使用教程頂端或底端的材料下載 按鈕來下載初始項目。下載好以後,在 Xcode 裏打開這個初始項目。ide

不一樣的狀態

一個設計良好的 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 就在那裏,但定義不明確。欠缺定義的狀態會讓人費解,這段代碼是在作什麼?屬性變化以後應該如何迴應?

無效狀態

若是 isLoadingtrue,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 屬性被設置爲 truetableView.tableFooterView 被設置爲 loading view。在 loadRecordings 的開頭移除這兩行:

isLoading = true
tableView.tableFooterView = loadingView
複製代碼

將其替換爲:

state = .loading
複製代碼

而後,移除 fetchRecordings 回調 block 中的 self.isLoading = falseloadRecordings() 應該以下所示:

@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])
複製代碼

不須要再用可選型了!

MainViewControllerrecordings 屬性也用不到了,刪掉它以及 loadRecordings() 中對它的引用。

構建並運行 app。

全部狀態如今都應該能夠正常運行了。咱們移除了 isLoadingerror、以及 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。咱們須要以下操做:

  1. 添加一個叫作 paging 的新狀態。
  2. 若是 networkingService.fetchRecordings 的 response 表示還有更多頁結果,就把 state 設置爲 .paging
  3. 在 table view 即將顯示最後一個 cell 時,若是 state 是 .paging 就加載下一頁結果。
  4. 把調用服務獲得的新紀錄添加到 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 的狀態管理有所幫助!若是有任何問題或想法,歡迎在下方留言。

材料下載

相關文章
相關標籤/搜索