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 的例子。
這些很棒的截屏插圖來自 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. 使用矩陣(但是把它弄得漂亮點)
<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ù)組,或者說矩陣。
矩陣才是你組織分組 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)變得明確
這些很棒的截屏插圖來自 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)載請注明出處。