UITableView-FDTemplateLayoutCell----UITableViewCell高度計算的那些事

我是前言

這篇文章是我和我們團隊最近對 UITableViewCell 利用 AutoLayout 自動高度計算和 UITableView 滑動優化的一個總結。
我們也在維護一個開源的擴展,UITableView+FDTemplateLayoutCell,讓高度計算這個事情變的前所未有的簡單,也受到了很多星星的支持,github鏈接請戳我

這篇總結你可以讀到:

  • UITableView高度計算和估算的機制
  • 不同iOS系統在高度計算上的差異
  • iOS8 self-sizing cell
  • UITableView+FDTemplateLayoutCell如何用一句話解決高度問題
  • UITableView+FDTemplateLayoutCell中對RunLoop的使用技巧

UITableViewCell高度計算

rowHeight

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。

estimatedRowHeight

這個屬性 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 很類似,設置這個估算高度有兩種方法:

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,基本符合預期。

說完了估算高度的基本使用,可以開始吐槽了:

  1. 設置估算高度後,contentSize.height 根據“cell估算值 x cell個數”計算,這就導致滾動條的大小處於不穩定的狀態,contentSize 會隨着滾動從估算高度慢慢替換成真實高度,肉眼可見滾動條突然變化甚至“跳躍”。
  2. 若是有設計不好的下拉刷新或上拉加載控件,或是 KVO 了 contentSize 或 contentOffset 屬性,有可能使表格滑動時跳動。

  1. 估算高度設計初衷是好的,讓加載速度更快,那憑啥要去侵害滑動的流暢性呢,用戶可能對進入頁面時多零點幾秒加載時間感覺不大,但是滑動時實時計算高度帶來的卡頓是明顯能體驗到的,個人覺得還不如一開始都算好了呢(iOS8更過分,即使都算好了也會邊劃邊計算)

iOS8 self-sizing cell

具有動態高度內容的 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,所以第二行代碼可以省略。

問題:

  1. 這個自動算高在 push 到下一個頁面或者轉屏時會出現高度特別詭異的情況,不過現在的版本修復了。
  2. 求一個能讓最低支持 iOS8 的公司- -

iOS8抽風的算高機制

相同的代碼在 iOS7 和 iOS8 上滑動順暢程度完全不同,iOS8 莫名奇妙的卡。很大一部分原因是 iOS8 上的算高機制大不相同,這是我做的小測試:

研究後發現這麼多次額外計算有下面的原因:

  1. 不開啓高度估算時,UITableView 上來就要對所有 cell 調用算高來確定 contentSize
  2. dequeueReusableCellWithIdentifier:forIndexPath: 相比不帶 “forIndexPath” 的版本會多調用一次高度計算
  3. iOS7 計算高度後有”緩存“機制,不會重複計算;而 iOS8 不論何時都會重新計算 cell 高度

iOS8 把高度計算搞成這個樣子,從 WWDC 也倒是能找到點解釋,cell 被認爲隨時都可能改變高度(如從設置中調整動態字體大小),所以每次滑動出來後都要重新計算高度。

說了這麼多,究竟有沒有既能省去算高煩惱,又能保證順暢的滑動,還能支持 iOS6+ 的一站式解決方案呢?


UITableView+FDTemplateLayoutCell

使用 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];
    }];
}

寫完上面的代碼後,你就已經使用到了:

  • 和每個 UITableViewCell ReuseID 一一對應的 template layout cell
    這個 cell 只爲了參加高度計算,不會真的顯示到屏幕上;它通過 UITableView 的 -dequeueCellForReuseIdentifier: 方法 lazy 創建並保存,所以要求這個 ReuseID 必須已經被註冊到了 UITableView 中,也就是說,要麼是 Storyboard 中的原型 cell,要麼就是使用了 UITableView 的 -registerClass:forCellReuseIdentifier: 或 -registerNib:forCellReuseIdentifier:其中之一的註冊方法。
  • 根據 autolayout 約束自動計算高度
    使用了系統在 iOS6 就提供的 API:-systemLayoutSizeFittingSize:
  • 根據 index path 的一套高度緩存機制
    計算出的高度會自動進行緩存,所以滑動時每個 cell 真正的高度計算只會發生一次,後面的高度詢問都會命中緩存,減少了非常可觀的多餘計算。
  • 自動的緩存失效機制
    無須擔心你數據源的變化引起的緩存失效,當調用如-reloadData-deleteRowsAtIndexPaths:withRowAnimation:等任何一個觸發 UITableView 刷新機制的方法時,已有的高度緩存將以最小的代價執行失效。如刪除一個 indexPath 爲 [0:5] 的 cell 時,[0:0] ~ [0:4] 的高度緩存不受影響,而 [0:5] 後面所有的緩存值都向前移動一個位置。自動緩存失效機制對 UITableView 的 9 個公有 API 都進行了分別的處理,以保證沒有一次多餘的高度計算。
  • 預緩存機制
    預緩存機制將在 UITableView 沒有滑動的空閒時刻執行,計算和緩存那些還沒有顯示到屏幕中的 cell,整個緩存過程完全沒有感知,這使得完整列表的高度計算既沒有發生在加載時,又沒有發生在滑動時,同時保證了加載速度和滑動流暢性,下文會着重講下這塊的實現原理。

我們在設計這個工具的 API 時斟酌了非常長的時間,既要保證功能的強大,也要保證接口的精簡,一行調用背後隱藏着很多功能。

這一套緩存機制能對滑動起多大影響呢?除了肉眼能明顯的感知到外,我還做了個小測試:
一個有 54 個內容和高度不同 cell 的 table view,從頭滑動到尾,再從尾滑動到頭,iOS8 系統下,iPhone6,使用 Time Profiler 監測算高函數所花費的時間:

未使用緩存API、未使用估算,共花費 877 ms:

使用緩存API、開啓估算,共花費 77 ms:

測試數據的精度先不管,從量級上就差了一個數量級,說實話自己也沒想到差距有這麼大- -

同時,工具也順手解決了-preferredMaxLayoutWidth的問題,在計算高度前向 contentView 加了一條和 table view 寬度相同的寬度約束,強行讓 contentView 內部的控件知道了自己父 view 的寬度,再反算自己被外界約束的寬度,破除“雞生蛋蛋生雞”的問題,這裏比較 tricky,就不展開說了。下面說說利用 RunLoop 預緩存的實現。





發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章