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

zhangqippp發表於2017-11-11

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

Table views 是 iOS 開發中最重要的佈局元件之一。通常我們的一些最重要的頁面都是 table views:feed 流,設定頁,條目列表等。

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

我會給出一些我認為有益(至少在現在是有益)的原則,它們幫助我解決了很多問題。這些建議並不僅僅針對複雜的 table view,對你所有的 table view 來說它們都能適用。

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

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

這是 PokeBall,一個為 Pokémon 定製的社交網路。像其它社交網路一樣,它需要一個 feed 流來顯示跟使用者相關的不同事件。這些事件包括新的照片和狀態資訊,按天進行分組。所以,現在我們有兩個需要擔心的問題:一是 table view 有不同的狀態,二是多個 cell 和 section。

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 方法中。

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前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章