從自適應單元格高度說起-淺談如何提高UITableView的載入效率

Fabric發表於2018-03-01

大家基本上都做過這樣的需求:在UITableView上展示文字,且文字內容長短不一,每一行單元格都要動態計算高度,使得單元格可以剛好容納下需要展示的文字。為了方便講解,我們把文字框設定成一個距離cell上下左右均有20px間距的UILabel,需要單元格動態調整高度,使得文字框剛好可以展示出所有的文字內容。

實現方案

需求本身並不是非常複雜,實現這個需求基本上可以採用兩種方法:

1、程式碼動態計算高度

2、利用iOS8中UITableView的estimatedRowHeight新特性通過約束計算高度

我們先來看一下兩種方案的實現方式:

程式碼動態計算高度

在UITableViewCell的自定義類中增加一個計算cell高度的類方法,具體程式碼如下:

+ (CGFloat)calculateTitleWidth:(NSString *)title{
    
    CGFloat stringWidth = 0;
    CGSize size = CGSizeMake(kRBScreenWidth - 20.0f*2, MAXFLOAT);
    
    if (title.length > 0) {
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 70000
        stringWidth = [title
                      boundingRectWithSize:size
                      options:NSStringDrawingUsesLineFragmentOrigin
                      attributes:@{NSFontAttributeName:kRBTextFont}
                      context:nil].size.height;
#else
        //iOS7.0以下方法
        stringWidth = [title sizeWithFont:kRBTextFont
                            constrainedToSize:size
                                lineBreakMode:NSLineBreakByCharWrapping].height;
#endif
    }
    return stringWidth;
}
複製程式碼

當我們通過- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath方法得到對應的cell之後,呼叫cell的- (void)buildData:(NSString *)title方法,填充文字,設定文字框高度:

- (void)buildData:(NSString *)title{
    
    self.titleLabel.text = title;
    self.titleLabel.frame = CGRectMake(20.0f, 20.0f, kRBScreenWidth - 20.0f*2, [RBAutoSizeTableViewCell calculateTitleWidth:title]);
}
複製程式碼

重寫UITableViewDataSource的protocol方法,動態計算每一行的高度:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
    
    return [RBAutoSizeTableViewCell calculateTitleWidth:self.titles[indexPath.row]] + 20.0*2;
}
複製程式碼

利用自動佈局和約束計算高度結合estimatedRowHeight特性計算高度

先將titleLabel利用約束固定在cell上:

[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
        make.top.left.mas_equalTo(self.contentView).with.mas_offset(20.0f);
        make.bottom.right.mas_equalTo(self.contentView).with.mas_offset(-20.0f);
}];
複製程式碼

再將UITableView設定為預估高度的模式:

self.estimatedRowHeight = 300.0f;  //設定近似值
self.rowHeight = UITableViewAutomaticDimension;
複製程式碼

只需要兩行程式碼,我們就完成了動態高度的估算工作,非常的簡潔明瞭。

這裡我用了Xib載入cell和程式碼構建cell兩種方式生成cell:

//程式碼建立cell
if(!autoSizeTableViewCell){
        autoSizeTableViewCell = [[RBAutoSizeTableViewCell1 alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellID];
}

//nib建立cell
if(!autoSizeTableViewCell){
        autoSizeTableViewCell = [[NSBundle mainBundle] loadNibNamed:NSStringFromClass([RBAutoSizeTableViewCell2 class]) owner:self options:nil].lastObject;
}
複製程式碼

儘管很多同學都用過Xib檔案,但是對於其中的原理不甚熟悉,Xib其實就是一個XML檔案,在專案執行時會被編譯成二進位制檔案即nib檔案,Fabric將會在下文中分析Xib的執行效率。

注意:千萬不要再次重寫- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath方法,否則UITableView將不會預估高度

載入效率對比

當我接到一個需求的時候,其實腦子裡面閃現過許多實現需求的方法,到底用哪一種方法,取決於很多因素:程式碼複雜度,可擴充套件性,穩定性,程式碼執行效率等等。

今天Fabric主要從效能方面來分析兩種實現方式的優劣,下面是一張三種方式動態計算高度(我們把Xib+約束動態計算單元格高度當作第三種自適應方法),載入UITableView所需時間的柱狀圖:

自適應高度耗時柱狀圖
當然,耗時的多少還和文字的大小有關係,Fabric為了凸顯3種方法的效率差別故意把文字內容設定的很長。

正如大家看到的,程式碼動態計算高度的耗時要遠遠地高於後兩者,效率非常低下,當我們把cell總數設定為1000,甚至10000的時候,可以很明顯的感受到載入緩慢,嚴重的傷害了使用者體驗。

效能差別分析

大家可能會驚訝,短短几行程式碼,為什麼耗時的差距可以高達上萬倍呢?!

原因在於:當使用程式碼動態計算高度時,UITableView會首先執行一遍

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
    
    return [RBAutoSizeTableViewCell calculateTitleWidth:self.titles[indexPath.row]] + 20.0*2;
}
複製程式碼

方法,當有1000個cell的時候,UITableview就會首先執行1000次計算高度的方法,然後再去執行- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath獲取cell,獲取cell之後,又會執行一次- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath方法,來獲取當前cell的高度。這樣一來,肯定要耗費非常長的時間。

反觀第二種方法,UITableView只會預載入一個UITableView contentSize的內容,也就是說,無論有多少cell,UITableView會先載入一屏內容,再預計算第二屏的高度,不會有更多的計算操作。這種預載入邏輯,保障了UITableView既不會卡頓,也不會消耗更多的資源。

另外看一下Xib+約束的執行效率,並不比純程式碼要低,可能有讀者會有疑問:

  • 1、UITableView上一次性建立的Xib檔案不多所以看不出效能差別。

  • 2、Xib檔案上只有一個UILabel,太簡單了,所以看不出Xib檔案的耗時。

所以Fabric把行高設定成5px,讓UITableView一次性多生成一些cell;儘量多拖拽一些控制元件到Xib上,增加Xib檔案的複雜度,執行結果顯示: 純程式碼構建cell和用Xib獲取cell沒有明顯的效能區別。因此,Xib檔案的執行效率是很高的,並不像我起先設想的那樣,讀取XML檔案會很耗時。


總結

通過動態載入單元格的效能實驗,我們知道了UITableView載入緩慢的原因:重複執行了大量的耗時操作,因此Fabric總結了以下幾點提高UITableView載入效率的方法:

  • 1、不要在UITableViewDataSource的代理方法中加入過多的耗時方法,比如說計算寬高或者載入資料。
  • 2、儘量複用自定義的UITableViewCell,而不是定義非常多個UITableViewCell,畢竟從快取池裡獲取cell要比重新建立cell要來的快。
  • 3、對於需要反覆使用的資料建議加入快取,比如說我們要重複獲取一張名字為"Fabric"的圖片,那麼我們可以用如下程式碼:
- (UIImage *)getCellImage:(NSString *)imageName{
   
   if(!imageName) return nil;
   UIImage *img = [self.imageDict objectForKey:imageName];
   if(!img){
       img = [UIImage imageNamed:imageName];
       [self.imageDict setValue:img forKey:imageName];
   }
   return img;
}
複製程式碼

當然,無論是第三方SDWebImage還是系統方法+ (nullable UIImage *)imageNamed:(NSString *)name,都已經幫我們將圖片儲存在磁碟上了,不需要我們再次去做快取了,Fabric只是用圖片快取舉個例子而已。

  • 4、儘量不要在- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath方法中,獲取到cell之後再去addSubView,如果這樣做的話,cell每一次出現在使用者介面上就add一次subView,那麼使用者來回滑動幾次UITableView,就會發現介面卡頓,滑動明顯變慢,甚至滑不動了。
  • 5、多用hidden屬性去隱藏對使用者不可見的控制元件,而不是通過設定alpha為0,或者設定控制元件寬高為0的方式來隱藏控制元件,因為當控制元件的hidden屬性為YES的時候,系統會自動優化控制元件記憶體,減少裝置的資源消耗。

後續 - 提高方法一(程式碼計算高度)的UITableView的載入效率

首先,感謝兩位讀者Asuray和ControlM給Fabric的寶貴留言。他們一針見血的指出了程式碼計算高度自適應UITableView效率低下的原因:在UITableViewDataSource的代理方法中,執行了過多的冗餘的計算UILabel高度的操作

Fabric的方法一是一個不恰當的載入UITableView的思路,旨在讓讀者看到UITableView載入效率低下的原因。下面我們來設想一下如何優化,結合上文總結的五點提高UITableView載入效率的方法,Fabric想出了以下三點改進方法:

  • 1.加入快取機制,即把title的內容寫入Model,在Model中計算出UILabel的高度,避免UITableView每次獲取高度都要計算一遍高度,也避免了在獲取到Cell的時候計算UILabel高度,程式碼如下:
- (void)convertDataToModel{
    
    [self.titles enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        RBTitleModel *titleModel = [[RBTitleModel alloc] init];
        titleModel.title = obj;
        titleModel.titleLabelHeight = 0.0f;
        [self.titles replaceObjectAtIndex:idx withObject:titleModel];
    }];
}
複製程式碼
  • 2.在- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath方法中不再執行計算UILabel高度的方法,而是給出一個預設的高度,程式碼如下:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
    
    RBTitleModel *model = [self.titles objectAtIndex:indexPath.row];
    return model.titleLabelHeight + 20.0*2;
}
複製程式碼
  • 3.單純的把文字高度計算放入Model中效率還是極其低下的,因為在reloadData之前,需要執行所有的Model的計算高度程式碼,假設資料來源是有1000000個元素的陣列,那麼在轉換model之前,就需要計算1000000次高度。所以最好的方法是在- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath方法中計算UILabel的高度,程式碼如下:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
    
    static NSString *autoSizeTableViewCellID = @"RBAutoSizeTableViewCell";
    RBAutoSizeTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:autoSizeTableViewCellID];
    if(!cell){
        cell = [[RBAutoSizeTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:autoSizeTableViewCellID];
    }
    //動態計算當前cell的高度
    RBTitleModel *titleModel = [self.titles objectAtIndex:indexPath.row];
    [titleModel calculateTitleWidth];
    
    [cell buildData:self.titles[indexPath.row]];
    return cell;
}
複製程式碼

在計算高度時,Fabric採用了快取機制,如果titleModel.titleHeight的數值不為0,說明已經計算過高度不需要重複計算,程式碼如下:

- (void)calculateTitleWidth{
    //有快取則不需要重複計算
    if(self.titleLabelHeight > 0) return;
    
    CGFloat stringWidth = 0;
    CGSize size = CGSizeMake(kRBScreenWidth - 20.0f*2, MAXFLOAT);
    
    if (self.title.length > 0) {
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 70000
        stringWidth = [self.title
                       boundingRectWithSize:size
                       options:NSStringDrawingUsesLineFragmentOrigin
                       attributes:@{NSFontAttributeName:kRBTextFont}
                       context:nil].size.height;
#else
        //iOS7.0以下方法
        stringWidth = [self.title sizeWithFont:kRBTextFont
                        constrainedToSize:size
                            lineBreakMode:NSLineBreakByCharWrapping].height;
#endif
    }
    self.titleLabelHeight = stringWidth;
}
複製程式碼

經過改進之後,UITableView的執行效率明顯變高了,下圖是改進之後的兩種方式的UITableView載入耗時的柱狀圖:

從自適應單元格高度說起-淺談如何提高UITableView的載入效率
雖然程式碼計算高度的效率還是最低的,但是相比之前要好了很多。感興趣的同學可以去我的GitHub上下載Demo,閱讀原始碼,也可以自己動手實現一下。大家有更好的優化UITableView載入效率的方法,也可以直接在Demo中修改,然後push給Fabric,大家共同進步,一起提高技術水平。

Fabric能想到的優化UITableView載入效率的方法就只有以上這麼多了,歡迎大家在文章下方留言一起探討,也可以加我的微信justlikeitRobert和我討論,喜歡這篇文章請點贊,謝謝大家的關注與支援。

相關文章