我是前言
這篇文章是我和我們團隊最近對 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 | |
上面的代碼指定了一個所有 cell 都是 88 高度的 UITableView,對于定高需求的表格,強烈建議使用這種(而非下面的)方式保證不必要的高度計算和調用。rowHeight屬性的預設值是 44,是以一個空的 UITableView 顯示成那個樣子。
另一種方式就是實作 UITableViewDelegate 中的:
1 2 3 | |
需要注意的是,實作了這個方法後,rowHeight 的設定将無效。是以,這個方法适用于具有多種 cell 高度的 UITableView。
estimatedRowHeight
這個屬性 iOS 7 就出現了, 文檔是這麼描述它的作用的:
1 | |
恩,聽上去蠻靠譜的。我們知道,UITableView 是個 UIScrollView,就像平時使用 UIScrollView 一樣,加載時指定 contentSize 後它才能根據自己的 bounds、contentInset、contentOffset 等屬性共同決定是否可以滑動以及滾動條的長度。而 UITableView 在一開始并不知道自己會被填充多少内容,于是詢問 data source 個數和建立 cell,同時詢問 delegate 這些 cell 應該顯示的高度,這就造成它在加載的時候浪費了多餘的計算在螢幕外邊的 cell 上。和上面的 rowHeight 很類似,設定這個估算高度有兩種方法:
1 2 3 4 5 | |
有所不同的是,即使面對種類不同的 cell,我們依然可以使用簡單的 estimatedRowHeight 屬性指派,隻要整體估算值接近就可以,比如大概有一半 cell 高度是 44, 一半 cell 高度是 88, 那就可以估算一個 66,基本符合預期。
說完了估算高度的基本使用,可以開始吐槽了:
- 設定估算高度後,contentSize.height 根據“cell估算值 x cell個數”計算,這就導緻滾動條的大小處于不穩定的狀态,contentSize 會随着滾動從估算高度慢慢替換成真實高度,肉眼可見滾動條突然變化甚至“跳躍”。
- 若是有設計不好的下拉重新整理或上拉加載控件,或是 KVO 了 contentSize 或 contentOffset 屬性,有可能使表格滑動時跳動。
- 估算高度設計初衷是好的,讓加載速度更快,那憑啥要去侵害滑動的流暢性呢,使用者可能對進入頁面時多零點幾秒加載時間感覺不大,但是滑動時實時計算高度帶來的卡頓是明顯能體驗到的,個人覺得還不如一開始都算好了呢(iOS8更過分,即使都算好了也會邊劃邊計算)
iOS 8 self-sizing cell
具有動态高度内容的 cell 一直是個頭疼的問題,比如聊天氣泡的 cell, frame 布局時代通常是用資料内容反算高度:
1 | |
供 UITableViewDelegate 調用時很可能是個 cell 的類方法:
1 2 3 | |
各種魔法 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 都可以享受到:
這個特性首先要求是 iOS 8,要是最低支援的系統版本小于8的話,還得針對老版本單寫套老式的算高(囧),不過用的 API 到不是新面孔:
1 2 | |
這裡又不得不吐槽了,自動計算 rowHeight 跟 estimatedRowHeight 到底是有什麼仇,如果不加上估算高度的設定,自動算高就失效了- -
PS:iOS8 系統中 rowHeight 的預設值已經設定成了 UITableViewAutomaticDimension,是以第二行代碼可以省略。
問題:
- 這個自動算高在 push 到下一個頁面或者轉屏時會出現高度特别詭異的情況,不過現在的版本修複了。
- 求一個能讓最低支援 iOS8 的公司- -
iOS8抽風的算高機制
相同的代碼在 iOS7 和 iOS8 上滑動順暢程度完全不同,iOS8 莫名奇妙的卡。很大一部分原因是 iOS8 上的算高機制大不相同,這是我做的小測試:
研究後發現這麼多次額外計算有下面的原因:
- 不開啟高度估算時,UITableView 上來就要對所有 cell 調用算高來确定 contentSize
- dequeueReusableCellWithIdentifier:forIndexPath: 相比不帶 “forIndexPath” 的版本會多調用一次高度計算
- iOS 7 計算高度後有”緩存“機制,不會重複計算;而 iOS8 不論何時都會重新計算 cell 高度
iOS 8 把高度計算搞成這個樣子,從 WWDC 也倒是能找到點解釋,cell 被認為随時都可能改變高度(如從設定中調整動态字型大小),是以每次滑動出來後都要重新計算高度。
說了這麼多,究竟有沒有既能省去算高煩惱,又能保證順暢的滑動,還能支援 iOS6+ 的一站式解決方案呢?
UITableView+FDTemplateLayoutCell
使用 UITableView+FDTemplateLayoutCell 無疑是解決算高問題的最佳實踐之一,既有 iOS8 self-sizing 功能簡單的 API,又可以達到 iOS7 流暢的滑動效果,還保持了最低支援 iOS6。
使用起來大概是這樣:
1 2 3 4 5 6 7 | |
寫完上面的代碼後,你就已經使用到了:
-
和每個 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,從頭滑動到尾,再從尾滑動到頭,iOS 8 系統下,iPhone6,使用 Time Profiler 監測算高函數所花費的時間:
未使用緩存API、未使用估算,共花費 877 ms:
使用緩存API、開啟估算,共花費 77 ms:
測試資料的精度先不管,從量級上就差了一個數量級,說實話自己也沒想到差距有這麼大- -
同時,工具也順手解決了-preferredMaxLayoutWidth的問題,在計算高度前向 contentView 加了一條和 table view 寬度相同的寬度限制,強行讓 contentView 内部的控件知道了自己父 view 的寬度,再反算自己被外界限制的寬度,破除“雞生蛋蛋生雞”的問題,這裡比較 tricky,就不展開說了。下面說說利用 RunLoop 預緩存的實作。
利用RunLoop空閑時間執行預緩存任務
FDTemplateLayoutCell 的高度預緩存是一個優化功能,它要求頁面處于空閑狀态時才執行計算,當使用者正在滑動清單時顯然不應該執行計算任務影響滑動體驗。
一般來說,這個功能要耦合 UITableView 的滑動狀态才行,但這種實作十分不優雅且可能破壞外部的 delegate 結構,但好在我們還有RunLoop這個工具,了解它的運作機制後,可以用很簡單的代碼實作上面的功能。
空閑RunLoopMode
在曾經的 RunLoop 線下分享會(視訊可戳)中介紹了 RunLoopMode 的概念。
當使用者正在滑動 UIScrollView 時,RunLoop 将切換到 UITrackingRunLoopMode 接受滑動手勢和處理滑動事件(包括減速和彈簧效果),此時,其他 Mode (除 NSRunLoopCommonModes 這個組合 Mode)下的事件将全部暫停執行,來保證滑動事件的優先處理,這也是 iOS 滑動順暢的重要原因。
當 UI 沒在滑動時,預設的 Mode 是 NSDefaultRunLoopMode(同 CF 中的 kCFRunLoopDefaultMode),同時也是 CF 中定義的 “空閑狀态 Mode”。當使用者啥也不點,此時也沒有什麼網絡 IO 時,就是在這個 Mode 下。
用RunLoopObserver找準時機
注冊 RunLoopObserver 可以觀測目前 RunLoop 的運作狀态,并在狀态機切換時收到通知:
- RunLoop開始
- RunLoop即将處理Timer
- RunLoop即将處理Source
- RunLoop即将進入休眠狀态
- RunLoop即将從休眠狀态被事件喚醒
- RunLoop退出
因為“預緩存高度”的任務需要在最無感覺的時刻進行,是以應該同時滿足:
- RunLoop 處于“空閑”狀态 Mode
- 當這一次 RunLoop 疊代處理完成了所有事件,馬上要休眠時
使用 CF 的帶 block 版本的注冊函數可以讓代碼更簡潔:
1 2 3 4 5 6 7 | |
在其中的 TODO 位置,就可以開始任務的收集和分發了,當然,不能忘記适時的移除這個 observer
分解成多個RunLoop Source任務
假設清單有 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 | |
這個方法将建立一個 Source 0 任務,分發到指定線程的 RunLoop 中,在給定的 Mode 下執行,若指定的 RunLoop 處于休眠狀态,則喚醒它處理事件,簡單來說就是“睡你xx,起來嗨!”
于是,我們用一個可變數組裝載目前所有需要“預緩存”的 index path,每個 RunLoopObserver 回調時都把第一個任務拿出來分發:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
這樣,每個任務都被配置設定到下個“空閑” RunLoop 疊代中執行,其間但凡有滑動事件開始,Mode 切換成 UITrackingRunLoopMode,所有的“預緩存”任務的分發和執行都會自動暫定,最大程度保證滑動流暢。
開始使用UITableView+FDTemplateLayoutCell
如果你覺得這個工具能幫得到你,整合到工程也十分簡單。
使用 cocoapods:
1 | |
寫這篇文章時的最新版本為 1.2,去除了前一個版本的黑魔法,增加了預緩存功能。
歡迎使用和支援這個工具,有 bug 請随時回報哦~
再複習下 github 位址: https://github.com/forkingdog/UITableView-FDTemplateLayoutCell