在许多关于 UITableview 性能优化的文章里都提到了缓存行高的优化方式,这也是苹果工程师提出的改进建议.

正常情况下,heigtForRowAtIndexPath: 方法会被调用很多次,在 UITableview 滚动的过程中也会不断的调用,这时如果我们只计算一次 Cell的高度,之后每次调用时都返回缓存的高度,就能让 UITableview 的滑动更加流畅,尤其是对高度计算特别耗时的复杂的 Cell 来说.

这篇文章中,我们来打造一个极简的行高缓存工具类 ModelSizeCache

这样命名是有原因的,我们来慢慢分析.

基本思路

下图是我为了辅助说明缓存行高而制作的 Demo, 源码在 Github, 建议结合源码来看下面的博文

demo.gif
cell 主要有3个控件
UIImageView *demoImageView;
UILabel *demoLabel;
UIStepper *demoStepper;
每一个展示一个 Joke Model ,模型类主要有4个属性,
NSString *objectID;
NSString *content;
NSString *imageName;
NSInteger repeatCount; //文字内容重复次数,模拟 Model 中数据变化,重新计算高度的情况
ModelSizeCache 使用
相比没有缓存高度的情况,只需修改一个方法:
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
//先从缓存根据 Model 的 hash 值取缓存的行高,如果没有就调用后面的 orCalc Block计算行高,将计算结果存入缓存,然后返回行高.
return [self.modelSizeCache getHeightForModel:self.jokes[indexPath.row] withTableView:tableView orCalc:^CGFloat(id model, UITableView *tableView) {
return [self.prototypeCell calcCellHeightWithJoke:self.jokes[indexPath.row] tableView:tableView];
}];
}

基本思路

首先我们要计算一次 Cell 的高度,之后每次都返回缓存的高度

我们的 Cell 的高度根据 Model, 这里是 Joke 模型类来计算的,所以我们缓存的高度应该说是填充完 Model 数据后 Cell的高度

如果 Model 的内容变化了,比如上图中的文字长度变化了,就要重新计算行高,并缓存起来.

由上面的说明我们得出以下结论:

Cell 来计算高度最合适, Cell 知道自己的 View 是怎样布局的,然后在传入 Model ,就能计算出行高,所以我们在 Cell 中添加

-(CGFloat)calcCellHeightWithJoke:(Joke *)joke tableView:(UITableView *)tableView 方法来计算行高.

Cell 的行高根据它填充的数据模型 Model 而计算出来的,所以我们要根据 Model 来缓存行高.

第一点: 计算 cell 高度

本 Demo 为了简洁使用 AutoLayout + Storyboard布局,使用

[self.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];

来根据 Cell 的约束来计算 Cell 高度

如果你使用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文字高度,在加上图片的高度等的方式得出 Cell 高度,那么这个 Cell 高度计算过程可以在从网络加载完 JSON 数据就在后台执行,并将计算结果缓存起来,在 UITableview请求 cell 高度时,直接返回缓存的高度就好了,这样就避免了在主线程计算 Cell 高度,达到了UITableview滑动优化目的.

但由于我使用 [self.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize]; 方法来计算 Cell 高度,需要访问 View, 所以不能在后台先执行计算,就将计算过程放在UITableview 的heightForRowAtIndexPath方法,第一次请求该 Model 对应的 Cell 行高时完成.

前文:在后台计算 Model 对应的行高思路来自于

YYKit 作者,ibireme iOS 保持界面流畅的技巧一文

预排版:

当获取到 API JSON 数据后,我会把每条 Cell 需要的数据都在后台线程计算并封装为一个布局对象 CellLayout。CellLayout 包含所有文本的 CoreText 排版结果、Cell 内部每个控件的高度、Cell 的整体高度。每个 CellLayout 的内存占用并不多,所以当生成后,可以全部缓存到内存,以供稍后使用。这样,TableView 在请求各个高度函数时,不会消耗任何多余计算量;当把 CellLayout 设置到 Cell 内部时,Cell 内部也不用再计算布局了

对于通常的 TableView 来说,提前在后台计算好布局结果是非常重要的一个性能优化点。为了达到最高性能,你可能需要牺牲一些开发速度,不要用 Autolayout 等技术,少用 UILabel 等文本控件。但如果你对性能的要求并不那么高,可以尝试用 TableView 的预估高度的功能,并把每个 Cell 高度缓存下来。这里有个来自百度知道团队的开源项目可以很方便的帮你实现这一点:FDTemplateLayoutCell。

第二点:缓存 Model 高度

我们将计算好的 Model 高度存入 NSCache 类中,这个集合类很像 NSMutableDictionary,主要有下面2个方法

- (nullable ObjectType)objectForKey:(KeyType)key;
- (void)setObject:(ObjectType)obj forKey:(KeyType)key;

将高度存入 NSCache中需要一个 Key, 一般我们的模型类,比如一条微博,一句聊天信息,都有一个唯一 ID, 我们可以使用它作为 Key

但如果模型类中的数据变化了,比如上面 Demo Gif 中的Joke模型类的文字长度变化了,就要让这个缓存的高度失效,根据 Model 的数据重新计算行高.

ModelSizeCache 具体实现

ModelSizeCache 定义了一个 protocol
@protocol ModelSizeCacheProtocol 
-(NSString*)modelID;
@end

任何需要缓存高度的模型类都应该遵守这个协议,返回 Model 的唯一 ID,这个 ID 作为在 NSCache 中存取缓存行高的 Key.

ModelSizeCache 继承自 NSObject, 有2个属性
@property (strong,nonatomic) NSCache *cacheLandscape;
@property (strong,nonatomic) NSCache *cachePortrait;
分别缓存 Model 在横竖屏状态下的 Cell 高度,
主要的方法只有一个
-(CGFloat)getHeightForModel:(id)model withTableView:(UITableView *)tableView orCalc:(CalcModelHeightBlock)block{
//先从缓存中取行高
CGSize modelSize= [self getCacheSizeForModel:model];
//没有就计算一下
if( CGSizeEqualToSize(modelSize, NilCacheSize)){
modelSize.height= block(model,tableView);
//计算完成存入缓存中
[self setOrientationSize:modelSize forModel:model];
NSLog(@"计算行高 :%@",@(modelSize.height));
}
return modelSize.height;
}
其中 const CGSize NilCacheSize ={-1,-1};
然后使用时修改一个方法即可
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
//先从缓存根据 Model 的 hash 值取缓存的行高,如果没有就调用后面的 orCalc Block计算行高,将计算结果存入缓存,然后返回行高.
return [self.modelSizeCache getHeightForModel:self.jokes[indexPath.row] withTableView:tableView orCalc:^CGFloat(id model, UITableView *tableView) {
return [self.prototypeCell calcCellHeightWithJoke:self.jokes[indexPath.row] tableView:tableView];
}];
}

最后在我们点击 UIStepper 时,更改模型类的数据,并让缓存的高度失效即可,这样会重新计算这个 Model 的高度,并存入缓存,其它的 Model 直接读取缓存,因为他们的数据没有变化,Cell 的高度也就没有变化.

-(void)cell:(Cell *)cell didStepperValueChanged:(NSInteger)value{
NSIndexPath *indexPath= [self.tableView indexPathForCell:cell];
Joke *joke= self.jokes[indexPath.row];
joke.repeatCount=value; //修改 Model 的数据
[self.modelSizeCache invalidateCacheForModel:joke]; //让缓存失效
[self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
}

这样就达到了缓存行高,优化UITableview 滑动性能的作用.

完整代码在 Github

关于用 ModelSizeCache存储行高:

其实也可以用 Category + Associated Objects 为模型类添加@property CGFloat height 属性来存储模型的高度,但是我觉得存储在一个单独的 ModelSizeCache中更合适,不污染模型类的代码,方便集中管理缓存数据.

Ref