【譯】處理 iOS 中複雜的 Table Views 並保持優雅

處理 iOS 中複雜的 Table Views 並保持優雅

Table views 是 iOS 開發中最重要的佈局組件之一。一般咱們的一些最重要的頁面都是 table views:feed 流,設置頁,條目列表等。前端

每一個開發複雜的 table view 的 iOS 開發者都知道這樣的 table view 會使代碼很快就變的很粗糙。這樣會產生包含大量 UITableViewDataSource 方法和大量 if 和 switch 語句的巨大的 view controller。加上數組索引計算和偶爾的越界錯誤,你會在這些代碼中遭受不少挫折。react

我會給出一些我認爲有益(至少在如今是有益)的原則,它們幫助我解決了不少問題。這些建議並不只僅針對複雜的 table view,對你全部的 table view 來講它們都能適用。android

咱們來看一下一個複雜的 UITableView 的例子。ios

這些很棒的截屏插圖來自 LazyAmphygit

這是 PokeBall,一個爲 Pokémon 定製的社交網絡。像其它社交網絡同樣,它須要一個 feed 流來顯示跟用戶相關的不一樣事件。這些事件包括新的照片和狀態信息,按天進行分組。因此,如今咱們有兩個須要擔憂的問題:一是 table view 有不一樣的狀態,二是多個 cell 和 section。github

1. 讓 cell 處理一些邏輯

我見過不少開發者將 cell 的配置邏輯放到 cellForRowAt: 方法中。仔細思考一下,這個方法的目的是建立一個 cell。UITableViewDataSource 的目的是提供數據。數據源的做用不是用來設置按鈕字體的。編程

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell

  let status = statuses[indexPath.row]
  cell.statusLabel.text = status.text
  cell.usernameLabel.text = status.user.name

  cell.statusLabel.font = .boldSystemFont(ofSize: 16)
  return cell
}複製代碼

你應該把配置和設置 cell 樣式的代碼放到 cell 中。若是是一些在 cell 的整個生命週期都存在的東西,例如一個 label 的字體,就應該把它放在 awakeFromNib 方法中。swift

class StatusTableViewCell: UITableViewCell {

  @IBOutlet weak var statusLabel: UILabel!
  @IBOutlet weak var usernameLabel: UILabel!

  override func awakeFromNib() {
    super.awakeFromNib()

    statusLabel.font = .boldSystemFont(ofSize: 16)
  }
}複製代碼

另外你也能夠給屬性添加觀察者來設置 cell 的數據。後端

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name
  }
}複製代碼

那樣的話你的 cellForRow 方法就變得簡潔易讀了。數組

func tableView(_ tableView: UITableView, 
  cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.row]
  return cell
}複製代碼

此外,cell 的設置邏輯如今被放置在一個單獨的地方,而不是散落在 cell 和 view controller 中。

2. 讓 model 處理一些邏輯

一般,你會用從某個後臺服務中獲取的一組 model 對象來填充一個 table view。而後 cell 須要根據 model 來顯示不一樣的內容。

var status: Status! {
  didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name

    if status.comments.isEmpty {
      commentIconImageView.image = UIImage(named: "no-comment")
    } else {
      commentIconImageView.image = UIImage(named: "comment-icon")
    }

    if status.isFavorite {
      favoriteButton.setTitle("Unfavorite", for: .normal)
    } else {
      favoriteButton.setTitle("Favorite", for: .normal)
    }
  }
}複製代碼

你能夠建立一個適配 cell 的對象,傳入上文提到的 model 對象來初始化它,在其中計算 cell 中須要的標題,圖片以及其它屬性。

class StatusCellModel {

  let commentIcon: UIImage
  let favoriteButtonTitle: String
  let statusText: String
  let usernameText: String

  init(_ status: Status) {
    statusText = status.text
    usernameText = status.user.name

    if status.comments.isEmpty {
      commentIcon = UIImage(named: "no-comments-icon")!
    } else {
      commentIcon = UIImage(named: "comments-icon")!
    }

    favoriteButtonTitle = status.isFavorite ? "Unfavorite" : "Favorite"
  }
}複製代碼

如今你能夠將大量的展現 cell 的邏輯移到 model 中。你能夠獨立地實例化並單元測試你的 model 了,不須要在單元測試中作複雜的數據模擬和 cell 獲取了。這也意味着你的 cell 會變得很是簡單易讀。

var model: StatusCellModel! {
  didSet {
    statusLabel.text = model.statusText
    usernameLabel.text = model.usernameText
    commentIconImageView.image = model.commentIcon
    favoriteButton.setTitle(model.favoriteButtonTitle, for: .normal)
  }
}複製代碼

這是一種相似於 MVVM 的模式,只是應用在一個單獨的 table view 的 cell 中。

3. 使用矩陣(可是把它弄得漂亮點)

Just a regular iOS developer making some table views
Just a regular iOS developer making some table views

分組的 table view 常常亂成一團。你見過下面這種狀況嗎?

func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  switch section {
  case 0: return "Today"
  case 1: return "Yesterday"
  default: return nil
  }
}複製代碼

這一大團代碼中,使用了大量的硬編碼的索引,而這些索引本應該是簡單而且易於改變和轉換的。對這個問題有一個簡單的解決方案:矩陣。

記得矩陣麼?搞機器學習的人以及一年級的計算機科學專業的學生會常常用到它,可是應用開發者一般不會用到。若是你考慮一個分組的 table view,其實你是在展現分組的列表。每一個分組是一個 cell 的列表。聽起來像是一個數組的數組,或者說矩陣。

矩陣纔是你組織分組 table view 的正確姿式。用數組的數組來替代一維的數組。 UITableViewDataSource 的方法也是這樣組織的:你被要求返回第 m 組的第 n 個 cell,而不是 table view 的第 n 個 cell。

var cells: [[Status]] = [[]]

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
  cell.status = statuses[indexPath.section][indexPath.row]
  return cell
}複製代碼

咱們能夠經過定義一個分組容器類型來擴展這個思路。這個類型不只持有一個特定分組的 cell,也持有像分組標題之類的信息。

struct Section {
  let title: String
  let cells: [Status]
}
var sections: [Section] = []複製代碼

如今咱們能夠避免以前 switch 中使用的硬編碼索引了,咱們定義一個分組的數組並直接返回它們的標題。

func tableView(_ tableView: UITableView, 
  titleForHeaderInSection section: Int) -> String? {
  return sections[section].title
}複製代碼

這樣在咱們的數據源方法中代碼更少了,相應地也減小了越界錯誤的風險。代碼的表達力和可讀性也變得更好。

4. 枚舉是你的朋友

處理多種 cell 的類型有時候會很棘手。例如在某種 feed 流中,你不得不展現不一樣類型的 cell,像是圖片和狀態信息。爲了保持代碼優雅以及避免奇怪的數組索引計算,你應該將各類類型的數據存儲到同一個數組中。

然而數組是同質的,意味着你不能在同一個數組中存儲不一樣的類型。面對這個問題首先想到的解決方案是協議。畢竟 Swift 是面向協議的。

你能夠定義一個 FeedItem 協議,而且讓咱們的 cell 的 model 對象都遵照這個協議。

protocol FeedItem {}
struct Status: FeedItem { ... }
struct Photo: FeedItem { ... }複製代碼

而後定義一個持有 FeedItem 類型對象的數組。

var cells: [FeedItem] = []複製代碼

可是,用這個方案實現 cellForRowAt: 方法時,會有一個小問題。

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  if let model = cellModel as? Status {
    let cell = ...
    return cell
  } else if let model = cellModel as? Photo {
    let cell = ...
    return cell
  } else {
    fatalError()
  }
}複製代碼

在讓 model 對象遵照協議的同時,你丟失了大量你實際上須要的信息。你對 cell 進行了抽象,可是實際上你須要的是具體的實例。因此,你最終必須檢查是否能夠將 model 對象轉換成某個類型,而後才能據此顯示 cell。

這樣也能達到目的,可是還不夠好。向下轉換對象類型內在就是不安全的,並且會產生可選類型。你也沒法得知是否覆蓋了全部的狀況,由於有無限的類型能夠遵照你的協議。因此你還須要調用 fatalError 方法來處理意外的類型。

當你試圖把一個協議類型的實例轉化成具體的類型時,代碼的味道就不對了。使用協議是在你不須要具體的信息時,只要有原始數據的一個子集就能完成任務。

更好的實現是使用枚舉。那樣你能夠用 switch 來處理它,而當你沒有處理所有狀況時代碼就沒法編譯經過。

enum FeedItem {
  case status(Status)
  case photo(Photo)
}複製代碼

枚舉也能夠具備關聯的值,因此也能夠在實際的值中放入須要的數據。

數組依然是那樣定義,但你的 cellForRowAt: 方法會變的清爽不少:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cellModel = cells[indexPath.row]

  switch cellModel {
  case .status(let status):
    let cell = ... 
    return cell
  case .photo(let photo):
    let cell = ...
    return cell
  }
}複製代碼

這樣你就沒有類型轉換,沒有可選類型,沒有未處理的狀況,因此也不會有 bug。

5. 讓狀態變得明確

這些很棒的截屏插圖來自 LazyAmphy

空白的頁面可能會使用戶困惑,因此咱們通常在 table view 爲空時在頁面上顯示一些消息。咱們也會在加載數據時顯示一個加載標記。可是若是頁面出了問題,咱們最好告訴用戶發生了什麼,以便他們知道如何解決問題。

咱們的 table view 一般擁有全部的這些狀態,有時候還會更多。管理這些狀態就有些痛苦了。

咱們假設你有兩種可能的狀態:顯示數據,或者一個提示用戶沒有數據的視圖。初級開發者可能會簡單的經過隱藏 table view,顯示無數據視圖來代表「無數據」的狀態。

noDataView.isHidden = false
tableView.isHidden = true複製代碼

在這種狀況下改變狀態意味着你要修改兩個布爾值屬性。在 view controller 的另外一部分中,你可能想修改這個狀態,你必須牢記你要同時修改這兩個屬性。

實際上,這兩個布爾值老是同步變化的。不能顯示着無數據視圖的時候,又在列表裏顯示一些數據。

咱們有必要思考一下實際中狀態的數值和應用中可能出現的狀態數值有何不一樣。兩個布爾值有四種可能的組合。這表示你有兩種無效的狀態,在某些狀況下你可能會變成這些無效的狀態值,你必須處理這種意外狀況。

你能夠經過定義一個 State 枚舉來解決這個問題,枚舉中只列舉你的頁面可能出現的狀態。

enum State {
  case noData
  case loaded
}
var state: State = .noData複製代碼

你也能夠定義一個單獨的 state 屬性,來做爲修改頁面狀態的惟一入口。每當該屬性變化時,你就更新頁面到相應的狀態。

var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
    case .loaded:
      noDataView.isHidden = false
      tableView.isHidden = true
    }
  }
}複製代碼

若是你只經過這個屬性來修改狀態,就能保證不會忘記修改某個布爾值屬性,也就不會使頁面處於無效的狀態中。如今改變頁面狀態就變得簡單了。

self.state = .noData複製代碼

可能的狀態數量越多,這種模式就越有用。
你甚至能夠經過關聯值將錯誤信息和列表數據都放置在枚舉中。

enum State {
  case noData
  case loaded([Cell])
  case error(String)
}
var state: State = .noData {
  didSet {
    switch state {
    case .noData:
      noDataView.isHidden = false
      tableView.isHidden = true
      errorView.isHidden = true
    case .loaded(let cells):
      self.cells = cells
      noDataView.isHidden = true
      tableView.isHidden = false
      errorView.isHidden = true
    case .error(let error):
      errorView.errorLabel.text = error      
      noDataView.isHidden = true
      tableView.isHidden = true
      errorView.isHidden = false
    }
  }
}複製代碼

至此你定義了一個單獨的數據結構,它徹底知足了整個 table view controller 的數據需求。它
易於測試
(由於它是一個純 Swift 值),爲 table view 提供了一個惟一更新入口惟一數據源。歡迎來到易於調試的新世界!

幾點建議

還有幾點不值得單獨寫一節的小建議,可是它們依然頗有用:

響應式!

確保你的 table view 老是展現數據源的當前狀態。使用一個屬性觀察者來刷新 table view,不要試圖手動控制刷新。

var cells: [Cell] = [] {
  didSet {
    tableView.reloadData()
  }
}複製代碼

Delegate != View Controller

任何對象和結構均可以實現某個協議!你下次寫一個複雜的 table view 的數據源或者代理時必定要記住這一點。有效並且更優的作法是定義一個類型專門用做 table view 的數據源。這樣會使你的 view controller 保持整潔,把邏輯和責任分離到各自的對象中。

不要操做具體的索引值!

若是你發現本身在處理某個特定的索引值,在分組中使用 switch 語句以區別索引值,或者其它相似的邏輯,那麼你頗有可能作了錯誤的設計。若是你在特定的位置須要特定的 cell,你應該在源數據的數組中體現出來。不要在代碼中手動地隱藏這些 cell。

牢記迪米特法則

簡而言之,迪米特法則(或者最少知識原則)指出,在程序設計中,實例應該只和它的朋友交談,而不能和朋友的朋友交談。等等,這是說的啥?

換句話說,一個對象只應訪問它自身的屬性。不該該訪問其屬性的屬性。所以, UITableViewDataSource 不該該設置 cell 的 label 的 text 屬性。若是你看見一個表達式中有兩個點(cell.label.text = ...),一般說明你的對象訪問的太深刻了。

若是你不遵循迪米特法則,當你修改 cell 的時候你也不得不一樣時修改數據源。將 cell 和數據源解耦使得你在修改其中一項時不會影響另外一項。

當心錯誤的抽象

有時候,多個相近的 UITableViewCell 類 會比一個包含大量 if 語句的 cell 類要好得多。你不知道將來它們會如何分歧,抽象它們可能會是設計上的陷阱。YAGNI(你不會須要它)是個好的原則,但有時候你會實現成 YJMNI(你只是可能須要它)。

但願這些建議能幫助你,我確信你確定會有下一次作 table view 的時候。這裏還有一些擴展閱讀的資源能夠給你更多的幫助:

若是你有任何問題或建議,歡迎在下方留言。

Marin 是 COBE 的一名 iOS 開發人員,一名博主和一名計算機科學學生。他喜歡編程,學習東西,而後寫下它們,還喜歡騎自行車和喝咖啡。大多數狀況下,他只會把 SourceKit 搞崩潰。他有一隻叫 Amigo 的胖貓。他基本上不是靠本身寫完的這篇文章。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索