配合Masonry實現TableViewCell的高度自適應,以及更易管理的高度快取

weixin_34124651發表於2017-01-11

前言

對於TableViewCell的高度自適應,很多初次接觸的同學,還是很頭痛的。就算已經有些開發經驗的同學,處理起來也可能用錯了方法。但其實系統已經提供了很方便的處理方法,我們這裡就係統的高度計算做一個講解。然後主要要講的,是我在實際開發中(我們App加入了直播功能,直播中要處理大量的聊天訊息)用到的方法,也是在效能上優化了很多的方法,將計算好的高度快取下來,在大量資料(幾百、幾千條資料)進行重新整理、插入資料、刪除資料等操作的時候也能保證效能、流暢性,而相比於其他高度快取方案,這種方式的高度快取,更方便管理。以下高度都結合Masonry來完成(畢竟手寫Autolayout還是Masonry比較方便),使用XIB的同學,也可以直接拖約束。

場景模擬

我們寫個Demo,來模擬下直播聊天室中情況,眾所周知,直播聊天室中的訊息量是巨大的,而且重新整理特別快,在重新整理聊天列表的時候,最耗費效能的就是UITableView的兩個代理方法,一個heightForRowAtIndexPath,一個cellForRowAtIndexPath。無論是重新整理還是新增、刪除,都會反覆觸發這兩個方法,而對於聊天室,如果從後面追加資料,假設你原來有1000條資料,即使你從後面insert一個cell,那也會呼叫1000次HeightForRow,如果你在計算高度的時候,使用了很複雜的計算方式,就很影響效能了。
  首先新建個專案,然後在專案中加入Masonry,再然後加入一個顯示當前螢幕FPS的label進來,提取自YYKit,YYFPSLabel。這樣就能大致瞭解效能如何了。然後我們在ViewController.m中加入這個控制元件:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    YYFPSLabel *fpsLabel = [[YYFPSLabel alloc] initWithFrame:CGRectMake(0, 20, 60, 20)];
    [self.view addSubview:fpsLabel];
}

執行後我們的Demo頂部就會顯示FPS了:


1682338-f0cbc0aa40a0e6ec.png
Paste_Image.png

  然後我們先建一個Model,和一個Cell,Model代表我們從伺服器請求的資料模型,Cell就是我們要用到的展示內容的Cell。為了讓Cell更符合實際專案的需求,我們讓cell顯示多一些的內容,來一個拼接的屬性字串吧。
  新建個Model:


1682338-81f5b4b9560e7055.png
Paste_Image.png

  模擬聊天中的訊息展示,我們給Model兩個屬性,一個姓名,一個發言內容:
// 姓名
@property (nonatomic, copy) NSString *name;
// 發言內容
@property (nonatomic, copy) NSString *message;

我們再新建一個Cell,在Cell中將內容展示出來:


1682338-3751641855464fb9.png
Paste_Image.png

  我們的Cell中只有一個Label,用於展示“姓名:發言內容”這樣的內容,注意這裡佈局,採用自動佈局,Cell的ContentView由Label中的內容撐開:

@interface MessageCell ()

@property (nonatomic, strong) UILabel *messsageLabel;

@end

@implementation MessageCell

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    if (self == [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
        // 建立UI
        [self createUI];
    }
    
    return self;
}

- (void)createUI {
    /** 發言 */
    self.messsageLabel = [[UILabel alloc] init];
    self.messsageLabel.numberOfLines = 0;
    [self.contentView addSubview:self.messsageLabel];
    [self.messsageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
        make.top.mas_equalTo(8);
        make.left.mas_equalTo(10);
        make.right.mas_equalTo(-10);
        make.bottom.mas_equalTo(-8);
    }];
}

- (void)setMessage:(CellModel *)message {
    // 建立一個可變屬性字串
    NSMutableAttributedString *finalStr = [[NSMutableAttributedString alloc] init];
    
    // 建立姓名
    NSAttributedString *nameStr = [[NSAttributedString alloc] initWithString:message.name attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:16], NSForegroundColorAttributeName: [UIColor redColor]}];
    
    // 建立發言內容
    NSAttributedString *messageStr = [[NSAttributedString alloc] initWithString:message.message attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:16], NSForegroundColorAttributeName: [UIColor blackColor]}];
    
    // 拼接上兩個字串
    [finalStr appendAttributedString:nameStr];
    [finalStr appendAttributedString:messageStr];
    self.messsageLabel.attributedText = finalStr;
}
@end

這裡我們需要注意的是,Label要高度自適應的撐開Cell的ContentView的高度。然後我們去ViewController中新增一個用於展示這些內容的TableView,在viewDidLoad方法的結尾,我們新增一個按鈕,該按鈕模擬聊天室中接收到了新訊息,並滾動到TableView的最底部。具體程式碼如下:

@interface ViewController () <UITableViewDelegate, UITableViewDataSource>

@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) NSMutableArray *dataArr;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    YYFPSLabel *fpsLabel = [[YYFPSLabel alloc] initWithFrame:CGRectMake(0, 20, 60, 20)];
    [self.view addSubview:fpsLabel];
    
    // 建立TableView
    self.tableView = [[UITableView alloc] initWithFrame:CGRectMake(0, 100, self.view.frame.size.width, self.view.frame.size.height-100) style:0];
    self.tableView.dataSource = self;
    self.tableView.delegate = self;
    [self.view addSubview:self.tableView];
    // 註冊cell
    [self.tableView registerClass:[MessageCell class] forCellReuseIdentifier:@"MessageCell"];
    
    // 模擬一些資料來源
    NSArray *nameArr = @[@"張三:",
                         @"李四:",
                         @"王五:",
                         @"陳六:",
                         @"吳老二:"];
    NSArray *messageArr = @[@"ash快點回家愛是妒忌哈市黨和國家按時到崗哈時代光華撒國會大廈國會大廈國會大廈更好的噶山東黃金撒旦哈安師大噶是個混蛋撒",
                            @"傲世江湖點撒恭候大駕水草瑪瑙現在才明白你個壞蛋擦邊沙塵暴你先走吧出現在",
                            @"撒點花噶閃光燈",
                            @"按時間大公司大概好久撒大概好久撒黨和國家按時到崗哈師大就薩達資料庫化打算幾點撒謊就看電視驕傲的撒金葵花打暑假工大撒比的撒謊講大話手機巴士差距啊市場報價啊山東黃金as擦傷擦啊as擦肩時擦市場報價按時VC阿擦把持啊三重才撒啊雙層巴士吃按時吃啊雙層巴士擦報啥錯",
                            @"as大帥哥大孤山街道安師大好噶時間過得撒黃金國度"];
    // 向資料來源中隨機放入500個Model
    self.dataArr = [[NSMutableArray alloc] init];
    for (int i=0; i<500; i++) {
        CellModel *model = [[CellModel alloc] init];
        model.name = nameArr[arc4random()%nameArr.count];
        model.message = messageArr[arc4random()%messageArr.count];
        [self.dataArr addObject:model];
    }
    
    // 我們再建立一個按鈕,點選可從後面追加一些資料進來
    UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(0, 40, 100, 60)];
    button.backgroundColor = [UIColor redColor];
    [self.view addSubview:button];
    [button addTarget:self action:@selector(addData) forControlEvents:UIControlEventTouchUpInside];
}

- (void)addData {
    // 新增一個Model,在追加到Tableview中
    CellModel *model = [[CellModel alloc] init];
    model.name = @"皮皮:";
    model.message = @"安師大公司的嘎斯大時代安師大嘎斯高大上撒旦嘎嘎就是打閃光燈";
    [self.dataArr addObject:model];
    
    // 插入到tableView中
    [self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:self.dataArr.count-1 inSection:0]] withRowAnimation:UITableViewRowAnimationNone];
    // 再滾動到最底部
    [self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:self.dataArr.count-1 inSection:0] atScrollPosition:UITableViewScrollPositionBottom animated:YES];
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.dataArr.count;
}

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

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    MessageCell *cell = [tableView dequeueReusableCellWithIdentifier:@"MessageCell" forIndexPath:indexPath];
    [cell setMessage:self.dataArr[indexPath.row]];
    return cell;
}

@end

效果如下,這裡我們固定Cell高度為44了,所以全程怎麼滾動,FPS都是60:


1682338-4b9f7f2f99b078b1.png
9BAB5AB9072DACA29A7084C28B42DDA9.png

動態高度一:系統自帶支援

那好了,上面的固定高度測試完了,我們來測試下適配Cell高度的方法。首先採用系統的動態高度方法。
  我們需要做兩件事:第一:指定TableView的高度為自適應:

// 必須設定預估高度才能生效
self.tableView.estimatedRowHeight = 100;
self.tableView.rowHeight = UITableViewAutomaticDimension;

第二:將TableView的行高代理方法註釋掉,也就是下面這個方法:

//- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
//    return 44;
//}

這時再執行,你會發現,Cell的高度已經自動適配,滾動中也特別流暢,保持60幀:


1682338-cbe1d280ef69ada4.png
Paste_Image.png

  但如果點選我們的紅色按鈕,就卡爆了,而且會有一個重新整理的白屏:


1682338-f2776b0b354349b7.png
Paste_Image.png

  實測系統的這個方法,只適用於iOS8及以上,且在資料量超大的時候,進行插入和刪除,都是很不流暢的,不建議採用。當然這種方法針對一些常用場景,比如新聞列表、商品列表什麼的,資料量沒那麼大且不涉及到新增、刪除資料的時候,這種方法,還是蠻不錯的,寫起來很簡便。

動態高度二:自己計算高度

我們將上面的方法撤回,試驗下自己計算Cell高度,效能如何。

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    // 建立一個可變屬性字串
    NSMutableAttributedString *finalStr = [[NSMutableAttributedString alloc] init];
    
    // 取出Model
    CellModel *message = self.dataArr[indexPath.row];
    
    // 建立姓名
    NSAttributedString *nameStr = [[NSAttributedString alloc] initWithString:message.name attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:16], NSForegroundColorAttributeName: [UIColor redColor]}];
    
    // 建立發言內容
    NSAttributedString *messageStr = [[NSAttributedString alloc] initWithString:message.message attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:16], NSForegroundColorAttributeName: [UIColor blackColor]}];
    
    // 拼接上兩個字串
    [finalStr appendAttributedString:nameStr];
    [finalStr appendAttributedString:messageStr];
    
    // 計算高度
    CGSize size = [finalStr boundingRectWithSize:CGSizeMake(self.view.frame.size.width-20, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin context:nil].size;
    return ceil(size.height);
}

這種方式,在滾動列表的時候,還是60幀流暢的,點選紅色按鈕後,會降到47幀,並持續一小段時間,所以這段時間中,你如果是在聊天室中播放彈幕,或者進行點贊動畫的處理的時候,這些內容都會卡住,直到這段時間過去,當然相比於系統的方法,效能還是稍好一點的:


1682338-d92b00a0ad0d5a3d.png
Paste_Image.png

動態高度三:Autolayout計算高度

有人可能覺得,上面計算高度太麻煩了,不就是把Cell中setMessage拿出來再寫一遍嘛,同樣的程式碼不要寫兩次,那我們換種方式來寫。這裡我們先給ViewController這個Controller加一個屬性,下面的這個Cell,承擔了計算Cell高度的工作:

@property (nonatomic, strong) MessageCell *tempCell;

在viewDidLoad中初始化:

self.tempCell = [[MessageCell alloc] initWithStyle:0 reuseIdentifier:@"MessageCell"];

然後我們給Cell加個方法,這裡需要注意的是,我們要對最終算出來的高度加1,這個1是Cell的分割線的高度,當前如果你隱藏了分割線,就不需要加這個1了:

// 根絕資料計算cell的高度
- (CGFloat)heightForModel:(CellModel *)message {
    [self setMessage:message];
    [self layoutIfNeeded];
    
    CGFloat cellHeight = [self.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height+1;
    
    return cellHeight;
}

還要指定Cell中的Label的最大寬度,保證在適配Label的時候,不會超出這個寬度:

self.messsageLabel.preferredMaxLayoutWidth = [UIScreen mainScreen].bounds.size.width-20;

最後我們來獲取Cell的高度:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return [self.tempCell heightForModel:self.dataArr[indexPath.row]];
}

執行後,跟方案二的效果一樣,甚至效能還不如方案2,這種方案的好處就是不需要計算高度,高度由系統Autolayout計算好。最後我們引入方法四,再優化一些效能。

動態高度四:快取高度

效能的損耗大部分都在heightForRowAtIndexPath這個方法上,我們有500條資料,當我們點選紅色按鈕後,會重新整理tableView,這時就會呼叫501(加上我們新插入的資料)次heightForRowAtIndexPath方法,所以每個Cell的高度都會重新算一次,這樣效能就大打折扣,那我們想辦法不讓他算唄,那就把計算好的高度快取下來吧。所以我們在Model中加入一個屬性,用於儲存Model所對應的Cell的高度。所以最後我們Model中的屬性有這幾個:

@interface CellModel : NSObject

// 姓名
@property (nonatomic, copy) NSString *name;
// 發言內容
@property (nonatomic, copy) NSString *message;
// 該Model對應的Cell高度
@property (nonatomic, assign) CGFloat cellHeight;

@end

然後我們來到TableView的Cell高度的代理方法中,如果當前Model的cellHeight為0,說明這個Cell沒有快取過高度,則計算Cell的高度,並把這個高度記錄在Model中,這樣下次再獲取這個Cell的高度,就可以直接去Model中獲取,而不用重新計算了:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    CellModel *model = self.dataArr[indexPath.row];
    if (model.cellHeight == 0) {
        CGFloat cellHeight = [self.tempCell heightForModel:self.dataArr[indexPath.row]];
        
        // 快取給model
        model.cellHeight = cellHeight;
        
        return cellHeight;
    } else {
        return model.cellHeight;
    }
}

這樣就實現了高度快取和Model、Cell都對應的優化,我們無需手動管理高度快取,在新增和刪除資料的時候,都是對Model在資料來源中進行新增或刪除。
  最後再執行,你會發現,紅色按鈕,怎麼點,都是60幀滿,偶爾會掉到59,那也只是極為短暫的一個時間,可以忽略不計,這樣,聊天室的重新整理效能,就可以完美的解決了。

以上所有測試都在iPhone6s上進行,如果其他盆友也對TableView的效能優化感興趣,希望可以告知我其他型號手機的執行效果,或者如果有更高效的處理方法,都可以聯絡我,大家互相學習、共同進步。
  最後補上Demo:https://github.com/ZhaoheMHz/UITableVIewSelfSizing

相關文章