UITableViewCell優化諸事

最近的項目中出現了幾處界面卡頓的問題,雖然不全部是UITableViewCell的問題,但是這些問題都適用于UITableViewCell上,因此統一歸結為UITableViewCell的優化問題。

一、圓角###

其實圓角對流暢度的影響已經是一個老生常談的問題了,所以在此只對圓角對幀率影響的原因做一個簡單的概述。
圓角拖慢幀率的原因其實是由于離屏渲染,頻繁發生離屏渲染是非常耗時的。離屏渲染指的是GPU在當前屏幕緩沖區以外新開辟一個緩沖區進行渲染操作,離屏渲染耗時是發生在離屏這個動作上面,而不是渲染。離屏會發生緩沖區創建和上下文切換,創建新的緩沖區代價都不算大,付出最大代價的是上下文切換。
在上下文切換時首先要保存當前屏幕渲染環境,然后切換到一個新的繪制環境,申請繪制資源,初始化環境,然后開始一個繪制,繪制完畢后銷毀這個繪制環境,如需要切換到離屏渲染或者再開始一個新的離屏渲染重復之前的操作。 這一耗時的過程會致使離屏渲染要比普通渲染花費多一個數量級的時間。
不過好在ios9.0之后對UIImageView的圓角設置做了優化,UIImageView這樣設置圓角不會觸發離屏渲染。然而不幸的是我們要兼容ios9.0之前的性能,而且UIButton、UILabel這樣的控件設置圓角仍然會觸發離屏渲染,因此對于圓角問題我們依然要小心。

<img src="http://upload-images.jianshu.io/upload_images/1506986-f87ca3090d1afda6.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/240"/>

在這個項目中有一個自定義的日歷界面,日歷中日期的Label被設置了圓角,在從上一個界面進入到日歷這個界面時卡頓會非常明顯。由于日歷界面彈出過程比較短,從分析工具中很難看出幀率情況,因此重新寫了個用來演示工程。

演示工程

cell里的每一個Label都被設置了圓角,在設置圓角前后,滑動時幀率分別如下。

有圓角
無圓角

可以看出當cell里大量使用圓角以后,對界面流暢度的影響是驚人的。那么令人關心的問題來了,到底如何應對圓角問題呢?

  • 最直接的當然是不要使用圓角啦~

  • 什么?你非要用圓角嘛……那么這個時候也可以采用下面的方法:

     label.layer.shouldRasterize = YES;
     label.layer.rasterizationScale = [UIScreen mainScreen].scale;
    

    shouldRasterize = YES 會使視圖渲染內容被緩存起來,下次繪制的時候可以直接顯示緩存。不過這個方法也有它的局限性,要在視圖內容不改變的情況下才可以使用。

  • 那么使用layer.mask如何?不,千萬不!layer.mask將會更加降低你的流暢度,在這個測試工程里,使用layer.mask將會讓幀率降到11。除非你真的要使用到非常復雜的圓角,否則千萬不要使用layer.mask。

  • 下面介紹一個最全能的方法,對于UIView、UIImageView、UIButton、UILabel全部適用。

- (void)ay_setCornerRadius:(AYRadius)cornerRadius setNormalImage:(UIImage *)normalImage highlightedImage:(UIImage *)highlightedImage disabledImage:(UIImage *)disableImage selectedImage:(UIImage *)selectedImage backgroundColor:(UIColor *)color {

    if ([self isMemberOfClass:[UIButton class]]) {
        CGFloat viewWidth = self.frame.size.width;
        CGFloat viewHeight = self.frame.size.height;
        UIGraphicsBeginImageContextWithOptions(self.frame.size, NO, [UIScreen mainScreen].scale);
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextSetStrokeColorWithColor(context, color.CGColor);
        CGContextSetFillColorWithColor(context, color.CGColor);
        //    起始點
        CGContextMoveToPoint(context, 0, viewHeight * .5);
        CGContextAddArcToPoint(context, 0, 0, viewWidth * .5, 0, cornerRadius.topLeftCornerRadius);
        CGContextAddArcToPoint(context, viewWidth, 0, viewWidth , viewHeight * .5, cornerRadius.topRightCornerRadius);
        CGContextAddArcToPoint(context, viewWidth, viewHeight, viewWidth * .5, viewHeight, cornerRadius.bottomRightCornerRadius);
        CGContextAddArcToPoint(context, 0, viewHeight, 0, viewHeight * .5, cornerRadius.bottomLeftCornerRadius);
        CGContextClosePath(context);
        CGContextDrawPath(context, kCGPathFillStroke);
        UIImage *currentImage =  UIGraphicsGetImageFromCurrentImageContext();
        if (normalImage) {
            currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:normalImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
            [((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateNormal];
        }
        if (highlightedImage) {
            currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:highlightedImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
            [((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateHighlighted];
        }
        if (disableImage) {
            currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:disableImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
            [((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateDisabled];
        }
        if (selectedImage) {
            currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:selectedImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
            [((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateSelected];
        }
    }
}

- (void)ay_setCornerRadius:(AYRadius)cornerRadius backgroundImage:(UIImage *)image backgroundColor:(UIColor *)color {

    [self ay_setCornerRadius:cornerRadius backgroundImage:image backgroundColor:color withContentMode:UIViewContentModeScaleToFill];
}

- (void)ay_setCornerRadius:(AYRadius)cornerRadius backgroundColor:(UIColor *)color {

    [self ay_setCornerRadius:cornerRadius backgroundImage:nil backgroundColor:color withContentMode:UIViewContentModeScaleToFill];
}

- (void)ay_setCornerRadius:(AYRadius)cornerRadius backgroundImage:(UIImage *)image backgroundColor:(UIColor *)color withContentMode:(UIViewContentMode)contentMode {

    CGFloat viewWidth = self.frame.size.width;
    CGFloat viewHeight = self.frame.size.height;
    UIGraphicsBeginImageContextWithOptions(self.frame.size, NO, [UIScreen mainScreen].scale);
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetFillColorWithColor(context, color.CGColor);
//    起始點
    CGContextMoveToPoint(context, 0, viewHeight * .5);
    CGContextAddArcToPoint(context, 0, 0, viewWidth * .5, 0, cornerRadius.topLeftCornerRadius);
    CGContextAddArcToPoint(context, viewWidth, 0, viewWidth , viewHeight * .5, cornerRadius.topRightCornerRadius);
    CGContextAddArcToPoint(context, viewWidth, viewHeight, viewWidth * .5, viewHeight, cornerRadius.bottomRightCornerRadius);
    CGContextAddArcToPoint(context, 0, viewHeight, 0, viewHeight * .5, cornerRadius.bottomLeftCornerRadius);
    CGContextClosePath(context);
    CGContextDrawPath(context, kCGPathFill);
    UIImage *currentImage =  UIGraphicsGetImageFromCurrentImageContext();
    if ([self isMemberOfClass:[UIView class]]) {
        self.layer.contents = (__bridge id _Nullable)(currentImage.CGImage);
    } else if([self isMemberOfClass:[UIImageView class]]) {
        currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:image backgroundColor:color withDrawRect:self.bounds setContentMode:contentMode];
        ((UIImageView *)self).image = currentImage;
    } else if ([self isMemberOfClass:[UIButton class]]) {
        currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:image backgroundColor:color withDrawRect:self.bounds setContentMode:contentMode];
        [((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateNormal];
    } else if([self isMemberOfClass:[UILabel class]]) {
        ((UILabel *)self).layer.backgroundColor = [UIColor colorWithPatternImage:currentImage].CGColor;
    }
    UIGraphicsEndImageContext();
}

主要看ay_setCornerRadius: backgroundImage: backgroundColor: withContentMode:這個函數。

void UIGraphicsBeginImageContextWithOptions ( CGSize size, BOOL opaque, CGFloat scale )
創建一個基于位圖的圖形上下文,第二個參數設置不透明。
void CGContextAddArcToPoint ( CGContextRef c, CGFloat x1, CGFloat y1, CGFloat x2, CGFloat y2, CGFloat radius )
畫弧,從 (x1,y1) 到 (x2,y2),弧線半徑是radius。

如果是UIImageView 或UIButton將會進入 ay_clipImageWithCornerRadius setImage: backgroundColor: withDrawRect: setContentMode:(UIViewContentMode):

+ (UIImage *)ay_clipImageWithCornerRadius:(AYRadius)cornerRadius setImage:(UIImage *)image backgroundColor:(UIColor *)color withDrawRect:(CGRect)rect setContentMode:(UIViewContentMode)contentMode {
    if (image) {
        image = [image scaleImageWithContentMode:UIViewContentModeScaleToFill containerRect:rect];
        color = [UIColor colorWithPatternImage:image];
    }
    CGFloat imageWidth = rect.size.width;
    CGFloat imageHeight = rect.size.height;
    UIGraphicsBeginImageContextWithOptions(rect.size, NO, [UIScreen mainScreen].scale);
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetFillColorWithColor(context, color.CGColor);
    CGContextMoveToPoint(context, 0, imageHeight * .5);
    CGContextAddArcToPoint(context, 0, 0, imageWidth * .5, 0, cornerRadius.topLeftCornerRadius);
    CGContextAddArcToPoint(context, imageWidth, 0, imageWidth , imageHeight * .5, cornerRadius.topRightCornerRadius);
    CGContextAddArcToPoint(context, imageWidth, imageHeight, imageWidth * .5, imageHeight, cornerRadius.bottomRightCornerRadius);
    CGContextAddArcToPoint(context, 0, imageHeight, 0, imageHeight * .5, cornerRadius.bottomLeftCornerRadius);
    CGContextClosePath(context);
    CGContextDrawPath(context, kCGPathFill);
    UIImage *outputImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return outputImage;
}

使用時只需設置一行代碼,[testLabel ay_setCornerRadius:AYRadiusMake(10, 10, 10, 10) backgroundColor:[UIColor lightGrayColor]]; 注意:只能在這個方法里設置backgroundColor。

經過實測,還是在上邊的測試項目中,使用這種方法可以輕松保持滑動時幀率在55以上。

二、自動布局###

在這次的項目中使用到的自動布局是知名布局框架Masonry,自動布局肯定意味著更多的計算,但是經過實測才發現,使用Masonry和直接指定位置相比,計算量的差異竟然如此驚人。

手動設置
自動布局

使用Masonry將會多出幾十倍的CPU使用。為了看對幀率的影響,這里特別讓所有cell都不復用,最終看到使用自動布局和直接指定位置相對比,平均幀率分別是52和57,可見如果想要更多的追求界面的流暢度,應當盡量不去使用自動布局。

三、高度計算###

固定高度#

對于UITableViewCell我們經常需要給它指定高度,對于固定高度的cell一般都很好處理,可以使用以下兩種方法進行指定:

self.tableView.rowHeight = 100;

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return 100
}

相比第二種,第一種方法也會更直接和有效率。

可變高度#
estimatedRowHeight#

iOS7以后出現了estimatedRowHeight這個屬性,可以給一個整體估算值來大概指定cell的高度,設置好后根據“cell.estimatedRowHeight * cell個數”來計算contentSize.height。但是這個屬性也有很多它的問題:

  1. 由于是估算的高度,這就導致滾動條的大小處于不穩定的狀態,contentSize 會隨著滾動從估算高度慢慢替換成真實高度,肉眼可見滾動條突然變化甚至“跳躍”。
  2. 若是有設計不好的下拉刷新或上拉加載控件,或是 KVO 了 contentSize 或 contentOffset 屬性,有可能使表格滑動時跳動。
  3. 滑動時會實時計算高度,因此會帶來界面上的卡頓。
AutoLayout#

AutoLayout當然可以實現更精準的計算,但是一方面是要保證使用者對約束設置的比較熟練,另一方面也考慮到計算效率的問題,因此也不是最優方案。

self-sizing cell#

iOS8 WWDC 中推出了 self-sizing cell 的概念,可以讓 cell 自己負責自己的高度計算,代碼如下:

self.tableView.estimatedRowHeight = 213;
self.tableView.rowHeight = UITableViewAutomaticDimension;

方便是方便了,可是除非你的APP只需要支持iOS8以上,否則針對iOS8以前的系統還是要使用其他方式來計算高度。

UITableView+FDTemplateLayoutCell#

為了解決以上的問題,這里推薦一個解決算高問題的最佳方案UITableView+FDTemplateLayoutCell,地址是FDTemplateLayoutCell,使用起來如下:

#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];
    }];
}

FDTemplateLayoutCell的大致原理就是在空閑時刻執行預緩存,以保證加載速度和滑動流暢性,具體用到的有以下幾方面:

  • 和每個 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,整個緩存過程完全沒有感知,這使得完整列表的高度計算既沒有發生在加載時,又沒有發生在滑動時,同時保證了加載速度和滑動流暢性,下文會著重講下這塊的實現原理。
空閑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 版本的注冊函數可以讓代碼更簡潔:

CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFStringRef runLoopMode = kCFRunLoopDefaultMode;
CFRunLoopObserverRef observer =     CFRunLoopObserverCreateWithHandler
(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0,     ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
    // TODO here
});
CFRunLoopAddObserver(runLoop, observer, runLoopMode);

在其中的 TODO 位置,就可以開始任務的收集和分發了,當然,不能忘記適時的移除這個 observer

分解成多個RunLoop Source任務

假設列表有 20 個 cell,加載后展示了前 5 個,那么開啟估算后 table view 只計算了這 5 個的高度,此時剩下 15 個就是“預緩存”的任務,而我們并不希望這 15 個計算任務在同一個 RunLoop 迭代中同步執行,這樣會卡頓 UI,所以應該把它們分別分解到 15 個 RunLoop 迭代中執行,這時就需要手動向 RunLoop 中添加 Source 任務(由應用發起和處理的是 Source 0 任務)
Foundation 層沒對 RunLoopSource 提供直接構建的 API,但是提供了一個間接的、既熟悉又陌生的 API:

- (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 回調時都把第一個任務拿出來分發:

NSMutableArray *mutableIndexPathsToBePrecached = self.fd_allIndexPathsToBePrecached.mutableCopy;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
if (mutableIndexPathsToBePrecached.count == 0) {
    CFRunLoopRemoveObserver(runLoop, observer, runLoopMode);
    CFRelease(observer); // 注意釋放,否則會造成內存泄露
    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,所有的“預緩存”任務的分發和執行都會自動暫定,最大程度保證滑動流暢。

PS: 預緩存功能因為下拉刷新的沖突和不明顯的收益已經廢棄

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容