最近的项目中出现了几处界面卡顿的问题,虽然不全部是UITableViewCell的问题,但是这些问题都适用于UITableViewCell上,因此统一归结为UITableViewCell的优化问题。
一、圆角###
其实圆角对流畅度的影响已经是一个老生常谈的问题了,所以在此只对圆角对帧率影响的原因做一个简单的概述。
圆角拖慢帧率的原因其实是由于离屏渲染,频繁发生离屏渲染是非常耗时的。离屏渲染指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作,离屏渲染耗时是发生在离屏这个动作上面,而不是渲染。离屏会发生缓冲区创建和上下文切换,创建新的缓冲区代价都不算大,付出最大代价的是上下文切换。
在上下文切换时首先要保存当前屏幕渲染环境,然后切换到一个新的绘制环境,申请绘制资源,初始化环境,然后开始一个绘制,绘制完毕后销毁这个绘制环境,如需要切换到离屏渲染或者再开始一个新的离屏渲染重复之前的操作。 这一耗时的过程会致使离屏渲染要比普通渲染花费多一个数量级的时间。
不过好在ios9.0之后对UIImageView的圆角设置做了优化,UIImageView这样设置圆角不会触发离屏渲染。然而不幸的是我们要兼容ios9.0之前的性能,而且UIButton、UILabel这样的控件设置圆角仍然会触发离屏渲染,因此对于圆角问题我们依然要小心。
<img src="http://upload-images.jianshu.io/upload_images/1506986-f87ca3090d1afda6.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/240"/>
在这个项目中有一个自定义的日历界面,日历中日期的Label被设置了圆角,在从上一个界面进入到日历这个界面时卡顿会非常明显。由于日历界面弹出过程比较短,从分析工具中很难看出帧率情况,因此重新写了个用来演示工程。
cell里的每一个Label都被设置了圆角,在设置圆角前后,滑动时帧率分别如下。
可以看出当cell里大量使用圆角以后,对界面流畅度的影响是惊人的。那么令人关心的问题来了,到底如何应对圆角问题呢?
最直接的当然是不要使用圆角啦~
-
什么?你非要用圆角嘛……那么这个时候也可以采用下面的方法:
label.layer.shouldRasterize = YES; label.layer.rasterizationScale = [UIScreen mainScreen].scale;
shouldRasterize = YES
会使视图渲染内容被缓存起来,下次绘制的时候可以直接显示缓存。不过这个方法也有它的局限性,要在视图内容不改变的情况下才可以使用。 那么使用
layer.mask
如何?不,千万不!layer.mask将会更加降低你的流畅度,在这个测试工程里,使用layer.mask将会让帧率降到11。除非你真的要使用到非常复杂的圆角,否则千万不要使用layer.mask。下面介绍一个最全能的方法,对于UIView、UIImageView、UIButton、UILabel全部适用。
- (void)ay_setCornerRadius:(AYRadius)cornerRadius setNormalImage:(UIImage *)normalImage highlightedImage:(UIImage *)highlightedImage disabledImage:(UIImage *)disableImage selectedImage:(UIImage *)selectedImage backgroundColor:(UIColor *)color {
if ([self isMemberOfClass:[UIButton class]]) {
CGFloat viewWidth = self.frame.size.width;
CGFloat viewHeight = self.frame.size.height;
UIGraphicsBeginImageContextWithOptions(self.frame.size, NO, [UIScreen mainScreen].scale);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetStrokeColorWithColor(context, color.CGColor);
CGContextSetFillColorWithColor(context, color.CGColor);
// 起始点
CGContextMoveToPoint(context, 0, viewHeight * .5);
CGContextAddArcToPoint(context, 0, 0, viewWidth * .5, 0, cornerRadius.topLeftCornerRadius);
CGContextAddArcToPoint(context, viewWidth, 0, viewWidth , viewHeight * .5, cornerRadius.topRightCornerRadius);
CGContextAddArcToPoint(context, viewWidth, viewHeight, viewWidth * .5, viewHeight, cornerRadius.bottomRightCornerRadius);
CGContextAddArcToPoint(context, 0, viewHeight, 0, viewHeight * .5, cornerRadius.bottomLeftCornerRadius);
CGContextClosePath(context);
CGContextDrawPath(context, kCGPathFillStroke);
UIImage *currentImage = UIGraphicsGetImageFromCurrentImageContext();
if (normalImage) {
currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:normalImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
[((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateNormal];
}
if (highlightedImage) {
currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:highlightedImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
[((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateHighlighted];
}
if (disableImage) {
currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:disableImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
[((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateDisabled];
}
if (selectedImage) {
currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:selectedImage backgroundColor:color withDrawRect:self.bounds setContentMode:UIViewContentModeScaleToFill];
[((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateSelected];
}
}
}
- (void)ay_setCornerRadius:(AYRadius)cornerRadius backgroundImage:(UIImage *)image backgroundColor:(UIColor *)color {
[self ay_setCornerRadius:cornerRadius backgroundImage:image backgroundColor:color withContentMode:UIViewContentModeScaleToFill];
}
- (void)ay_setCornerRadius:(AYRadius)cornerRadius backgroundColor:(UIColor *)color {
[self ay_setCornerRadius:cornerRadius backgroundImage:nil backgroundColor:color withContentMode:UIViewContentModeScaleToFill];
}
- (void)ay_setCornerRadius:(AYRadius)cornerRadius backgroundImage:(UIImage *)image backgroundColor:(UIColor *)color withContentMode:(UIViewContentMode)contentMode {
CGFloat viewWidth = self.frame.size.width;
CGFloat viewHeight = self.frame.size.height;
UIGraphicsBeginImageContextWithOptions(self.frame.size, NO, [UIScreen mainScreen].scale);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(context, color.CGColor);
// 起始点
CGContextMoveToPoint(context, 0, viewHeight * .5);
CGContextAddArcToPoint(context, 0, 0, viewWidth * .5, 0, cornerRadius.topLeftCornerRadius);
CGContextAddArcToPoint(context, viewWidth, 0, viewWidth , viewHeight * .5, cornerRadius.topRightCornerRadius);
CGContextAddArcToPoint(context, viewWidth, viewHeight, viewWidth * .5, viewHeight, cornerRadius.bottomRightCornerRadius);
CGContextAddArcToPoint(context, 0, viewHeight, 0, viewHeight * .5, cornerRadius.bottomLeftCornerRadius);
CGContextClosePath(context);
CGContextDrawPath(context, kCGPathFill);
UIImage *currentImage = UIGraphicsGetImageFromCurrentImageContext();
if ([self isMemberOfClass:[UIView class]]) {
self.layer.contents = (__bridge id _Nullable)(currentImage.CGImage);
} else if([self isMemberOfClass:[UIImageView class]]) {
currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:image backgroundColor:color withDrawRect:self.bounds setContentMode:contentMode];
((UIImageView *)self).image = currentImage;
} else if ([self isMemberOfClass:[UIButton class]]) {
currentImage = [UIImage ay_clipImageWithCornerRadius:cornerRadius setImage:image backgroundColor:color withDrawRect:self.bounds setContentMode:contentMode];
[((UIButton *)self) setBackgroundImage:currentImage forState:UIControlStateNormal];
} else if([self isMemberOfClass:[UILabel class]]) {
((UILabel *)self).layer.backgroundColor = [UIColor colorWithPatternImage:currentImage].CGColor;
}
UIGraphicsEndImageContext();
}
主要看ay_setCornerRadius: backgroundImage: backgroundColor: withContentMode:
这个函数。
void UIGraphicsBeginImageContextWithOptions ( CGSize size, BOOL opaque, CGFloat scale )
创建一个基于位图的图形上下文,第二个参数设置不透明。
void CGContextAddArcToPoint ( CGContextRef c, CGFloat x1, CGFloat y1, CGFloat x2, CGFloat y2, CGFloat radius )
画弧,从 (x1,y1) 到 (x2,y2),弧线半径是radius。
如果是UIImageView 或UIButton将会进入 ay_clipImageWithCornerRadius setImage: backgroundColor: withDrawRect: setContentMode:(UIViewContentMode)
:
+ (UIImage *)ay_clipImageWithCornerRadius:(AYRadius)cornerRadius setImage:(UIImage *)image backgroundColor:(UIColor *)color withDrawRect:(CGRect)rect setContentMode:(UIViewContentMode)contentMode {
if (image) {
image = [image scaleImageWithContentMode:UIViewContentModeScaleToFill containerRect:rect];
color = [UIColor colorWithPatternImage:image];
}
CGFloat imageWidth = rect.size.width;
CGFloat imageHeight = rect.size.height;
UIGraphicsBeginImageContextWithOptions(rect.size, NO, [UIScreen mainScreen].scale);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(context, color.CGColor);
CGContextMoveToPoint(context, 0, imageHeight * .5);
CGContextAddArcToPoint(context, 0, 0, imageWidth * .5, 0, cornerRadius.topLeftCornerRadius);
CGContextAddArcToPoint(context, imageWidth, 0, imageWidth , imageHeight * .5, cornerRadius.topRightCornerRadius);
CGContextAddArcToPoint(context, imageWidth, imageHeight, imageWidth * .5, imageHeight, cornerRadius.bottomRightCornerRadius);
CGContextAddArcToPoint(context, 0, imageHeight, 0, imageHeight * .5, cornerRadius.bottomLeftCornerRadius);
CGContextClosePath(context);
CGContextDrawPath(context, kCGPathFill);
UIImage *outputImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return outputImage;
}
使用时只需设置一行代码,[testLabel ay_setCornerRadius:AYRadiusMake(10, 10, 10, 10) backgroundColor:[UIColor lightGrayColor]];
注意:只能在这个方法里设置backgroundColor。
经过实测,还是在上边的测试项目中,使用这种方法可以轻松保持滑动时帧率在55以上。
二、自动布局###
在这次的项目中使用到的自动布局是知名布局框架Masonry,自动布局肯定意味着更多的计算,但是经过实测才发现,使用Masonry和直接指定位置相比,计算量的差异竟然如此惊人。
使用Masonry将会多出几十倍的CPU使用。为了看对帧率的影响,这里特别让所有cell都不复用,最终看到使用自动布局和直接指定位置相对比,平均帧率分别是52和57,可见如果想要更多的追求界面的流畅度,应当尽量不去使用自动布局。
三、高度计算###
固定高度#
对于UITableViewCell我们经常需要给它指定高度,对于固定高度的cell一般都很好处理,可以使用以下两种方法进行指定:
self.tableView.rowHeight = 100;
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return 100
}
相比第二种,第一种方法也会更直接和有效率。
可变高度#
estimatedRowHeight#
iOS7以后出现了estimatedRowHeight这个属性,可以给一个整体估算值来大概指定cell的高度,设置好后根据“cell.estimatedRowHeight * cell个数”来计算contentSize.height。但是这个属性也有很多它的问题:
- 由于是估算的高度,这就导致滚动条的大小处于不稳定的状态,contentSize 会随着滚动从估算高度慢慢替换成真实高度,肉眼可见滚动条突然变化甚至“跳跃”。
- 若是有设计不好的下拉刷新或上拉加载控件,或是 KVO 了 contentSize 或 contentOffset 属性,有可能使表格滑动时跳动。
- 滑动时会实时计算高度,因此会带来界面上的卡顿。
AutoLayout#
AutoLayout当然可以实现更精准的计算,但是一方面是要保证使用者对约束设置的比较熟练,另一方面也考虑到计算效率的问题,因此也不是最优方案。
self-sizing cell#
iOS8 WWDC 中推出了 self-sizing cell 的概念,可以让 cell 自己负责自己的高度计算,代码如下:
self.tableView.estimatedRowHeight = 213;
self.tableView.rowHeight = UITableViewAutomaticDimension;
方便是方便了,可是除非你的APP只需要支持iOS8以上,否则针对iOS8以前的系统还是要使用其他方式来计算高度。
UITableView+FDTemplateLayoutCell#
为了解决以上的问题,这里推荐一个解决算高问题的最佳方案UITableView+FDTemplateLayoutCell
,地址是FDTemplateLayoutCell,使用起来如下:
#import <UITableView+FDTemplateLayoutCell.h>
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return [tableView fd_heightForCellWithIdentifier:@"identifer" cacheByIndexPath:indexPath configuration:^(id cell) {
// 配置 cell 的数据源,和 "cellForRow" 干的事一致,比如:
cell.entity = self.feedEntities[indexPath.row];
}];
}
FDTemplateLayoutCell的大致原理就是在空闲时刻执行预缓存,以保证加载速度和滑动流畅性,具体用到的有以下几方面:
-
和每个 UITableViewCell ReuseID 一一对应的 template layout cell
这个 cell 只为了参加高度计算,不会真的显示到屏幕上;它通过 UITableView 的 -dequeueCellForReuseIdentifier: 方法 lazy 创建并保存,所以要求这个 ReuseID 必须已经被注册到了 UITableView 中,也就是说,要么是 Storyboard 中的原型 cell,要么就是使用了 UITableView 的 -registerClass:forCellReuseIdentifier: 或 -registerNib:forCellReuseIdentifier:其中之一的注册方法。 -
根据 autolayout 约束自动计算高度
使用了系统在 iOS6 就提供的 API:-systemLayoutSizeFittingSize: -
根据 index path 的一套高度缓存机制
计算出的高度会自动进行缓存,所以滑动时每个 cell 真正的高度计算只会发生一次,后面的高度询问都会命中缓存,减少了非常可观的多余计算。 -
自动的缓存失效机制
无须担心你数据源的变化引起的缓存失效,当调用如-reloadData,-deleteRowsAtIndexPaths:withRowAnimation:等任何一个触发 UITableView 刷新机制的方法时,已有的高度缓存将以最小的代价执行失效。如删除一个 indexPath 为 [0:5] 的 cell 时,[0:0] ~ [0:4] 的高度缓存不受影响,而 [0:5] 后面所有的缓存值都向前移动一个位置。自动缓存失效机制对 UITableView 的 9 个公有 API 都进行了分别的处理,以保证没有一次多余的高度计算。 -
预缓存机制
预缓存机制将在 UITableView 没有滑动的空闲时刻执行,计算和缓存那些还没有显示到屏幕中的 cell,整个缓存过程完全没有感知,这使得完整列表的高度计算既没有发生在加载时,又没有发生在滑动时,同时保证了加载速度和滑动流畅性,下文会着重讲下这块的实现原理。
空闲RunLoopMode#
当用户正在滑动 UIScrollView 时,RunLoop 将切换到 UITrackingRunLoopMode 接受滑动手势和处理滑动事件(包括减速和弹簧效果),此时,其他 Mode (除 NSRunLoopCommonModes 这个组合 Mode)下的事件将全部暂停执行,来保证滑动事件的优先处理,这也是 iOS 滑动顺畅的重要原因。
当 UI 没在滑动时,默认的 Mode 是 NSDefaultRunLoopMode(同 CF 中的 kCFRunLoopDefaultMode),同时也是 CF 中定义的 “空闲状态 Mode”。当用户啥也不点,此时也没有什么网络 IO 时,就是在这个 Mode 下。
用RunLoopObserver找准时机
注册 RunLoopObserver 可以观测当前 RunLoop 的运行状态,并在状态机切换时收到通知:
- RunLoop开始
- RunLoop即将处理Timer
- RunLoop即将处理Source
- RunLoop即将进入休眠状态
- RunLoop即将从休眠状态被事件唤醒
- RunLoop退出
因为“预缓存高度”的任务需要在最无感知的时刻进行,所以应该同时满足:
RunLoop 处于“空闲”状态 Mode
当这一次 RunLoop 迭代处理完成了所有事件,马上要休眠时
使用 CF 的带 block 版本的注册函数可以让代码更简洁:
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFStringRef runLoopMode = kCFRunLoopDefaultMode;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler
(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
// TODO here
});
CFRunLoopAddObserver(runLoop, observer, runLoopMode);
在其中的 TODO 位置,就可以开始任务的收集和分发了,当然,不能忘记适时的移除这个 observer
分解成多个RunLoop Source任务
假设列表有 20 个 cell,加载后展示了前 5 个,那么开启估算后 table view 只计算了这 5 个的高度,此时剩下 15 个就是“预缓存”的任务,而我们并不希望这 15 个计算任务在同一个 RunLoop 迭代中同步执行,这样会卡顿 UI,所以应该把它们分别分解到 15 个 RunLoop 迭代中执行,这时就需要手动向 RunLoop 中添加 Source 任务(由应用发起和处理的是 Source 0 任务)
Foundation 层没对 RunLoopSource 提供直接构建的 API,但是提供了一个间接的、既熟悉又陌生的 API:
- (void)performSelector:(SEL)aSelector
onThread:(NSThread *)thr
withObject:(id)arg
waitUntilDone:(BOOL)wait
modes:(NSArray *)array;
这个方法将创建一个 Source 0 任务,分发到指定线程的 RunLoop 中,在给定的 Mode 下执行,若指定的 RunLoop 处于休眠状态,则唤醒它处理事件,简单来说就是“睡你xx,起来嗨!”
于是,我们用一个可变数组装载当前所有需要“预缓存”的 index path,每个 RunLoopObserver 回调时都把第一个任务拿出来分发:
NSMutableArray *mutableIndexPathsToBePrecached = self.fd_allIndexPathsToBePrecached.mutableCopy;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
if (mutableIndexPathsToBePrecached.count == 0) {
CFRunLoopRemoveObserver(runLoop, observer, runLoopMode);
CFRelease(observer); // 注意释放,否则会造成内存泄露
return;
}
NSIndexPath *indexPath = mutableIndexPathsToBePrecached.firstObject;
[mutableIndexPathsToBePrecached removeObject:indexPath];
[self performSelector:@selector(fd_precacheIndexPathIfNeeded:)
onThread:[NSThread mainThread]
withObject:indexPath
waitUntilDone:NO
modes:@[NSDefaultRunLoopMode]];
});
这样,每个任务都被分配到下个“空闲” RunLoop 迭代中执行,其间但凡有滑动事件开始,Mode 切换成 UITrackingRunLoopMode,所有的“预缓存”任务的分发和执行都会自动暂定,最大程度保证滑动流畅。
PS: 预缓存功能因为下拉刷新的冲突和不明显的收益已经废弃