這篇文章是我和咱們團隊最近對 UITableViewCell 利用 AutoLayout 自動高度計算和 UITableView 滑動優化的一個總結。
咱們也在維護一個開源的擴展,UITableView+FDTemplateLayoutCell
,讓高度計算這個事情變的史無前例的簡單,也受到了不少星星的支持,github連接請戳我git
這篇總結你能夠讀到:github
UITableView
是咱們再熟悉不過的視圖了,它的 delegate 和 data source 回調不知寫了多少次,也難免遇到 UITableViewCell 高度計算的事。UITableView 詢問 cell 高度有兩種方式。
一種是針對全部 Cell 具備固定高度的狀況,經過:數組
1 |
self.tableView.rowHeight = 88; |
上面的代碼指定了一個全部 cell 都是 88 高度的 UITableView,對於定高需求的表格,強烈建議使用這種(而非下面的)方式保證沒必要要的高度計算和調用。rowHeight
屬性的默認值是 44,因此一個空的 UITableView 顯示成那個樣子。緩存
另外一種方式就是實現 UITableViewDelegate 中的:網絡
1 2 3 |
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { // return xxx } |
須要注意的是,實現了這個方法後,rowHeight
的設置將無效。因此,這個方法適用於具備多種 cell 高度的 UITableView。ide
這個屬性 iOS7 就出現了, 文檔是這麼描述它的做用的:函數
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.工具
恩,聽上去蠻靠譜的。咱們知道,UITableView 是個 UIScrollView,就像平時使用 UIScrollView 同樣,加載時指定 contentSize
後它才能根據本身的 bounds、contentInset、contentOffset 等屬性共同決定是否能夠滑動以及滾動條的長度。而 UITableView 在一開始並不知道本身會被填充多少內容,因而詢問 data source 個數和建立 cell,同時詢問 delegate 這些 cell 應該顯示的高度,這就形成它在加載的時候浪費了多餘的計算在屏幕外邊的 cell 上。和上面的 rowHeight
很相似,設置這個估算高度有兩種方法:oop
1 2 3 4 5 |
self.tableView.estimatedRowHeight = 88; // or - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { // return xxx } |
有所不一樣的是,即便面對種類不一樣的 cell,咱們依然可使用簡單的 estimatedRowHeight
屬性賦值,只要總體估算值接近就能夠,好比大概有一半 cell 高度是 44, 一半 cell 高度是 88, 那就能夠估算一個 66,基本符合預期。佈局
說完了估算高度的基本使用,能夠開始吐槽了:
具備動態高度內容的 cell 一直是個頭疼的問題,好比聊天氣泡的 cell, frame 佈局時代一般是用數據內容反算高度:
1 |
CGFloat height = textHeightWithFont() + imageHeight + topMargin + bottomMargin + ...;
|
供 UITableViewDelegate 調用時極可能是個 cell 的類方法:
1 2 3 |
@interface BubbleCell : UITableViewCell + (CGFloat)heightWithEntity:(id)entity; @end |
各類魔法 margin 加上耦合了屏幕寬度。
AutoLayout 時代好了很多,提供了-systemLayoutSizeFittingSize:
的 API,在 contentView 中設置約束後,就能計算出準確的值;缺點是計算速度確定沒有手算快,並且這是個實例方法,須要維護專門爲計算高度而生的 template layout cell
,它還要求使用者對約束設置的比較熟練,要保證 contentView 內部上下左右全部方向都有約束支撐,設置不合理的話計算的高度就成了0。
這裏還不得不提到一個 UILabel 的蛋疼問題,當 UILabel 行數大於0時,須要指定 preferredMaxLayoutWidth
後它才知道本身何時該折行。這是個「雞生蛋蛋生雞」的問題,由於 UILabel 須要知道 superview 的寬度才能折行,而 superview 的寬度還依仗着子 view 寬度的累加才能肯定。這個問題好像到 iOS8 纔可以自動解決(不過咱們找到了解決方案)
回到正題,iOS8 WWDC 中推出了 self-sizing cell
的概念,旨在讓 cell 本身負責本身的高度計算,使用 frame layout 和 auto layout 均可以享受到:
這個特性首先要求是 iOS8,要是最低支持的系統版本小於8的話,還得針對老版本單寫套老式的算高(囧),不過用的 API 到不是新面孔:
1 2 |
self.tableView.estimatedRowHeight = 213; self.tableView.rowHeight = UITableViewAutomaticDimension; |
這裏又不得不吐槽了,自動計算 rowHeight 跟 estimatedRowHeight 究竟是有什麼仇,若是不加上估算高度的設置,自動算高就失效了- -
PS:iOS8 系統中 rowHeight 的默認值已經設置成了 UITableViewAutomaticDimension,因此第二行代碼能夠省略。
問題:
相同的代碼在 iOS7 和 iOS8 上滑動順暢程度徹底不一樣,iOS8 莫名奇妙的卡。很大一部分緣由是 iOS8 上的算高機制大不相同,這是我作的小測試:
研究後發現這麼屢次額外計算有下面的緣由:
dequeueReusableCellWithIdentifier:forIndexPath:
相比不帶 「forIndexPath」 的版本會多調用一次高度計算iOS8 把高度計算搞成這個樣子,從 WWDC 也卻是能找到點解釋,cell 被認爲隨時均可能改變高度(如從設置中調整動態字體大小),因此每次滑動出來後都要從新計算高度。
說了這麼多,究竟有沒有既能省去算高煩惱,又能保證順暢的滑動,還能支持 iOS6+ 的一站式解決方案呢?
使用 UITableView+FDTemplateLayoutCell
無疑是解決算高問題的最佳實踐之一,既有 iOS8 self-sizing 功能簡單的 API,又能夠達到 iOS7 流暢的滑動效果,還保持了最低支持 iOS6。
使用起來大概是這樣:
1 2 3 4 5 6 7 |
#import <UITableView+FDTemplateLayoutCell.h> - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return [tableView fd_heightForCellWithIdentifier:@"identifer" cacheByIndexPath:indexPath configuration:^(id cell) { // 配置 cell 的數據源,和 "cellForRow" 乾的事一致,好比: cell.entity = self.feedEntities[indexPath.row]; }]; } |
寫完上面的代碼後,你就已經使用到了:
-dequeueCellForReuseIdentifier:
方法 lazy 建立並保存,因此要求這個 ReuseID 必須已經被註冊到了 UITableView 中,也就是說,要麼是 Storyboard 中的原型 cell,要麼就是使用了 UITableView 的 -registerClass:forCellReuseIdentifier:
或 -registerNib:forCellReuseIdentifier:
其中之一的註冊方法。-systemLayoutSizeFittingSize:
-reloadData
,-deleteRowsAtIndexPaths:withRowAnimation:
等任何一個觸發 UITableView 刷新機制的方法時,已有的高度緩存將以最小的代價執行失效。如刪除一個 indexPath 爲 [0:5] 的 cell 時,[0:0] ~ [0:4] 的高度緩存不受影響,而 [0:5] 後面全部的緩存值都向前移動一個位置。自動緩存失效機制對 UITableView 的 9 個公有 API 都進行了分別的處理,以保證沒有一次多餘的高度計算。咱們在設計這個工具的 API 時斟酌了很是長的時間,既要保證功能的強大,也要保證接口的精簡,一行調用背後隱藏着不少功能。
這一套緩存機制能對滑動起多大影響呢?除了肉眼能明顯的感知到外,我還作了個小測試:
一個有 54 個內容和高度不一樣 cell 的 table view,從頭滑動到尾,再從尾滑動到頭,iOS8 系統下,iPhone6,使用 Time Profiler
監測算高函數所花費的時間:
未使用緩存API、未使用估算,共花費 877 ms:
使用緩存API、開啓估算,共花費 77 ms:
測試數據的精度先無論,從量級上就差了一個數量級,說實話本身也沒想到差距有這麼大- -
同時,工具也順手解決了-preferredMaxLayoutWidth
的問題,在計算高度前向 contentView 加了一條和 table view 寬度相同的寬度約束,強行讓 contentView 內部的控件知道了本身父 view 的寬度,再反算本身被外界約束的寬度,破除「雞生蛋蛋生雞」的問題,這裏比較 tricky,就不展開說了。下面說說利用 RunLoop 預緩存的實現。
FDTemplateLayoutCell 的高度預緩存是一個優化功能,它要求頁面處於空閒狀態時才執行計算,當用戶正在滑動列表時顯然不該該執行計算任務影響滑動體驗。
通常來講,這個功能要耦合 UITableView 的滑動狀態才行,但這種實現十分不優雅且可能破壞外部的 delegate 結構,但好在咱們還有RunLoop
這個工具,瞭解它的運行機制後,能夠用很簡單的代碼實現上面的功能。
在曾經的 RunLoop 線下分享會(視頻可戳)中介紹了 RunLoopMode 的概念。
當用戶正在滑動 UIScrollView 時,RunLoop 將切換到 UITrackingRunLoopMode
接受滑動手勢和處理滑動事件(包括減速和彈簧效果),此時,其餘 Mode (除 NSRunLoopCommonModes 這個組合 Mode)下的事件將所有暫停執行,來保證滑動事件的優先處理,這也是 iOS 滑動順暢的重要緣由。
當 UI 沒在滑動時,默認的 Mode 是 NSDefaultRunLoopMode
(同 CF 中的 kCFRunLoopDefaultMode),同時也是 CF 中定義的 「空閒狀態 Mode」。當用戶啥也不點,此時也沒有什麼網絡 IO 時,就是在這個 Mode 下。
註冊 RunLoopObserver 能夠觀測當前 RunLoop 的運行狀態,並在狀態機切換時收到通知:
由於「預緩存高度」的任務須要在最無感知的時刻進行,因此應該同時知足:
使用 CF 的帶 block 版本的註冊函數可讓代碼更簡潔:
1 2 3 4 5 6 7 |
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFStringRef runLoopMode = kCFRunLoopDefaultMode;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler
(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) { // TODO here }); CFRunLoopAddObserver(runLoop, observer, runLoopMode); |
在其中的 TODO 位置,就能夠開始任務的收集和分發了,固然,不能忘記適時的移除這個 observer
假設列表有 20 個 cell,加載後展現了前 5 個,那麼開啓估算後 table view 只計算了這 5 個的高度,此時剩下 15 個就是「預緩存」的任務,而咱們並不但願這 15 個計算任務在同一個 RunLoop 迭代中同步執行,這樣會卡頓 UI,因此應該把它們分別分解到 15 個 RunLoop 迭代中執行,這時就須要手動向 RunLoop 中添加 Source 任務(由應用發起和處理的是 Source 0 任務)
Foundation 層沒對 RunLoopSource 提供直接構建的 API,可是提供了一個間接的、既熟悉又陌生的 API:
1 2 3 4 5 |
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray *)array; |
這個方法將建立一個 Source 0 任務,分發到指定線程的 RunLoop 中,在給定的 Mode 下執行,若指定的 RunLoop 處於休眠狀態,則喚醒它處理事件,簡單來講就是「睡你xx,起來嗨!」
因而,咱們用一個可變數組裝載當前全部須要「預緩存」的 index path,每一個 RunLoopObserver 回調時都把第一個任務拿出來分發:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
NSMutableArray *mutableIndexPathsToBePrecached = self.fd_allIndexPathsToBePrecached.mutableCopy; CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler (kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) { if (mutableIndexPathsToBePrecached.count == 0) { CFRunLoopRemoveObserver(runLoop, observer, runLoopMode); return; } NSIndexPath *indexPath = mutableIndexPathsToBePrecached.firstObject; [mutableIndexPathsToBePrecached removeObject:indexPath]; [self performSelector:@selector(fd_precacheIndexPathIfNeeded:) onThread:[NSThread mainThread] withObject:indexPath waitUntilDone:NO modes:@[NSDefaultRunLoopMode]]; }); |
這樣,每一個任務都被分配到下個「空閒」 RunLoop 迭代中執行,其間但凡是有滑動事件開始,Mode 切換成 UITrackingRunLoopMode,全部的「預緩存」任務的分發和執行都會自動暫定,最大程度保證滑動流暢。
若是你以爲這個工具能幫獲得你,整合到工程也十分簡單。
使用 cocoapods:
1 |
pod search UITableView+FDTemplateLayoutCell
|
寫這篇文章時的最新版本爲 1.2,去除了前一個版本的黑魔法,增長了預緩存功能。
歡迎使用和支持這個工具,有 bug 請隨時反饋哦~
再複習下 github 地址: https://github.com/forkingdog/UITableView-FDTemplateLayoutCell
轉自:http://blog.sunnyxx.com/2015/05/17/cell-height-calculation/