處理 iOS 中復(fù)雜的 Table Views 并保持優(yōu)雅

Table views 是 iOS 開發(fā)中最重要的布局組件之一。通常我們的一些最重要的頁面都是 table views:feed 流,設(shè)置頁,條目列表等。

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

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

我們來看一下一個復(fù)雜的 UITableView 的例子。

image

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

這是 PokeBall,一個為 Pokémon 定制的社交網(wǎng)絡(luò)。像其它社交網(wǎng)絡(luò)一樣,它需要一個 feed 流來顯示跟用戶相關(guān)的不同事件。這些事件包括新的照片和狀態(tài)信息,按天進行分組。所以,現(xiàn)在我們有兩個需要擔(dān)心的問題:一是 table view 有不同的狀態(tài),二是多個 cell 和 section。

1. 讓 cell 處理一些邏輯

我見過很多開發(fā)者將 cell 的配置邏輯放到 cellForRowAt: 方法中。仔細思考一下,這個方法的目的是創(chuàng)建一個 cell。UITableViewDataSource 的目的是提供數(shù)據(jù)。數(shù)據(jù)源的作用不是用來設(shè)置按鈕字體的。

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
}

你應(yīng)該把配置和設(shè)置 cell 樣式的代碼放到 cell 中。如果是一些在 cell 的整個生命周期都存在的東西,例如一個 label 的字體,就應(yīng)該把它放在 awakeFromNib 方法中。

class StatusTableViewCell: UITableViewCell {

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

  override func awakeFromNib() {
    super.awakeFromNib()

    statusLabel.font = .boldSystemFont(ofSize: 16)
  }
}

另外你也可以給屬性添加觀察者來設(shè)置 cell 的數(shù)據(jù)。

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 的設(shè)置邏輯現(xiàn)在被放置在一個單獨的地方,而不是散落在 cell 和 view controller 中。

2. 讓 model 處理一些邏輯

通常,你會用從某個后臺服務(wù)中獲取的一組 model 對象來填充一個 table view。然后 cell 需要根據(jù) model 來顯示不同的內(nèi)容。

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)
    }
  }
}

你可以創(chuàng)建一個適配 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"
  }
}

現(xiàn)在你可以將大量的展示 cell 的邏輯移到 model 中。你可以獨立地實例化并單元測試你的 model 了,不需要在單元測試中做復(fù)雜的數(shù)據(jù)模擬和 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 的模式,只是應(yīng)用在一個單獨的 table view 的 cell 中。

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

Just a regular iOS developer making some table views

<figcaption>Just a regular iOS developer making some table views

分組的 table view 經(jīng)常亂成一團。你見過下面這種情況嗎?

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

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

記得矩陣么?搞機器學(xué)習(xí)的人以及一年級的計算機科學(xué)專業(yè)的學(xué)生會經(jīng)常用到它,但是應(yīng)用開發(fā)者通常不會用到。如果你考慮一個分組的 table view,其實你是在展示分組的列表。每個分組是一個 cell 的列表。聽起來像是一個數(shù)組的數(shù)組,或者說矩陣。

image

矩陣才是你組織分組 table view 的正確姿勢。用數(shù)組的數(shù)組來替代一維的數(shù)組。 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] = []

現(xiàn)在我們可以避免之前 switch 中使用的硬編碼索引了,我們定義一個分組的數(shù)組并直接返回它們的標題。

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

這樣在我們的數(shù)據(jù)源方法中代碼更少了,相應(yīng)地也減少了越界錯誤的風(fēng)險。代碼的表達力和可讀性也變得更好。

4. 枚舉是你的朋友

處理多種 cell 的類型有時候會很棘手。例如在某種 feed 流中,你不得不展示不同類型的 cell,像是圖片和狀態(tài)信息。為了保持代碼優(yōu)雅以及避免奇怪的數(shù)組索引計算,你應(yīng)該將各種類型的數(shù)據(jù)存儲到同一個數(shù)組中。

然而數(shù)組是同質(zhì)的,意味著你不能在同一個數(shù)組中存儲不同的類型。面對這個問題首先想到的解決方案是協(xié)議。畢竟 Swift 是面向協(xié)議的。

你可以定義一個 FeedItem 協(xié)議,并且讓我們的 cell 的 model 對象都遵守這個協(xié)議。

protocol FeedItem {}
struct Status: FeedItem { ... }
struct Photo: FeedItem { ... }

然后定義一個持有 FeedItem 類型對象的數(shù)組。

var cells: [FeedItem] = []

但是,用這個方案實現(xiàn) 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 對象遵守協(xié)議的同時,你丟失了大量你實際上需要的信息。你對 cell 進行了抽象,但是實際上你需要的是具體的實例。所以,你最終必須檢查是否可以將 model 對象轉(zhuǎn)換成某個類型,然后才能據(jù)此顯示 cell。

這樣也能達到目的,但是還不夠好。向下轉(zhuǎn)換對象類型內(nèi)在就是不安全的,而且會產(chǎn)生可選類型。你也無法得知是否覆蓋了所有的情況,因為有無限的類型可以遵守你的協(xié)議。所以你還需要調(diào)用 fatalError 方法來處理意外的類型。

當(dāng)你試圖把一個協(xié)議類型的實例轉(zhuǎn)化成具體的類型時,代碼的味道就不對了。使用協(xié)議是在你不需要具體的信息時,只要有原始數(shù)據(jù)的一個子集就能完成任務(wù)。

更好的實現(xiàn)是使用枚舉。那樣你可以用 switch 來處理它,而當(dāng)你沒有處理全部情況時代碼就無法編譯通過。

enum FeedItem {
  case status(Status)
  case photo(Photo)
}

枚舉也可以具有關(guān)聯(lián)的值,所以也可以在實際的值中放入需要的數(shù)據(jù)。

數(shù)組依然是那樣定義,但你的 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
  }
}

這樣你就沒有類型轉(zhuǎn)換,沒有可選類型,沒有未處理的情況,所以也不會有 bug。

5. 讓狀態(tài)變得明確

image

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

空白的頁面可能會使用戶困惑,所以我們一般在 table view 為空時在頁面上顯示一些消息。我們也會在加載數(shù)據(jù)時顯示一個加載標記。但是如果頁面出了問題,我們最好告訴用戶發(fā)生了什么,以便他們知道如何解決問題。

我們的 table view 通常擁有所有的這些狀態(tài),有時候還會更多。管理這些狀態(tài)就有些痛苦了。

我們假設(shè)你有兩種可能的狀態(tài):顯示數(shù)據(jù),或者一個提示用戶沒有數(shù)據(jù)的視圖。初級開發(fā)者可能會簡單的通過隱藏 table view,顯示無數(shù)據(jù)視圖來表明“無數(shù)據(jù)”的狀態(tài)。

noDataView.isHidden = false
tableView.isHidden = true

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

實際上,這兩個布爾值總是同步變化的。不能顯示著無數(shù)據(jù)視圖的時候,又在列表里顯示一些數(shù)據(jù)。

我們有必要思考一下實際中狀態(tài)的數(shù)值和應(yīng)用中可能出現(xiàn)的狀態(tài)數(shù)值有何不同。兩個布爾值有四種可能的組合。這表示你有兩種無效的狀態(tài),在某些情況下你可能會變成這些無效的狀態(tài)值,你必須處理這種意外情況。

你可以通過定義一個 State 枚舉來解決這個問題,枚舉中只列舉你的頁面可能出現(xiàn)的狀態(tài)。

enum State {
  case noData
  case loaded
}
var state: State = .noData

你也可以定義一個單獨的 state 屬性,來作為修改頁面狀態(tài)的唯一入口。每當(dāng)該屬性變化時,你就更新頁面到相應(yīng)的狀態(tài)。

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

如果你只通過這個屬性來修改狀態(tài),就能保證不會忘記修改某個布爾值屬性,也就不會使頁面處于無效的狀態(tài)中。現(xiàn)在改變頁面狀態(tài)就變得簡單了。

self.state = .noData

可能的狀態(tài)數(shù)量越多,這種模式就越有用。
你甚至可以通過關(guān)聯(lián)值將錯誤信息和列表數(shù)據(jù)都放置在枚舉中。

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
    }
  }
}

至此你定義了一個單獨的數(shù)據(jù)結(jié)構(gòu),它完全滿足了整個 table view controller 的數(shù)據(jù)需求。它 易于測試(因為它是一個純 Swift 值),為 table view 提供了一個唯一更新入口唯一數(shù)據(jù)源。歡迎來到易于調(diào)試的新世界!

幾點建議

還有幾點不值得單獨寫一節(jié)的小建議,但是它們依然很有用:

響應(yīng)式!

確保你的 table view 總是展示數(shù)據(jù)源的當(dāng)前狀態(tài)。使用一個屬性觀察者來刷新 table view,不要試圖手動控制刷新。

var cells: [Cell] = [] {
  didSet {
    tableView.reloadData()
  }
}

Delegate != View Controller

任何對象和結(jié)構(gòu)都可以實現(xiàn)某個協(xié)議!你下次寫一個復(fù)雜的 table view 的數(shù)據(jù)源或者代理時一定要記住這一點。有效而且更優(yōu)的做法是定義一個類型專門用作 table view 的數(shù)據(jù)源。這樣會使你的 view controller 保持整潔,把邏輯和責(zé)任分離到各自的對象中。

不要操作具體的索引值!

如果你發(fā)現(xiàn)自己在處理某個特定的索引值,在分組中使用 switch 語句以區(qū)別索引值,或者其它類似的邏輯,那么你很有可能做了錯誤的設(shè)計。如果你在特定的位置需要特定的 cell,你應(yīng)該在源數(shù)據(jù)的數(shù)組中體現(xiàn)出來。不要在代碼中手動地隱藏這些 cell。

牢記迪米特法則

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

換句話說,一個對象只應(yīng)訪問它自身的屬性。不應(yīng)該訪問其屬性的屬性。因此, UITableViewDataSource 不應(yīng)該設(shè)置 cell 的 label 的 text 屬性。如果你看見一個表達式中有兩個點(cell.label.text = ...),通常說明你的對象訪問的太深入了。

如果你不遵循迪米特法則,當(dāng)你修改 cell 的時候你也不得不同時修改數(shù)據(jù)源。將 cell 和數(shù)據(jù)源解耦使得你在修改其中一項時不會影響另一項。

小心錯誤的抽象

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

希望這些建議能幫助你,我確信你肯定會有下一次做 table view 的時候。這里還有一些擴展閱讀的資源可以給你更多的幫助:

作者:zhangqippp
鏈接:https://juejin.im/post/5a078ec3f265da431955c0d5
來源:掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 屬于你自己的待辦清單app(Your own to-do app) To-do list(待辦清單)app是App...
    Billionfan閱讀 3,525評論 11 10
  • 2017.02.22 可以練習(xí),每當(dāng)這個時候,腦袋就犯困,我這腦袋真是神奇呀,一說讓你做事情,你就犯困,你可不要太...
    Carden閱讀 1,494評論 0 1
  • *面試心聲:其實這些題本人都沒怎么背,但是在上海 兩周半 面了大約10家 收到差不多3個offer,總結(jié)起來就是把...
    Dove_iOS閱讀 27,655評論 30 472
  • [圖片]你是一個會做事的人嗎?或者說你有沒有過這樣的經(jīng)驗:在生活上或者工作中,想做或要做的事有很多,卻總是因為拖延...
    周助人閱讀 185評論 0 0
  • 時間:2017.9.28 題目鏈接:www.shiyanbar.com/ctf/1822 題目大意: aZZg/x...
    苗小張的時光片閱讀 675評論 0 0

友情鏈接更多精彩內(nèi)容