前言

在本系列文章中,我将和大家聊一聊关于 UITableView 的种种,那些你知道的或者不知道的事。

本文是系列文章《聊一聊 UITableView》的完结篇。

第一篇:聊一聊 UITableView(一)
第二篇:聊一聊 UITableView(二)


实现

如果你有仔细阅读过本系列文章的前两篇(没看过的一定要去看看呀!),那么我想你基本上已经对实现的原理及流程有了清晰的认识。所以关于具体的实现,本文不做详细介绍,你可以参见下面两篇文章:


那些坑

下面列举了几个在开发中比较常见的供大家学习:

1. UILabel 的 preferredMaxLayoutWidth

定义如下:

This property affects the size of the label when layout constraints are applied to it. During layout, if the text extends beyond the width specified by this property, the additional text is flowed to one or more new lines, thereby increasing the height of the label.

如果我们要使用 Auto Layout 自动计算多行 UILabel 的高度,这个属性就必须在运行时指定,要不然系统计算不出 UILabel 的宽度。这是因为 UILabel 需要知道 superview 的宽度才能折行,而 superview 的宽度还依仗着子 view 宽度的累加才能确定。

同时需要设置 UILabel 的 numberOfLines 属性为 0 以表示显示多行。

2. UITableView 的 estimatedRowHeight

我们知道,UITableView 是个 UIScrollView,就像平时使用 UIScrollView 一样,加载时指定 contentSize 后它才能根据自己的 bounds、contentInset、contentOffset 等属性共同决定是否可以滑动以及滚动条的长度。而 UITableView 在一开始并不知道自己会被填充多少内容,于是询问 data source 个数和创建 cell,同时询问 delegate 这些 cell 应该显示的高度,这就造成它在加载的时候浪费了多余的计算在屏幕外边的 cell 上。

1.设置估算高度后,contentSize.height 根据“cell 估算值 x cell 个数”计算,这就导致滚动条的大小处于不稳定的状态,contentSize 会随着滚动从估算高度慢慢替换成真实高度,肉眼可见滚动条突然变化甚至“跳跃”。

2.若是有设计不好的下拉刷新或上拉加载控件,或是 KVO 了 contentSize 或 contentOffset 属性,有可能使表格滑动时跳动。

3.估算高度设计初衷是好的,让加载速度更快,那凭啥要去侵害滑动的流畅性呢,用户可能对进入页面时多零点几秒加载时间感觉不大,但是滑动时实时计算高度带来的卡顿是明显能体验到的,个人觉得还不如一开始都算好了呢(iOS8 更过分,即使都算好了也会边划边计算)

3. UITableView 的 heightForRowAtIndexPath:

对于 Auto Layout 下的 cell,使用 systemLayoutSizeFittingSize: 计算 tableview.contentView 的 UILayoutFittingCompressedSize 返回的 CGSize 的高度是首选,但它同样是根据 intrinsicContentSize 来计算的,得出的高度其实是不包含 UITextView 这种 view 的,所以结果还需要加上 UITextView 的高度即可。

4. iOS 8 算高机制

相同的代码在 iOS 7 和 iOS 8 上滑动顺畅程度完全不同,iOS8 莫名奇妙的卡。很大一部分原因是 iOS 8 上的算高机制大不相同,图片来自 sunnyxx

造成这样的原因:
1.不开启高度估算时,UITableView 上来就要对所有 cell 调用算高来确定 contentSize
2.dequeueReusableCellWithIdentifier:forIndexPath: 相比不带 “forIndexPath” 的版本会多调用一次高度计算
3.iOS 7 计算高度后有”缓存“机制,不会重复计算;而 iOS 8 不论何时都会重新计算 cell 高度(cell 被认为随时都可能改变高度(如从设置中调整动态字体大小),所以每次滑动出来后都要重新计算高度。)


那些优化

1. 避免 cell 的重新布局

cell 的布局填充等操作比较耗时,一般可在创建时就布局好。如自定义 cell, 可重写其 initWithStyle: 方法,在其中将 cell 的布局设置完成。
创建 cell 完成之后,调用 相应方法 往其中填充内容即可,即将 cell 的布局及填充分开执行,且尽量将要填充的 data 提前准备好。

1
2
3
4
5
6
7
8
9
10
// 调用 UITableView 的 dequeueReusableCellWithIdentifier 方法时会通过这个方法初始化 Cell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
// 在这里!!!
[self initView];
[self updateConstraints];
}
return self;
}

2. 在 Model(Entity)中计算并保存 Cell 的高度

我们都知道,UITableView 是继承自 UIScrollView 的,需要先确定它的 contentSize 及每个 cell 的位置,然后才会把重用的 cell 放置到对应的位置。所以事实上,UITableView 的回调顺序是先多次调用 tableView:heightForRowAtIndexPath:以确定 contentSize 及 cell 的位置,然后才会调用 tableView:cellForRowAtIndexPath:,从而来显示在当前屏幕的 cell。

1
2
3
4
5
6
7
8
9
10
@interface DataEntity : NSObject

// 原始数据
@property(copy, nonatomic) NSString *content;
@property(copy, nonatomic) NSString *title;

// Cell 高度
@roperty(assign, nonatomic) CGFloat cellHeight;

@end

这样,就不用在 tableView:heightForRowAtIndexPath: 中每次都计算了。

补充:同理可将 view 缓存起来的:比如每一个 cell 都需要用到的 UIImage, UIFont, NSDateFormatter 或者任何在绘制时需要的对象,推荐使用类层级的初始化方法中执行分配,并将其存储为静态变量。

3. 滑动 UITableView 时,按需加载对应的内容

从 UIScrollView 的角度出发,对 cell 进行按需加载, 即滚动很快时候, 只加载目标范围内的 cell.

1
2
3
if (needLoadArr.count>0 && [needLoadArr indexOfObject:indexPath]==NSNotFound) {
[cell clear]; return;
}

例如:如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定 3 行加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (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+33) {
[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];
}
}

4. 复用高开销的对象

在 Objective-C 中有些对象的初始化过程很缓慢,比如:NSDateFormatterNSCalendar,但是有些时候,你也不得不使用它们。为了这样的高开销的对象成为影响程序性能的重要因素,我们可以复用它们。

比如,我们在一个类里添加一个 NSDateFormatter 的对象,并使用懒加载机制来使用它,整个类只用到一个这样的对象,并只初始化一次:

1
2
3
4
5
6
7
8
9
10
11
12
// in your .h or inside a class extension
@property (nonatomic, strong) NSDateFormatter *dateFormatter;

// inside the implementation (.m)
// When you need, just use self.dateFormatter
- (NSDateFormatter *)dateFormatter {
if (! _dateFormatter) {
_dateFormatter = [[NSDateFormatter alloc] init];
[_dateFormatter setDateFormat:@"yyyy-MM-dd a HH:mm:ss EEEE"];
}
return _dateFormatter;
}

但是上面的代码在多线程环境下会有问题,所以我们可以改进如下:

1
2
3
4
5
6
7
8
9
10
// no property is required anymore. The following code goes inside the implementation (.m)
- (NSDateFormatter *)dateFormatter {
static NSDateFormatter *dateFormatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateFormat:@"yyyy-MM-dd a HH:mm:ss EEEE"];
});
return dateFormatter;
}

这样就线程安全了。

5. 尽量减少不必要的透明 View

透明图层对渲染性能会有一定的影响,系统必须将透明图层与下面的视图混合起来计算颜色,并绘制出来。减少透明图层并使用不透明的图层来替代它们,可以极大地提高渲染速度。

6. 优化touch事件传递

把不需要接受 touch 的 view 的 userInteractionEnabled 设为 0

7. 其他

  • 选择合适的数据结构来承载数据,不同的数据结构对不同操作的开销是存在差异的;
  • 如果 Cell 展示的内容来自网络,确保用异步加载的方式来获取数据,并且进行缓存,滚出可视范围的载入进程要 Cancel 掉;
  • 尽量减少 subview 的数量,减少渲染工作;
  • 异步获取数据:
  • 启用 GZIP 数据压缩;

写在最后

纸上谈兵终觉浅 绝知此事要躬行

好啦,不管怎样,亲自去实践才是获取与巩固知识的最佳办法!

参考