UITableViewCell 自動高度

weixin_34236869發表於2015-11-25

UITableViewCell 自動高度

iOS8

由於各種天時地利的原因(OS X EI 和 Xcode 7.1.1)導致我在 google 了各種方式之後還是隻能最低執行到 iOS8,所以就先從 iOS8 開始說起吧。

首先在 iOS8 開始,系統將 Cell 的高度計算明確的分為了兩種方式:

  1. 固定高度
  2. 自動高度

固定高度

只要一行程式碼就可以很簡單的實現 Cell 的固定高度:

tableView.rowHeight = /* fixed height */;

不能更方便了。

自動高度

在 iOS8 中,Cell 的高度計算方式預設就是『自動高度』,那麼怎麼實現呢?其實也很簡單:

  1. 為你的 Cell 設定了『合理的約束』
  2. 設定 TableView 的 estimatedRowHeight 屬性

iOS8 中 tableView.rowHeight 的預設值就是 UITableViewAutomaticDimension,所以不必設定了

contentSize

眾所周知 UITableView 繼承於 UIScrollView,那麼 UITableView 就需要設定 contentSize 值。那麼 UITableView 如何知道 contentSize 的值呢?

固定高度

如果 TableView 中的 Cell 採用的是固定高度,那麼 contentSize 的高度很明顯就是 fixedHeight × cellCount

自動高度

當採用了自動高度的話,那麼系統會分別呼叫 Cell 上的 systemLayoutSizeFittingSize 的方法,這個方法會根據你為 Cell 設定的約束計算出 Cell 的尺寸,那麼 contentSize 就會變成 dynamicallyCalculatedCellSize × cellCount

estimatedRowHeight

估算高度的作用是很大的,上面說到當你採用了『自動高度』的計算方式,那麼系統為了知道 contentSize,別無他法的在每個 Cell 上呼叫例項方法計算其尺寸,當你有若干的 Cell 時,就會引發效能問題。

為了上述的問題,系統在 TableView 上提供了 estimatedRowHeight 引數。那麼我們可以看看這個『估算行高』是怎麼起作用的。

首先 TableView 是可以知道自身的 bounds 的,那麼就以 bounds 為基準,至少獲取能填滿 bounds 的 Cells 的尺寸,對於其餘的 Cells 尺寸,系統採用的是『騎驢看唱本-走著瞧』的方式。下面舉幾個例子大家體會下:

  1. bounds.size.height 為 570,而 estimatedRowHeight 為 20,總共的 cells 有 100 個,cells 的真實高度都八九不離十的是 22。問,最初計算的 cell's height 有多少?

29個 = ceil( 570 / 20 )

  1. bounds.size.height 為 570,而 estimatedRowHeight 為 20,總共的 cells 有 100 個,cells 的真實高度都八九不離十的是 100。問,最初計算的 cell's height 有多少?

仍然是 29個 = ceil( 570 / 20 )

  1. bounds.size.height 為 570,而 estimatedRowHeight 為 90,總共的 cells 有 100 個,cells 的真實高度都八九不離十的是 100。問,最初計算的 cell's height 有多少?

7個 = ceil(570/90)

  1. bounds.size.height 為 570,而 estimatedRowHeight 為 1000,總共的 cells 有 100 個,cells 的真實高度都八九不離十的是 100。問,最初計算的 cell's height 有多少?

6 個。因為 estimatedRowHeight 為 1000 那麼系統通過計算 ceil(570 / 1000) 得出需要動態計算一個 Cell 的大小。可是問題來了,計算了 Cell 的實際高度發現只有 100,於是為了填滿 bounds,必須繼續計算接下來的 Cell,於是計算到填滿了就不再計算。

所以對於 estimatedRowHeight 的值,可以設定得和所有 Cell 的平均值一樣,也可以設定得很大,比 TableView 的 bounds 還要大,當然前者是比較好確定的。

那麼小結下 estimatedRowHeight 的作用,就是為了加速 TableView 獲取自身的 contentSize 的操作,這樣儘快的將資料顯示出來,然後其餘的 Cells 尺寸在滑動的時候在計算。

問題及優化

在 iOS8 中使用了 Cell 自動高度之後,你會發現,只要一個 Cell 需要被顯示到螢幕上,它的高度都會被計算一次,即使這個 Cell 在之前的滑動中已經被計算過高度了。之所以被設計成這樣的原因系統認為 Cell 的高度是隨時可能改變的,比如在設定中改變了字型大小:

737583-760d62c08da78e84.png

如果在 iOS7 中使用了自動高度,你就會發現一旦 Cell 在之前被計算過高度,那麼它下一次滑動出來時就不會被計算高度了。這是因為從 iOS7 開始,iOS7 中引入了 Dynamic Type 的功能,這個功能使得使用者可以調整應用中字型的大小,而 iOS7 中的所有系統應用都適配了這個功能需求。但是從 iOS8 開始,Apple 希望所有的應用都可以適配這個功能需求,於是就取消了 Cell 在自動算高時的高度快取。

於是如你所見,在 iOS8 中由於沒有了自動的高度快取,那麼在使用自動高度時,Cell 的高度會被多次計算,這樣就會導致滑動不流暢。其實這不是大的問題,Apple 為了把 Cell 的高度計算變得更靈活,使得是否動態計算高度 or 使用快取已計算的高度的工作放到了開發者這邊,還是很符合設計模式的,只不過開發者使用有些麻煩了。

優化的方式其實說起來也是很簡單的,就是對於已經計算了高度的 Cell,只要確信它的高度是不會再變化的,那麼就將這個高度快取起來,下回在系統向你所要 Cell 高度時(heightForRowAtIndexPath),返回那個之間計算過的高度快取就行了。

iOS7

其實 iOS7 中使用 Cell 自動高度沒有什麼好討論的了,系統會自動的為我們快取已經計算過的 Cell 高度。唯一要注意的是在 iOS7 中需要顯式的設定:

tableView.rowHeight = UITableViewAutomaticDimension;

其他還是和在 iOS8 中一樣的:你為 Cell 設定了『合理的約束』,讓 TabaleView 使用自動 Cell 高度計算,剩下的系統就為了做了。

iOS6

完全沒接觸過不清楚

怎麼快取

上面已經說了在 iOS8 中我們需要自己決定是否快取那些已經計算過的 Cell 高度。那麼我們應該如何快取呢?有兩點很重要:

  1. 快取的 Key 如何決定
  2. 使用什麼作為 Cache Storage

Key

因為需要通過 Key 去取回 Cell 已經計算過的 Height,那麼 Key 需要可以標識出各個 Cell。我們可以選取既可以標識 Cells 又可以區別它們之間不同的屬性來作為 Key。對於一個 Objc 物件,它們之間最顯著的不同肯定是它們的 memory address 了,而且需要獲取 Objc 物件的記憶體地址也很簡單:

NSString *temp = @"123";
uintptr_t ptrAddress = (uintptr_t) temp;

但是,請回憶我們在使用 TableView 和 Cell 時常用的方法:

- dequeueReusableCellWithIdentifier:forIndexPath:
- dequeueReusableCellWithIdentifier:

就是它倆使得 TableView 中 Cells 都是 Reused。所以通過 memory address 的方式是不行了。剩下的唯一可用的方式就是 indexPath 了 ?。

Cache Storage

選什麼作為 Cache Storage 呢?可用的有:

  • NSMutableArray
  • NSCache
  • objc_setAssociatedObject

先看看它們之間讀寫效能的差別,主要程式碼來自這兒,我加上了 NSMutableArray 部分:

#import <Foundation/Foundation.h>
#import <objc/runtime.h>

void logTimeSpentExecutingBlock(dispatch_block_t block, NSString* label)
{
    NSTimeInterval then = CFAbsoluteTimeGetCurrent();
    block();
    NSTimeInterval now = CFAbsoluteTimeGetCurrent();
    NSLog(@"Spent %.5f seconds on %@", now - then, label);
}

@interface Test : NSObject {
@public
    NSString* ivar;
}
@property (nonatomic, strong) NSString* ordinary;
@end

@interface Test (Runtime)
@property (nonatomic, strong) NSString* runtime;
@end

@implementation Test

- (void)setOrdinary:(NSString*)ordinary
{
    // the default implementation checks if the ivar is already equal
    _ordinary = ordinary;
}

@end

@implementation Test (Runtime)

- (NSString*)runtime
{
    return objc_getAssociatedObject(self, @selector(runtime));
}

- (void)setRuntime:(NSString*)string
{
    objc_setAssociatedObject(self, @selector(runtime), string, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

@end

int main(int argc, const char* argv[])
{
    @autoreleasepool
    {
        Test* test = [Test new];
        int iterations = 1000000;

        NSCache* cache = [[NSCache alloc] init];
        NSMutableArray* arr = [[NSMutableArray alloc] init];

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                test->ivar = @"foo";
            }
        }, @"writing ivar");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                test->ivar;
            }
        }, @"reading ivar");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                test.ordinary = @"foo";
            }
        }, @"writing ordinary");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                [test ordinary];
            }
        }, @"reading ordinary");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                test.runtime = @"foo";
            }
        }, @"writing runtime");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                [test runtime];
            }
        }, @"reading runtime");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                [cache setObject:@"1" forKey:@(i)];
            }
        }, @"writing NSCache");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                [cache objectForKey:@(i)];
            }
        }, @"reading NSCache");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                [arr addObject:@"1"];
            }
        }, @"writing NSMutableArray");

        logTimeSpentExecutingBlock(^{
            for (int i = 0; i < iterations; i++) {
                arr[i];
            }
        }, @"reading NSMutableArray");
    }
    return 0;
}

輸出的結果:

Spent 0.00408 seconds on writing ivar
Spent 0.00177 seconds on reading ivar
Spent 0.02587 seconds on writing ordinary
Spent 0.01329 seconds on reading ordinary
Spent 0.06314 seconds on writing runtime
Spent 0.04348 seconds on reading runtime
Spent 1.26897 seconds on writing NSCache
Spent 0.29358 seconds on reading NSCache
Spent 0.02913 seconds on writing NSMutableArray
Spent 0.01621 seconds on reading NSMutableArray

好了可以淘汰 NSCache 了。看到 objc_set/get 效能和 NSMutableArray 是差不多的,那麼選擇哪一個呢?

其實這是要根據我們的業務需求的,對於存 Height 它倆都可以完成,但是我們知道 Cells 是需要可以 Delete/Insert 的,那麼問題來了,如果有了 Delete/Insert 操作,而我們的 Key 是根據的 indexPath,那麼快取中的 Key 就『不準』了,需要進行相應的調整。而使用 NSMutableArray 當你 Delete/Insert 時它會自動的為我們將操作索引的後續索引進行調整。

所以如果我們需要使用自己的快取,需要這樣:

  1. 決定合適的 Cache Key
  2. 選取合適的 Cache Storage
  3. Delete/Insert 發生時調整快取資料

所有這些還是有點麻煩的,所以大概的原理知道了就可以開始使用別人的勞動成果了 UITableView-FDTemplateLayoutCell ?

對了開頭的『合理的約束』是什麼可以在這裡找到 About self-satisfied cell

相關文章