前言
對于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了:
然后我們先建一個Model,和一個Cell,Model代表我們從服務器請求的數據模型,Cell就是我們要用到的展示內容的Cell。為了讓Cell更符合實際項目的需求,我們讓cell顯示多一些的內容,來一個拼接的屬性字符串吧。
新建個Model:
模擬聊天中的消息展示,我們給Model兩個屬性,一個姓名,一個發言內容:
// 姓名
@property (nonatomic, copy) NSString *name;
// 發言內容
@property (nonatomic, copy) NSString *message;
我們再新建一個Cell,在Cell中將內容展示出來:
我們的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:
動態高度一:系統自帶支持
那好了,上面的固定高度測試完了,我們來測試下適配Cell高度的方法。首先采用系統的動態高度方法。
我們需要做兩件事:第一:指定TableView的高度為自適應:
// 必須設置預估高度才能生效
self.tableView.estimatedRowHeight = 100;
self.tableView.rowHeight = UITableViewAutomaticDimension;
第二:將TableView的行高代理方法注釋掉,也就是下面這個方法:
//- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// return 44;
//}
這時再運行,你會發現,Cell的高度已經自動適配,滾動中也特別流暢,保持60幀:
但如果點擊我們的紅色按鈕,就卡爆了,而且會有一個刷新的白屏:
實測系統的這個方法,只適用于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幀,并持續一小段時間,所以這段時間中,你如果是在聊天室中播放彈幕,或者進行點贊動畫的處理的時候,這些內容都會卡住,直到這段時間過去,當然相比于系統的方法,性能還是稍好一點的:
動態高度三: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