UITableView 性能优化
最近在阅读 ibireme 文章时,YY 在异步绘制中提到了 VVeboTableViewDemo,该项目提供了一种滑动过程中 Cell 绘制性能提升的解决方案。本文在介绍 VVeboTableViewDemo 实现细节的同时会对现有 UITableView
已有优化方案进行介绍并分析其中优缺点。
UITableView 简介
UITableView 和其他控件相比最大的特点是:UITableViewCell 的重用机制。简而言之:UITableView 只会创建一屏(或一屏多点)的 UITableViewCell,每次显示时都是复用这些 Cell。当要显示某一位置的 Cell 时,会先去集合(或数组)中取,如果有,直接显示;如没有,创建 Cell 并放入缓存池。当 Cell 滑出屏幕时,该 Cell 就会被放入集合(或数组)中。
UITableView 在显示时会多次调用这两个方法:
- tableView:heightForRowAtIndexPath:
- tableView:cellForRowAtIndexPath:
通常情况下,我们会认为 UITableView 在显示的时候会先调用前者,再调用后者,这和我们创建控件的思路是一致的,先创建它,再设置布局。但实际使用时并非如此,UITableView 是继承自 UIScrollView,需要先确定 contentSize 及每个 Cell 的位置,然后才会把复用的 Cell 放到对应的位置。因此,UITableView 会先多次回调 - tableView:heightForRowAtIndexPath:
确定 contentSize 和 Cell 的位置,然后再调用 - tableView:cellForRowAtIndexPath:
来确定显示的 Cell。
举个例子:
现在要显示100个 Cell,一屏显示5个,那么刷新(reload)TableView 时,TableView 会先调用100次 - tableView:heightForRowAtIndexPath:
方法,然后调用5次 - tableView:cellForRowAtIndexPath:
方法;滑动屏幕时,当有新 Cell 滑入屏幕时,都会调用一次- tableView:heightForRowAtIndexPath:
、- tableView:cellForRowAtIndexPath:
方法。
UITableView 优化
上一节已对 TableView 的复用机制和核心方法进行了简要介绍,下面将基于示例来介绍现有 TableView 的优化方案。
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
ContacterTableCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ContacterTableCell"];
if (!cell) {
cell = (ContacterTableCell *)[[[NSBundle mainBundle] loadNibNamed:@"ContacterTableCell" owner:self options:nil] lastObject];
}
NSDictionary *dict = self.dataList[indexPath.row];
[cell setContentInfo:dict];
return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
return cell.frame.size.height;
}
上面代码是我们初次使用 TableView 时的常见写法,很多教程也是这么来写的。但基于上一节中的分析我们知道,在显示一屏 Cell 之前,我们需要计算全部 Cell 的高度。如果有1000行数据,就会调用1000+次 - cellForRowAtIndexPath:indexPath
,而该方法非常重,我们会在该方法中对模型赋值,设置 Cell 布局等,每次调用开销很大,滑动过程中会卡顿,急需优化。
预计算高度并缓存
例子中代码存在两个问题:
- 高度计算和 Cell 赋值耦合
- 高度未缓存
高度计算和 Cell 赋值应当分离,TableView 的两个回调方法应各司其职,不应存在依赖关系。Cell 的高度计算过后就不会变更,此时可以将其缓存,下次使用时直接读取即可。
基于上述思路,从网络获取到数据后,根据数据计算出相应的布局,并缓存到数据源中,在 - tableView:heightForRowAtIndexPath:
方法中可直接返回高度,不需要重复计算。
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *dict = self.dataList[indexPath.row];
CGRect rect = [dict[@"frame"] CGRectValue];
return rect.frame.height;
}
本方案在一般的场景下可以满足性能的要求,但是在像朋友圈图文混排需求面前,依旧会有卡顿现象出现。究其原因:本方案中所有 Cell 的绘制都放在主线程,当 Cell 非常复杂主线程绘制不及时就会出现卡顿。
异步绘制
上一个方案中所有 Cell 的绘制都在主线程中进行,如将绘制任务提交到后台线程,则主线程任务会显著减少,滑动性能会显著提升。
首先为自定义的 Cell 添加 draw
方法,在方法体中实现绘制任务:
// 异步绘制
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
CGRect rect = [_data[@"frame"] CGRectValue];
UIGraphicsBeginImageContextWithOptions(rect.size, YES, 0);
CGContextRef context = UIGraphicsGetCurrentContext();
// 整个内容的背景
[[UIColor colorWithRed:250/255.0 green:250/255.0 blue:250/255.0 alpha:1] set];
CGContextFillRect(context, rect);
// 转发内容的背景
if ([_data valueForKey:@"subData"]) {
[[UIColor colorWithRed:243/255.0 green:243/255.0 blue:243/255.0 alpha:1] set];
CGRect subFrame = [_data[@"subData"][@"frame"] CGRectValue];
CGContextFillRect(context, subFrame);
[[UIColor colorWithRed:200/255.0 green:200/255.0 blue:200/255.0 alpha:1] set];
CGContextFillRect(context, CGRectMake(0, subFrame.origin.y, rect.size.width, .5));
}
{
// 名字
float leftX = SIZE_GAP_LEFT+SIZE_AVATAR+SIZE_GAP_BIG;
float x = leftX;
float y = (SIZE_AVATAR-(SIZE_FONT_NAME+SIZE_FONT_SUBTITLE+6))/2-2+SIZE_GAP_TOP+SIZE_GAP_SMALL-5;
[_data[@"name"] drawInContext:context withPosition:CGPointMake(x, y) andFont:FontWithSize(SIZE_FONT_NAME)
andTextColor:[UIColor colorWithRed:106/255.0 green:140/255.0 blue:181/255.0 alpha:1]
andHeight:rect.size.height];
// 时间+设备
y += SIZE_FONT_NAME+5;
float fromX = leftX;
float size = [UIScreen screenWidth]-leftX;
NSString *from = [NSString stringWithFormat:@"%@ %@", _data[@"time"], _data[@"from"]];
[from drawInContext:context withPosition:CGPointMake(fromX, y) andFont:FontWithSize(SIZE_FONT_SUBTITLE)
andTextColor:[UIColor colorWithRed:178/255.0 green:178/255.0 blue:178/255.0 alpha:1]
andHeight:rect.size.height andWidth:size];
}
// 将绘制的内容以图片的形式返回,并调主线程显示
UIImage *temp = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
if (flag==drawColorFlag) {
postBGView.frame = rect;
postBGView.image = nil;
postBGView.image = temp;
}
}
// 绘制文本
[self drawText];
}}
Cell 中各项内容都根据之前算好的布局进行异步绘制,此时 TableView 的性能较之前又提高了一个等级。
条件绘制
但 TableView 的优化之路仍未停止,在 TableView 高速滑动时,滑动过程中的多数 Cell 对用户来说都是无用的,用户只关心滑动停止时附近的几个 Cell。滑动时,用户松开手指后,立刻计算出滑动停止时 Cell 的位置,并预先绘制那个位置附近的几个 Cell。这个方法比较有技巧性,并且对滑动性能来说提升巨大,唯一的缺点就是快速滑动中会出现大量空白内容。
//按需加载 - 如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定3行加载。
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{
NSIndexPath *ip = [self indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
NSIndexPath *cip = [[self indexPathsForVisibleRows] firstObject];
NSInteger skipCount = 8;
if (labs(cip.row-ip.row)>skipCount) {
NSArray *temp = [self indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.width, self.height)];
NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];
if (velocity.y < 0) { // 下拉
NSIndexPath *indexPath = [temp lastObject];
if (indexPath.row+3<datas.count) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+1 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row+3 inSection:0]];
}
} else { // 上滑
NSIndexPath *indexPath = [temp firstObject];
if (indexPath.row>3) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-3 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row-1 inSection:0]];
}
}
[needLoadArr addObjectsFromArray:arr];
}
}
在 - tableView:cellForRowAtIndexPath:
中加入判断:
if (needLoadArr.count>0&&[needLoadArr indexOfObject:indexPath]==NSNotFound) {
[cell clear];
return;
}
快速滑动时,只加载目标区域的 Cell,按需绘制,提高 TableView 流畅度。
总结
通过介绍上述几个优化方案,TableView 的优化可以从下面几方面入手:
- 提前计算并缓存高度
- 异步渲染内容到图片
- 滑动时按需加载
除了上述大方向外,TableView 还有很多大家都熟知的优化点:
- 正确使用 reuseIdentifier 来重用Cells
- 尽量使所有的 view opaque,包括Cell自身
- 尽量少用或不用透明图层
- 如果 Cell 内现实的内容来自 web,使用异步加载,缓存请求结果
- 减少 subviews 的数量
- 在heightForRowAtIndexPath:中尽量不使用 cellForRowAtIndexPath:,如果你需要用到它,只用一次然后缓存结果
- 尽量少用 addView 给 Cell 动态添加 View,可以初始化时就添加,然后通过hide来控制是否显示
参考文章: