我是前言
這篇文章是我和我們團(tuán)隊(duì)最近對(duì)UITableViewCell利用AutoLayout自動(dòng)高度計(jì)算和UITableView滑動(dòng)優(yōu)化的一個(gè)總結(jié)。
我們也在維護(hù)一個(gè)開(kāi)源的擴(kuò)展递胧,UITableView+FDTemplateLayoutCell,讓高度計(jì)算這個(gè)事情變的前所未有的簡(jiǎn)單叁怪,也受到了很多星星的支持,github鏈接請(qǐng)戳我
這篇總結(jié)你可以讀到:
UITableView高度計(jì)算和估算的機(jī)制
不同iOS系統(tǒng)在高度計(jì)算上的差異
iOS8 self-sizing cell
UITableView+FDTemplateLayoutCell如何用一句話解決高度問(wèn)題
UITableView+FDTemplateLayoutCell中對(duì)RunLoop的使用技巧
UITableViewCell高度計(jì)算
rowHeight
UITableView是我們?cè)偈煜げ贿^(guò)的視圖了深滚,它的delegate和data source回調(diào)不知寫(xiě)了多少次奕谭,也不免遇到 UITableViewCell 高度計(jì)算的事。UITableView 詢問(wèn) cell 高度有兩種方式痴荐。
一種是針對(duì)所有 Cell 具有固定高度的情況血柳,通過(guò):
self.tableView.rowHeight =88;
上面的代碼指定了一個(gè)所有 cell 都是 88 高度的 UITableView,對(duì)于定高需求的表格生兆,強(qiáng)烈建議使用這種(而非下面的)方式保證不必要的高度計(jì)算和調(diào)用难捌。rowHeight屬性的默認(rèn)值是 44,所以一個(gè)空的 UITableView 顯示成那個(gè)樣子皂贩。
另一種方式就是實(shí)現(xiàn) UITableViewDelegate 中的:
-(CGFloat)tableView:(UITableView *)tableViewheightForRowAtIndexPath:(NSIndexPath *)indexPath{
// returnxxx
}
需要注意的是栖榨,實(shí)現(xiàn)了這個(gè)方法后昆汹,rowHeight的設(shè)置將無(wú)效明刷。所以,這個(gè)方法適用于具有多種 cell 高度的 UITableView满粗。
estimatedRowHeight
這個(gè)屬性 iOS7 就出現(xiàn)了辈末, 文檔是這么描述它的作用的:
If the table contains variable height rows, it might be expensive to calculate all their heights when the table loads. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
恩,聽(tīng)上去蠻靠譜的映皆。我們知道挤聘,UITableView 是個(gè) UIScrollView,就像平時(shí)使用 UIScrollView 一樣捅彻,加載時(shí)指定contentSize后它才能根據(jù)自己的 bounds组去、contentInset、contentOffset 等屬性共同決定是否可以滑動(dòng)以及滾動(dòng)條的長(zhǎng)度步淹。而 UITableView 在一開(kāi)始并不知道自己會(huì)被填充多少內(nèi)容从隆,于是詢問(wèn) data source 個(gè)數(shù)和創(chuàng)建 cell诚撵,同時(shí)詢問(wèn) delegate 這些 cell 應(yīng)該顯示的高度,這就造成它在加載的時(shí)候浪費(fèi)了多余的計(jì)算在屏幕外邊的 cell 上键闺。和上面的rowHeight很類(lèi)似寿烟,設(shè)置這個(gè)估算高度有兩種方法:
self.tableView.estimatedRowHeight=88;
// or
- (CGFloat)tableView:(UITableView*)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath*)indexPath {
// returnxxx
}
有所不同的是,即使面對(duì)種類(lèi)不同的 cell辛燥,我們依然可以使用簡(jiǎn)單的estimatedRowHeight屬性賦值筛武,只要整體估算值接近就可以,比如大概有一半 cell 高度是 44挎塌, 一半 cell 高度是 88徘六, 那就可以估算一個(gè) 66,基本符合預(yù)期勃蜘。
說(shuō)完了估算高度的基本使用硕噩,可以開(kāi)始吐槽了:
設(shè)置估算高度后,contentSize.height 根據(jù)“cell估算值 x cell個(gè)數(shù)”計(jì)算缭贡,這就導(dǎo)致滾動(dòng)條的大小處于不穩(wěn)定的狀態(tài)炉擅,contentSize 會(huì)隨著滾動(dòng)從估算高度慢慢替換成真實(shí)高度,肉眼可見(jiàn)滾動(dòng)條突然變化甚至“跳躍”阳惹。
若是有設(shè)計(jì)不好的下拉刷新或上拉加載控件谍失,或是 KVO 了 contentSize 或 contentOffset 屬性,有可能使表格滑動(dòng)時(shí)跳動(dòng)莹汤。
估算高度設(shè)計(jì)初衷是好的快鱼,讓加載速度更快,那憑啥要去侵害滑動(dòng)的流暢性呢纲岭,用戶可能對(duì)進(jìn)入頁(yè)面時(shí)多零點(diǎn)幾秒加載時(shí)間感覺(jué)不大抹竹,但是滑動(dòng)時(shí)實(shí)時(shí)計(jì)算高度帶來(lái)的卡頓是明顯能體驗(yàn)到的,個(gè)人覺(jué)得還不如一開(kāi)始都算好了呢(iOS8更過(guò)分止潮,即使都算好了也會(huì)邊劃邊計(jì)算)
iOS8 self-sizing cell
具有動(dòng)態(tài)高度內(nèi)容的 cell 一直是個(gè)頭疼的問(wèn)題窃判,比如聊天氣泡的 cell, frame 布局時(shí)代通常是用數(shù)據(jù)內(nèi)容反算高度:
CGFloatheight = textHeightWithFont() + imageHeight + topMargin +bottomMargin+ ...;
供 UITableViewDelegate 調(diào)用時(shí)很可能是個(gè) cell 的類(lèi)方法:
@interfaceBubbleCell: UITableViewCell
+ (CGFloat)heightWithEntity:(id)entity;
@end
各種魔法 margin 加上耦合了屏幕寬度喇闸。
AutoLayout 時(shí)代好了不少袄琳,提供了-systemLayoutSizeFittingSize:的 API,在 contentView 中設(shè)置約束后燃乍,就能計(jì)算出準(zhǔn)確的值唆樊;缺點(diǎn)是計(jì)算速度肯定沒(méi)有手算快,而且這是個(gè)實(shí)例方法刻蟹,需要維護(hù)專(zhuān)門(mén)為計(jì)算高度而生的template layout cell逗旁,它還要求使用者對(duì)約束設(shè)置的比較熟練,要保證 contentView 內(nèi)部上下左右所有方向都有約束支撐舆瘪,設(shè)置不合理的話計(jì)算的高度就成了0片效。
這里還不得不提到一個(gè) UILabel 的蛋疼問(wèn)題仓洼,當(dāng) UILabel 行數(shù)大于0時(shí),需要指定preferredMaxLayoutWidth后它才知道自己什么時(shí)候該折行堤舒。這是個(gè)“雞生蛋蛋生雞”的問(wèn)題色建,因?yàn)?UILabel 需要知道 superview 的寬度才能折行,而 superview 的寬度還依仗著子 view 寬度的累加才能確定舌缤。這個(gè)問(wèn)題好像到 iOS8 才能夠自動(dòng)解決(不過(guò)我們找到了解決方案)
回到正題箕戳,iOS8 WWDC 中推出了self-sizing cell的概念,旨在讓 cell 自己負(fù)責(zé)自己的高度計(jì)算国撵,使用 frame layout 和 auto layout 都可以享受到:
這個(gè)特性首先要求是 iOS8陵吸,要是最低支持的系統(tǒng)版本小于8的話,還得針對(duì)老版本單寫(xiě)套老式的算高(囧)介牙,不過(guò)用的 API 到不是新面孔:
self.tableView.estimatedRowHeight =213;
self.tableView.rowHeight = UITableViewAutomaticDimension;
這里又不得不吐槽了壮虫,自動(dòng)計(jì)算 rowHeight 跟 estimatedRowHeight 到底是有什么仇,如果不加上估算高度的設(shè)置环础,自動(dòng)算高就失效了- -
PS:iOS8 系統(tǒng)中 rowHeight 的默認(rèn)值已經(jīng)設(shè)置成了 UITableViewAutomaticDimension囚似,所以第二行代碼可以省略。
問(wèn)題:
這個(gè)自動(dòng)算高在 push 到下一個(gè)頁(yè)面或者轉(zhuǎn)屏?xí)r會(huì)出現(xiàn)高度特別詭異的情況线得,不過(guò)現(xiàn)在的版本修復(fù)了饶唤。
求一個(gè)能讓最低支持 iOS8 的公司- -
iOS8抽風(fēng)的算高機(jī)制
相同的代碼在 iOS7 和 iOS8 上滑動(dòng)順暢程度完全不同,iOS8 莫名奇妙的卡贯钩。很大一部分原因是 iOS8 上的算高機(jī)制大不相同募狂,這是我做的小測(cè)試:
研究后發(fā)現(xiàn)這么多次額外計(jì)算有下面的原因:
不開(kāi)啟高度估算時(shí),UITableView 上來(lái)就要對(duì)所有 cell 調(diào)用算高來(lái)確定 contentSize
dequeueReusableCellWithIdentifier:forIndexPath:相比不帶 “forIndexPath” 的版本會(huì)多調(diào)用一次高度計(jì)算
iOS7 計(jì)算高度后有”緩存“機(jī)制角雷,不會(huì)重復(fù)計(jì)算祸穷;而 iOS8 不論何時(shí)都會(huì)重新計(jì)算 cell 高度
iOS8 把高度計(jì)算搞成這個(gè)樣子,從 WWDC 也倒是能找到點(diǎn)解釋?zhuān)琧ell 被認(rèn)為隨時(shí)都可能改變高度(如從設(shè)置中調(diào)整動(dòng)態(tài)字體大猩兹)雷滚,所以每次滑動(dòng)出來(lái)后都要重新計(jì)算高度。
說(shuō)了這么多檩咱,究竟有沒(méi)有既能省去算高煩惱揭措,又能保證順暢的滑動(dòng)胯舷,還能支持 iOS6+ 的一站式解決方案呢刻蚯?
UITableView+FDTemplateLayoutCell
使用UITableView+FDTemplateLayoutCell無(wú)疑是解決算高問(wèn)題的最佳實(shí)踐之一,既有 iOS8 self-sizing 功能簡(jiǎn)單的 API桑嘶,又可以達(dá)到 iOS7 流暢的滑動(dòng)效果炊汹,還保持了最低支持 iOS6。
使用起來(lái)大概是這樣:
#import
- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {
return[tableView fd_heightForCellWithIdentifier:@"identifer"cacheByIndexPath:indexPath configuration:^(idcell) {
// 配置 cell 的數(shù)據(jù)源逃顶,和 "cellForRow" 干的事一致讨便,比如:
cell.entity=self.feedEntities[indexPath.row];
}];
}
寫(xiě)完上面的代碼后充甚,你就已經(jīng)使用到了:
和每個(gè) UITableViewCell ReuseID 一一對(duì)應(yīng)的 template layout cell
這個(gè) cell 只為了參加高度計(jì)算,不會(huì)真的顯示到屏幕上霸褒;它通過(guò) UITableView 的-dequeueCellForReuseIdentifier:方法 lazy 創(chuàng)建并保存伴找,所以要求這個(gè) ReuseID 必須已經(jīng)被注冊(cè)到了 UITableView 中,也就是說(shuō)废菱,要么是 Storyboard 中的原型 cell技矮,要么就是使用了 UITableView 的-registerClass:forCellReuseIdentifier:或-registerNib:forCellReuseIdentifier:其中之一的注冊(cè)方法。
根據(jù) autolayout 約束自動(dòng)計(jì)算高度
使用了系統(tǒng)在 iOS6 就提供的 API:-systemLayoutSizeFittingSize:
根據(jù) index path 的一套高度緩存機(jī)制
計(jì)算出的高度會(huì)自動(dòng)進(jìn)行緩存殊轴,所以滑動(dòng)時(shí)每個(gè) cell 真正的高度計(jì)算只會(huì)發(fā)生一次衰倦,后面的高度詢問(wèn)都會(huì)命中緩存,減少了非撑岳恚可觀的多余計(jì)算樊零。
自動(dòng)的緩存失效機(jī)制
無(wú)須擔(dān)心你數(shù)據(jù)源的變化引起的緩存失效,當(dāng)調(diào)用如-reloadData孽文,-deleteRowsAtIndexPaths:withRowAnimation:等任何一個(gè)觸發(fā) UITableView 刷新機(jī)制的方法時(shí)驻襟,已有的高度緩存將以最小的代價(jià)執(zhí)行失效。如刪除一個(gè) indexPath 為 [0:5] 的 cell 時(shí)芋哭,[0:0] ~ [0:4] 的高度緩存不受影響塑悼,而 [0:5] 后面所有的緩存值都向前移動(dòng)一個(gè)位置。自動(dòng)緩存失效機(jī)制對(duì) UITableView 的 9 個(gè)公有 API 都進(jìn)行了分別的處理楷掉,以保證沒(méi)有一次多余的高度計(jì)算厢蒜。
預(yù)緩存機(jī)制
預(yù)緩存機(jī)制將在 UITableView 沒(méi)有滑動(dòng)的空閑時(shí)刻執(zhí)行,計(jì)算和緩存那些還沒(méi)有顯示到屏幕中的 cell烹植,整個(gè)緩存過(guò)程完全沒(méi)有感知斑鸦,這使得完整列表的高度計(jì)算既沒(méi)有發(fā)生在加載時(shí),又沒(méi)有發(fā)生在滑動(dòng)時(shí)草雕,同時(shí)保證了加載速度和滑動(dòng)流暢性巷屿,下文會(huì)著重講下這塊的實(shí)現(xiàn)原理。
我們?cè)谠O(shè)計(jì)這個(gè)工具的 API 時(shí)斟酌了非常長(zhǎng)的時(shí)間墩虹,既要保證功能的強(qiáng)大嘱巾,也要保證接口的精簡(jiǎn),一行調(diào)用背后隱藏著很多功能诫钓。
這一套緩存機(jī)制能對(duì)滑動(dòng)起多大影響呢旬昭?除了肉眼能明顯的感知到外,我還做了個(gè)小測(cè)試:
一個(gè)有 54 個(gè)內(nèi)容和高度不同 cell 的 table view菌湃,從頭滑動(dòng)到尾问拘,再?gòu)奈不瑒?dòng)到頭,iOS8 系統(tǒng)下,iPhone6骤坐,使用Time Profiler監(jiān)測(cè)算高函數(shù)所花費(fèi)的時(shí)間:
未使用緩存API绪杏、未使用估算,共花費(fèi) 877 ms:
使用緩存API纽绍、開(kāi)啟估算蕾久,共花費(fèi) 77 ms:
測(cè)試數(shù)據(jù)的精度先不管,從量級(jí)上就差了一個(gè)數(shù)量級(jí)拌夏,說(shuō)實(shí)話自己也沒(méi)想到差距有這么大- -
同時(shí)腔彰,工具也順手解決了-preferredMaxLayoutWidth的問(wèn)題,在計(jì)算高度前向 contentView 加了一條和 table view 寬度相同的寬度約束辖佣,強(qiáng)行讓 contentView 內(nèi)部的控件知道了自己父 view 的寬度霹抛,再反算自己被外界約束的寬度,破除“雞生蛋蛋生雞”的問(wèn)題卷谈,這里比較 tricky杯拐,就不展開(kāi)說(shuō)了。下面說(shuō)說(shuō)利用 RunLoop 預(yù)緩存的實(shí)現(xiàn)世蔗。
利用RunLoop空閑時(shí)間執(zhí)行預(yù)緩存任務(wù)
FDTemplateLayoutCell 的高度預(yù)緩存是一個(gè)優(yōu)化功能端逼,它要求頁(yè)面處于空閑狀態(tài)時(shí)才執(zhí)行計(jì)算,當(dāng)用戶正在滑動(dòng)列表時(shí)顯然不應(yīng)該執(zhí)行計(jì)算任務(wù)影響滑動(dòng)體驗(yàn)污淋。
一般來(lái)說(shuō)顶滩,這個(gè)功能要耦合 UITableView 的滑動(dòng)狀態(tài)才行,但這種實(shí)現(xiàn)十分不優(yōu)雅且可能破壞外部的 delegate 結(jié)構(gòu)寸爆,但好在我們還有RunLoop這個(gè)工具礁鲁,了解它的運(yùn)行機(jī)制后,可以用很簡(jiǎn)單的代碼實(shí)現(xiàn)上面的功能赁豆。
空閑RunLoopMode
在曾經(jīng)的 RunLoop 線下分享會(huì)(視頻可戳)中介紹了 RunLoopMode 的概念仅醇。
當(dāng)用戶正在滑動(dòng) UIScrollView 時(shí),RunLoop 將切換到UITrackingRunLoopMode接受滑動(dòng)手勢(shì)和處理滑動(dòng)事件(包括減速和彈簧效果)魔种,此時(shí)析二,其他 Mode (除 NSRunLoopCommonModes 這個(gè)組合 Mode)下的事件將全部暫停執(zhí)行,來(lái)保證滑動(dòng)事件的優(yōu)先處理节预,這也是 iOS 滑動(dòng)順暢的重要原因叶摄。
當(dāng) UI 沒(méi)在滑動(dòng)時(shí),默認(rèn)的 Mode 是NSDefaultRunLoopMode(同 CF 中的 kCFRunLoopDefaultMode)安拟,同時(shí)也是 CF 中定義的 “空閑狀態(tài) Mode”蛤吓。當(dāng)用戶啥也不點(diǎn),此時(shí)也沒(méi)有什么網(wǎng)絡(luò) IO 時(shí)去扣,就是在這個(gè) Mode 下柱衔。
用RunLoopObserver找準(zhǔn)時(shí)機(jī)
注冊(cè) RunLoopObserver 可以觀測(cè)當(dāng)前 RunLoop 的運(yùn)行狀態(tài),并在狀態(tài)機(jī)切換時(shí)收到通知:
RunLoop開(kāi)始
RunLoop即將處理Timer
RunLoop即將處理Source
RunLoop即將進(jìn)入休眠狀態(tài)
RunLoop即將從休眠狀態(tài)被事件喚醒
RunLoop退出
因?yàn)椤邦A(yù)緩存高度”的任務(wù)需要在最無(wú)感知的時(shí)刻進(jìn)行愉棱,所以應(yīng)該同時(shí)滿足:
RunLoop 處于“空閑”狀態(tài) Mode
當(dāng)這一次 RunLoop 迭代處理完成了所有事件唆铐,馬上要休眠時(shí)
使用 CF 的帶 block 版本的注冊(cè)函數(shù)可以讓代碼更簡(jiǎn)潔:
CFRunLoopRefrunLoop =CFRunLoopGetCurrent();
CFStringRefrunLoopMode = kCFRunLoopDefaultMode;
CFRunLoopObserverRefobserver =CFRunLoopObserverCreateWithHandler
(kCFAllocatorDefault, kCFRunLoopBeforeWaiting,true,0, ^(CFRunLoopObserverRefobserver,CFRunLoopActivity_) {
// TODO here
});
CFRunLoopAddObserver(runLoop, observer, runLoopMode);
在其中的 TODO 位置,就可以開(kāi)始任務(wù)的收集和分發(fā)了奔滑,當(dāng)然艾岂,不能忘記適時(shí)的移除這個(gè) observer
分解成多個(gè)RunLoop Source任務(wù)
假設(shè)列表有 20 個(gè) cell,加載后展示了前 5 個(gè)朋其,那么開(kāi)啟估算后 table view 只計(jì)算了這 5 個(gè)的高度王浴,此時(shí)剩下 15 個(gè)就是“預(yù)緩存”的任務(wù),而我們并不希望這 15 個(gè)計(jì)算任務(wù)在同一個(gè) RunLoop 迭代中同步執(zhí)行梅猿,這樣會(huì)卡頓 UI氓辣,所以應(yīng)該把它們分別分解到 15 個(gè) RunLoop 迭代中執(zhí)行,這時(shí)就需要手動(dòng)向 RunLoop 中添加 Source 任務(wù)(由應(yīng)用發(fā)起和處理的是 Source 0 任務(wù))
Foundation 層沒(méi)對(duì) RunLoopSource 提供直接構(gòu)建的 API袱蚓,但是提供了一個(gè)間接的钞啸、既熟悉又陌生的 API:
-(void)performSelector:(SEL)aSelector
onThread:(NSThread*)thr
withObject:(id)arg
waitUntilDone:(BOOL)wait
modes:(NSArray*)array;
這個(gè)方法將創(chuàng)建一個(gè) Source 0 任務(wù),分發(fā)到指定線程的 RunLoop 中喇潘,在給定的 Mode 下執(zhí)行体斩,若指定的 RunLoop 處于休眠狀態(tài),則喚醒它處理事件颖低,簡(jiǎn)單來(lái)說(shuō)就是“睡你xx絮吵,起來(lái)嗨!”
于是忱屑,我們用一個(gè)可變數(shù)組裝載當(dāng)前所有需要“預(yù)緩存”的 index path蹬敲,每個(gè) RunLoopObserver 回調(diào)時(shí)都把第一個(gè)任務(wù)拿出來(lái)分發(fā):
NSMutableArray*mutableIndexPathsToBePrecached =self.fd_allIndexPathsToBePrecached.mutableCopy;
CFRunLoopObserverRefobserver =CFRunLoopObserverCreateWithHandler
(kCFAllocatorDefault, kCFRunLoopBeforeWaiting,true,0, ^(CFRunLoopObserverRefobserver,CFRunLoopActivity_) {
if(mutableIndexPathsToBePrecached.count==0) {
CFRunLoopRemoveObserver(runLoop, observer, runLoopMode);
CFRelease(observer);// 注意釋放,否則會(huì)造成內(nèi)存泄露
return;
}
NSIndexPath*indexPath = mutableIndexPathsToBePrecached.firstObject;
[mutableIndexPathsToBePrecached removeObject:indexPath];
[selfperformSelector:@selector(fd_precacheIndexPathIfNeeded:)
onThread:[NSThreadmainThread]
withObject:indexPath
waitUntilDone:NO
modes:@[NSDefaultRunLoopMode]];
});
這樣莺戒,每個(gè)任務(wù)都被分配到下個(gè)“空閑” RunLoop 迭代中執(zhí)行粱栖,其間但凡有滑動(dòng)事件開(kāi)始,Mode 切換成 UITrackingRunLoopMode脏毯,所有的“預(yù)緩存”任務(wù)的分發(fā)和執(zhí)行都會(huì)自動(dòng)暫定闹究,最大程度保證滑動(dòng)流暢。
開(kāi)始使用UITableView+FDTemplateLayoutCell
如果你覺(jué)得這個(gè)工具能幫得到你食店,整合到工程也十分簡(jiǎn)單渣淤。
使用 cocoapods:
pod search UITableView+FDTemplateLayoutCell
寫(xiě)這篇文章時(shí)的最新版本為 1.2,去除了前一個(gè)版本的黑魔法吉嫩,增加了預(yù)緩存功能价认。
歡迎使用和支持這個(gè)工具,有 bug 請(qǐng)隨時(shí)反饋哦~
再?gòu)?fù)習(xí)下 github 地址:https://github.com/forkingdog/UITableView-FDTemplateLayoutCell