
源起
在 iOS 開(kāi)發(fā)中,UITableView 可以說(shuō)是最常用的控件。幾行代碼,實(shí)現(xiàn)對(duì)應(yīng)方法,系統(tǒng)就會(huì)給你呈現(xiàn)一個(gè) 60 幀無(wú)比流暢的列表,讓初學(xué)者成就感爆棚。然而隨著開(kāi)發(fā)的深入,我們就會(huì)慢慢覺(jué)察到當(dāng)前的 UITableView 實(shí)現(xiàn)會(huì)有這樣或那樣的問(wèn)題。
- 繁瑣的重用流程
幾乎所有 TableView Adapter 中都有如下的代碼 registerClass(Nib):forCellReuseIdentifier 進(jìn)行 cell 重用的注冊(cè),后續(xù)又需要使用 dequeueReusableCellWithIdentifier: 獲取對(duì)應(yīng) cell。蘋(píng)果的這套重用機(jī)制對(duì)于開(kāi)發(fā)者來(lái)說(shuō)相當(dāng)簡(jiǎn)單友好,但寫(xiě)多了難免覺(jué)得重復(fù)乏味。同時(shí)如何給 cell 設(shè)置一個(gè)有意義且不重復(fù)的 reuseIdentifier 又會(huì)成為眾多強(qiáng)迫癥程序員的煩惱之一。
- 不安全的 model 和 cell 映射關(guān)系
隨著業(yè)務(wù)深入,一個(gè) UITableView 往往會(huì)包含多種 model,對(duì)應(yīng)不同形式的 cell,那么建立 model 和 cell 的映射關(guān)系就會(huì)非常蛋疼,無(wú)論是if else,switch,還是 map<model,cell> 都不是那么的優(yōu)雅,每當(dāng) model 類(lèi)型有所增刪,開(kāi)發(fā)者往往需要心驚膽戰(zhàn)地檢查各處實(shí)現(xiàn)方法里是否進(jìn)行了正確的處理。
- 單調(diào)的優(yōu)化過(guò)程
業(yè)務(wù)繼續(xù)深入,為了保證相關(guān)代碼整潔,易于拓展和性能高效,除了維護(hù) model 和 cell 關(guān)系(ModelCellMap)外,我們往往需要引入各種類(lèi)做職責(zé)分離:DataSource 管理數(shù)據(jù)源,LayoutManager 負(fù)責(zé)排版和提供預(yù)計(jì)算高度能力,CellHeightCache 提供高度緩存,Interactor 提供事件路由和處理等等,這樣可以一定程度減輕代碼膨脹的問(wèn)題。但也不是完美的:套路都是類(lèi)似的,即使你熟練掌握了這些所謂的設(shè)計(jì)原則,在實(shí)際操作中仍有大量的重復(fù)代碼。
- 數(shù)據(jù)源和 UI 不綁定
當(dāng) model 變化時(shí),我們往往需要通過(guò)當(dāng)前 model 位置反推出 cell 在 UITableView 中的位置(即 indexPath),然后做相應(yīng)的更新處理,反之亦然。但這部分工作無(wú)非是數(shù)組遍歷,尋找 index,重復(fù)且繁瑣,稍有不慎還有出錯(cuò)導(dǎo)致崩潰的可能。
組件化方案
為了解決如上問(wèn)題,同時(shí)也受到 IGListKit 和 React.js 的啟發(fā),M80TableViewComponent 提出了一種組件化的解決方案,實(shí)現(xiàn)類(lèi)似 React.js 的 “單向數(shù)據(jù)綁定” 功能,同時(shí)將大量的重復(fù)計(jì)算歸納在組件內(nèi)部,上層使用者只需要根據(jù)當(dāng)前業(yè)務(wù)創(chuàng)建相應(yīng)組件并組合使用即可。
基礎(chǔ)組件
為了實(shí)現(xiàn)整個(gè) UITableView 的流程, M80TableViewComponent 引入三個(gè)基礎(chǔ)組件:
- M80TableViewComponent
- M80TableViewSectionComponent
- M80TableViewCellComponent
顧名思義,他們分別對(duì)應(yīng) UITableView,Section 和 UITableViewCell。用前端技術(shù)做類(lèi)比的話,M80TableViewComponent 就是我們定義的 VirtualDOM,而 UITableView 則是真正的 DOM。前者記錄虛擬的層次結(jié)構(gòu),后者仍負(fù)責(zé)最終的渲染。具體關(guān)系參考下圖:

簡(jiǎn)單使用
定義組件
一個(gè)簡(jiǎn)單的 M80TableViewComponent 定義如下

這是一個(gè)用于文本列表顯示的組件,只實(shí)現(xiàn)最基本組件協(xié)議
- 當(dāng)前組件對(duì)應(yīng)何種 UITableViewCell: - (Class)cellClass
- 當(dāng)前組件對(duì)應(yīng) UITableViewCell 高度是多少: - (CGFloat)height
- 如何通過(guò)當(dāng)前組件配置 UITableViewCell: - (void)configure:(UITableViewCell *)cell
和 UITableView 聯(lián)動(dòng)
定義完組件后,我們只需要按照順序?qū)⒔M件加入父組件中,即可完成和 UITableView 的綁定。

具體效果詳見(jiàn) Example Project
特性
看完上述的使用方式后,你很可能將 M80TableViewComponent 當(dāng)成一種固定數(shù)據(jù)源組裝方式而已,并沒(méi)有其他新意。但事實(shí)上,除了充當(dāng)固定結(jié)構(gòu)數(shù)據(jù)源外,它還有如下優(yōu)勢(shì)
單向綁定
當(dāng)我們使用組件時(shí),一旦當(dāng)前 M80TableViewComponent 和 UITableView 關(guān)聯(lián),后續(xù)針對(duì) M80TableViewComponent 的所有操作都會(huì)實(shí)時(shí)反應(yīng)到 UITableView 之上,包括對(duì) cell component 的移除,刷新,插入,以及 section component 的插入,移除和刷新。我們不再需要繁瑣地通過(guò) controller 同時(shí)操作 view 和 model 以保證其一致性,只需要單純操作 component 即可:component 將根據(jù)自身層次結(jié)構(gòu)計(jì)算出對(duì)應(yīng)的 UI 層次結(jié)構(gòu),在修改 component 內(nèi)部結(jié)構(gòu)的同時(shí)也會(huì)自動(dòng)獲取到對(duì)應(yīng)的 cell 對(duì)象進(jìn)行修改。這樣做的好處是上層開(kāi)發(fā)只需要關(guān)注 component 即可,而不再關(guān)心 indexPath 相關(guān)的計(jì)算過(guò)程,從而規(guī)避繁復(fù)的 indexPath 計(jì)算及計(jì)算錯(cuò)誤導(dǎo)致的崩潰。
靈活組裝功能
使用 M80TableViewComponent 可以輕易支持多種不同類(lèi)型的數(shù)據(jù)模型,同時(shí)由于我們將復(fù)用層次從 vc/tableview 下降到 cell/section component 層次,也更方便了在不同場(chǎng)景下的組合使用。
自動(dòng)重用
每一個(gè) M80TableViewCellComponent 在第一次被使用時(shí)都會(huì)通過(guò) M80TableViewComponentRegister 根據(jù)上下文信息自動(dòng)綁定 reuseIdentifier 和 cellClass 的關(guān)系,完成 cell 的重用。默認(rèn)使用當(dāng)前 cell component 的類(lèi)名作為 reuseIdentifier,既能保證不與其他 cell 重名,又省去了取名之苦。
高度優(yōu)化和局部刷新
在 iOS 中比較蛋疼的事情是如何判斷兩個(gè)對(duì)象相等:在不使用 runtime 的場(chǎng)景下,往往需要業(yè)務(wù)層添加大量冗余代碼用于支持對(duì)象比較,而使用了 runtime 又會(huì)對(duì)業(yè)務(wù)侵入過(guò)多。在 M80TableViewComponent 中我們使用了一種不基于 runtime 且比較輕量的方法:
所有的 M80TableViewCellComponent 都遵循 M80ListDiffable 協(xié)議,以用于組件內(nèi)部的一致性判斷:
- (NSString *)diffableHash;
默認(rèn)情況下,每個(gè) cell component 在初始化時(shí)都會(huì)有自己唯一的 cellIdentifier 作為 diffableHash。
以此為出發(fā)點(diǎn),我們就可以進(jìn)行如下場(chǎng)景的優(yōu)化。
- 自動(dòng) cell 高度緩存
- 通過(guò) ListDiff 算法實(shí)現(xiàn)的 section 局部刷新
當(dāng)開(kāi)啟高度緩存選項(xiàng)時(shí),M80TableViewComponent 計(jì)算 cell 高度后會(huì)自動(dòng)記錄 diffableHash 和 height 的對(duì)應(yīng)關(guān)系。后續(xù)再次刷新將自動(dòng)獲取對(duì)應(yīng)高度而無(wú)需再次計(jì)算。當(dāng)一個(gè) cell 有多重狀態(tài),需要在不同狀態(tài)下展示不同高度時(shí),則可以通過(guò)業(yè)務(wù)狀態(tài)返回不同的 diffableHash 進(jìn)行高度切換。除了高度緩存外,M80TableViewComponent 也提供了一種預(yù)計(jì)算高度的機(jī)制,在組裝完 cell component 后,只需要簡(jiǎn)單調(diào)用基類(lèi)方法 measure 就可以直接完成預(yù)計(jì)算。
而適用局部刷新時(shí),cell component 的 diffableHash 將做為唯一標(biāo)識(shí):old components 和 new components 根據(jù) diffableHash 被 hash 到不同桶內(nèi),沖突桶中的 component 標(biāo)記為 move,不沖突桶中的 component 則為 add/remove。詳細(xì)算法可參考 M80ListDiff 函數(shù)。在合適的場(chǎng)景下,使用 ListDiff 進(jìn)行 section 的重新載入,而不是人工計(jì)算各種變化信息后進(jìn)行逐一操作,能夠在保證性能的前提下,簡(jiǎn)化開(kāi)發(fā)過(guò)程和良好的界面表現(xiàn)。
使用貼士
不同于以往構(gòu)建 UITableView 的常見(jiàn)用法,使用 M80TableViewComponent 推薦所有操作都針對(duì) component 進(jìn)行。
- 涉及單個(gè) cell 的操作,直接使用 cell component 本身的方法,如 remove,reload 方法。
- 涉及單個(gè) section 內(nèi)多個(gè) cell 變化,可以考慮每次重新 setComponents 或調(diào)用 reloadUsingListDiff 進(jìn)行局部刷新。
- 涉及到多 section 多 cell 變化,則可以重新組裝所有 component。一方面這樣做比較簡(jiǎn)單,不容易出錯(cuò)。另一方面 component 只是 viewmodel,在真正刷新前的批量操作并不會(huì)有過(guò)多性能問(wèn)題。