【譯】如何合理地處理複雜TableView頁面

敲鐘人Quasimodo發表於2019-01-18

原文連結:medium.cobeisfresh.com/dealing-wit… 求大佬們點個關注,會定期寫原創和翻譯國外最新文章,跟大佬們一起學習進步,有問題或者建議歡迎加微信ruiwendelll,拉大家進技術交流群,一起探討學習,謝謝了!

table view是iOS開發中最重要的佈局元件之一。通常我們最重要的一些頁面是表格檢視:Feed,設定,列表等。

每個寫過複雜table viewiOS開發人員都知道它可以非常快速地實現。它有大量的UITableViewDataSource方法和大量的if和switch語句。

我總結了一套原則,我暫時滿意,這有助於我克服這些問題。這些技巧的好處在於它們不僅適用於複雜的表檢視,而且也適用於所有表檢視。

下面是一個複雜table view的例子:

【譯】如何合理地處理複雜TableView頁面

這是PokeBall,Pokémon的社交網路。與所有社交網路一樣,它需要一個顯與使用者的不同動態的Feed。這些動態包括按天分組的新照片和狀態訊息。因此,我們有兩個點需要擔心:表檢視具有不同的狀態,以及多個cell和section。

Cell

我看到很多開發者將cell配置過程放在他們的cellForRowAt:方法中。w我們思考一下啊,該方法的目的是建立一個cell。 UITableViewDataSource的目的是提供資料。dataSource不應該為按鈕設定字型。

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的整個生命週期中都會出現,就像標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和檢視控制器之間。

Model

通常,你使用從某種後端介面獲得的模型物件陣列來填充table view。然後,cell需要根據該模型對自身進行更改。

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的標題,影象和其他屬性。

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程式碼非常簡單易讀。

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。

矩陣

分section的table view通常會早成程式碼很亂。你見過類似下面的程式碼嗎:

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

這是很多程式碼,並且很多硬編碼索引應該非常簡單,易於更改和交換。這個問題有一個簡單的解決方案:矩陣。

還記得矩陣嗎?這是機器學習相關開發者和一年級CS專業學生使用的東西,但應用程式開發人員通常不這樣做。然而,如果你想到一個分段的table view,你正在展示一個section列表。每個section都是一個cell列表。這聽起來像一個陣列或矩陣。

【譯】如何合理地處理複雜TableView頁面

這就是你應該對分段table view進行建模的方式。而不是一維陣列,使用二維陣列。這就是UITableViewDataSource方法的結構:你被要求返回第m個section的第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
}
複製程式碼

然後我們可以通過定義Section容器型別來擴充套件這個概念。此型別不僅會儲存某個section的cell,還會保留secton標題。

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

現在我們可以避免使用我們的硬編碼索引,而是可以定義一個section陣列並直接返回它們的標題。

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

這樣,我們的資料來源方法中的程式碼就越少,因此越界錯誤的可能性就越小。程式碼也變得更具表現力和可讀性。

列舉

使用多種cell型別可能非常棘手。考慮某種型別的feed,你必須顯現不同型別的cell,如照片和狀態。為了保持清楚並避免奇怪的陣列索引運算,你應該將它們儲存在同一個陣列中。

但是,陣列是同質的,這意味著您不能擁有不同型別的陣列。想到的第一個解決方案是協議。畢竟,Swift是面向協議的!

你可以定義協議FeedItem,並確保我們的cell的模型實現該協議。

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()
  }
}
複製程式碼

在將模型向上轉換為協議時,您丟失了許多實際需要的資訊。你已經抽出了cell,但實際上你需要具體的例項。因此,您最終必須檢查是否可以轉換為型別,然後根據該型別顯示cell。

這會有效,但它並不漂亮。向下傾斜本質上是不安全的,並導致optional。你也不知道是否已涵蓋所有 情況,因為無數種型別都可以實現你的協議。這就是為什麼你需要呼叫fatalError,為了防止你得到一個意外的型別。

當您嘗試將協議的例項強制轉換為具體型別時,通常會使程式碼出現問題。當你不需要特定資訊時,可以使用協議,但可以使用原始資料的子集代替。

更好的方法是使用列舉。這樣你可以開啟它,如果你沒有處理所有情況,程式碼將無法編譯。

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
  }
}
複製程式碼

這樣,你沒有強制轉換,沒有可選項和沒有未處理的情況,所以我們沒有錯誤。

使狀態清晰

【譯】如何合理地處理複雜TableView頁面
因為看到空白螢幕會讓人感到困惑,所以當table view為空時,我們通常會顯示某種訊息。我們還在資料載入時顯示一個載入動畫。但是,如果事情不對,那麼告訴使用者發生了什麼以便他們知道如何解決問題會很好。

我們的table view通常具有所有這些狀態等等。管理它們可能會很痛苦

假設您有兩種可能的狀態:顯示資料或無資料檢視。一個naive的開發人員會隱藏table View並展示無資料檢視就來表示“無資料”狀態。

noDataView.isHidden = false
tableView.isHidden = true
複製程式碼

在這種情況下更改狀態意味著您必須更改兩個bool屬性。在檢視控制器的另一部分中,您可能希望將狀態設定為其他部分,並且需要記住設定兩個屬性。

實際上,這兩個bool屬性應該始終保持同步。您不能擁有無資料檢視而同時以顯示一些資料。

考慮現實世界狀態數與應用中可能的狀態數之間的區別很有用。兩個布林值有四種可能的組合。這意味著你有兩個不想要的無效狀態,你需要處理這些狀態。

您可以通過定義一個包含螢幕可能處於的所有可能狀態的狀態列舉來解決此問題。

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

你還可以定義單個狀態屬性,這是更改螢幕狀態的唯一方法。每次更改屬性時,你都將更新螢幕以顯示該狀態。

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提供單點更新和單一事實來源。

建議

下面是幾個小貼士,非常有用:

reactive

確保table view始終展示資料來源源陣列的當前狀態。使用屬性觀察器重新整理table View,不要嘗試手動保持它們同步。

Delegate != ViewController

任何人都可以實現協議!請記住,下次編寫複雜的table view資料來源或委託時。定一個唯一目的是table view的資料來源的型別會更好 。這樣可以保持檢視控制器的乾淨,並將邏輯和職責分離到各自的物件中。

不要比較index

如果你發現你會確認某個indexPath是某個確切的index,通過switch語句到某個section,或者類似的操作。這是不對的。如果你有某個cell要放在確定的位置,在你的源陣列中展示它。不要在你的程式碼中隱藏這些cell。

記住法則

總而言之,唯一的法則是在程式設計中,朋友只和它的朋友交談,不要和朋友的朋友交談。

換句話說,一個物件應該只訪問它自己的屬性。那些屬性的屬性應該保持不變。所以,UITableViewDataSource不應該為cell的label設定text屬性。如果你在程式碼中看到兩個點(例如cell.label.text=...)那就是不對的。

如果你不按照這個原則來,更改cell意味著你也不得不更改資料來源。將cell和資料來源解耦可以讓你更改或者重構一個cell而不用影響其他。

錯誤的抽象

有時候,擁有多個類似的UITableViewCell類比使用一堆if語句的單個類更好。你不會知道他們以後會怎麼出現問題,將它們抽象是一個陷阱。

我希望這些技巧可以幫助你,我相信你下次寫table view相關程式碼會用到這些建議。

求大佬們點個關注,會定期寫原創和翻譯國外最新文章,跟大佬們一起學習進步,有問題或者建議歡迎加微信ruiwendelll,拉大家進技術交流群,一起探討學習,謝謝了!

相關文章