配合Masonry实现TableViewCell的高度自适应,以及更易管理的高度缓存

前言

对于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了:


Paste_Image.png

  然后我们先建一个Model,和一个Cell,Model代表我们从服务器请求的数据模型,Cell就是我们要用到的展示内容的Cell。为了让Cell更符合实际项目的需求,我们让cell显示多一些的内容,来一个拼接的属性字符串吧。
  新建个Model:


Paste_Image.png

  模拟聊天中的消息展示,我们给Model两个属性,一个姓名,一个发言内容:
// 姓名
@property (nonatomic, copy) NSString *name;
// 发言内容
@property (nonatomic, copy) NSString *message;

我们再新建一个Cell,在Cell中将内容展示出来:


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:


9BAB5AB9072DACA29A7084C28B42DDA9.png

动态高度一:系统自带支持

那好了,上面的固定高度测试完了,我们来测试下适配Cell高度的方法。首先采用系统的动态高度方法。
  我们需要做两件事:第一:指定TableView的高度为自适应:

// 必须设置预估高度才能生效
self.tableView.estimatedRowHeight = 100;
self.tableView.rowHeight = UITableViewAutomaticDimension;

第二:将TableView的行高代理方法注释掉,也就是下面这个方法:

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

这时再运行,你会发现,Cell的高度已经自动适配,滚动中也特别流畅,保持60帧:


Paste_Image.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帧,并持续一小段时间,所以这段时间中,你如果是在聊天室中播放弹幕,或者进行点赞动画的处理的时候,这些内容都会卡住,直到这段时间过去,当然相比于系统的方法,性能还是稍好一点的:


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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容