iOS Core Animation - Advanced Techniques-学习笔记(五)

《iOS Core Animation: Advanced Techniques》- 性能调优篇

定时器动画调优

当我们想开发一个基于时间流逝运动的动画时,首先会想到使用NSTimer计时器,但是这里不推荐使用这个类,我们看下NSTimer是怎么工作的。

Runloop

iOS上每个线程都管理一个Runloop。对于主线程的Runloop,每一次循环都会做以下操作:

  • 处理触摸事件
  • 网络数据包处理
  • 执行GCD临界区任务
  • 处理计时器任务
  • 屏幕重绘

当设置了一个NSTimer计时器,这个任务会被插入任务队列中,但是它只会在上一个任务完成之后开始执行。这通常会导致有几毫秒的延迟,但是如果上一个任务过了很久才完成就会导致延迟很长一段时间。

我们可以通过一些途径来优化:

CADisplayLink和NSTimer的接口很相似,但是和NSTimer用秒作为及时单位不同,它使用属性frameInterval指定间隔多少帧后执行,用CADisplayLink而不是NSTimer,会保证帧率足够连续,使得动画看起来更加平滑。

但要知道即使CADisplayLink也不能保证每一帧都按计划执行,一些失去控制的离散任务或者事件(例如资源紧张的后台程序,GPU渲染进程)可能会导致动画偶尔地丢帧。

Runloop Mode选择

添加到Runloop的任务都有一个指定优先级的模式,为了保证用户界面保持平滑,iOS会提供和用户界面相关任务的优先级,而且当UI很活跃的时候的确会暂停一些别的任务。

  • NSDefaultRunLoopMode - 标准优先级
  • NSRunLoopCommonModes - 高优先级
  • UITrackingRunLoopMode - 用于UIScrollView和别的控件(例如Banner)的动画

一个典型的例子就是当是用UIScrollview滑动的时候,重绘滚动视图的内容会比别的任务优先级更高,所以标准的NSTimer和网络请求就不会启动。

我们可以同时加入NSDefaultRunLoopMode和UITrackingRunLoopMode来保证动画不会被滑动或者其他IO行为打断

1
2
3
4
5
{
self.timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(action:)];
[self.timer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
[self.timer addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode];
}

CPU性能调优

动画和屏幕上组合的图层实际上被一个单独的进程管理,而不是你的应用程序。这个进程我们称它为渲染服务。在iOS5之前叫SpringBoard(同时管理着iOS的主屏),在iOS6之后叫做BackBoard。

Core Animation运行一段动画的过程:

CPU处理:

  • 布局 - 你在CALayer上设置的图层属性,层级关系
  • 显示 - CALayer寄宿图绘制阶段,如果实现了-drawRect:,该方法会被调用
  • 准备 - Core Animation准备提交SpringBoard/BackBoard进程,会解码一些别的事务在动画中将要显示的图片时间点等
  • 提交 - 打包所有图层和动画属性,通过IPC(内部处理通信)发送到BackBoard进程,渲染服务

    当打包的图层和动画信息到达BackBoard,会被反序列化成渲染树,并使用这个树状结构,对每一帧做下面两次处理

  • 对所有的图层属性计算中间值,设置OpenGL几何形状(纹理化的三角形)来执行渲染

GPU处理:

  • 在屏幕上渲染可见的三角形

所以我们真正能控制和优化的,只有在CPU处理布局和显示阶段,但是我们提交到IPC的渲染行为是可以被优化的,下面介绍CPU行为上的优化方法

FlexBox代替AutoLayout

视图布局计算会消耗掉部分时间,特别是使用AutoLayout。以60FPS作为一个iOS流畅度的黄金标准,那么将要求布局在0.0166667s内完成,而AutoLayout基于Cassowary算法会计算大量线性等式和不等式,下图(图片来自互联网)做了一个简单的布局对比,当视图数达到50个,AutoLayout将会出现性能瓶颈。Facebook的yoga框架允许你在iOS开发中使用FlexBox布局,同样来自Facebook的AsyncDisplayKit框架也引入了FlexBox优化布局的性能开销

笔者曾经玩过yoga框架,使用过程中有两个头疼的问题,一个是不支持虚拟div,一个是不支持TableViewCell的自适应,如果要使用FlexBox需要进行二次开发,希望Facebook能够解决这几个问题。

初始化必要视图而非懒加载

懒加载只有在视图需要加载时才会去加载,这样的做法对内存占用和启动速度都要好处,但是在完成初始化操作前,你的动画都会被延迟。所以可以对动画必要视图进行优先初始化,而非傻傻的懒加载

使用CoreAnimation专用图层代替CoreGraphics绘制

当实现了视图中的-drawRect:方法,或者CALayerDelegate的-drawLayer:inContext:方法,就会在绘制前产生一个可估算的性能开销,CoreAnimation需要在内存中开辟一个等大小的寄宿图用于绘制,CoreGraphics绘制会十分缓慢,绘制结束还需通过通过IPC将图片数据上传到BackBoard,这也是为什么非不得已都不建议使用软件绘图,并且不要实现-drawRect:方法,尽管可能它是空方法。CoreAniamtion为图形绘制提供了专有图层,并提供了硬件加速,总体上都比Core Graphics更快,同时他们也避免了创造一个寄宿图

绘制上下文占用内存 = layer.width(px) x layer.height(px) x 4(bit)

对于一个在Retina iPad上的全屏图层来说,这个内存量就是2048 1526 4bit,相当于12MB内存,并且图层每次重绘的时候都需要重新抹掉内存并重新分配。

优化图片解码

PNG或者JPEG压缩之后的图片文件会比同质量的位图小得多,直接或间接使用UIImageView,或者将图片绘制到CoreGraphics都需要对图片解压缩,对于一个较大的图片,都会占用一定的时间。这一步虽然不可避免,但是我们可以把这个操作放到后台线程,先把图片绘制到CGBitmapContext中,然后从Bitmap直接创建图片。

主流的网络图片库都用了这样的方式,我们来看下SDWebImage的网络Res解析类,在获取到网络资源后,直接在子线程对图片绘制解码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
AFHTTPResponseSerializer.m

static UIImage * AFInflatedImageFromResponseWithDataAtScale(NSHTTPURLResponse *response, NSData *data, CGFloat scale) {
...

CGContextRef context = CGBitmapContextCreate(NULL, width, height, bitsPerComponent, bytesPerRow, colorSpace, bitmapInfo);

CGColorSpaceRelease(colorSpace);

if (!context) {
CGImageRelease(imageRef);

return image;
}

CGContextDrawImage(context, CGRectMake(0.0f, 0.0f, width, height), imageRef);
CGImageRef inflatedImageRef = CGBitmapContextCreateImage(context);

CGContextRelease(context);

UIImage *inflatedImage = [[UIImage alloc] initWithCGImage:inflatedImageRef scale:scale orientation:image.imageOrientation];

CGImageRelease(inflatedImageRef);
CGImageRelease(imageRef);

return inflatedImage;
}

异步绘制

之前我们说过,CoreGraphic绘图是有较大性能开销的,那么如果一定要使用软件绘图,那么我们在封装的时候,可以提供同步和异步的绘制方法,非常幸运,CoreGraphic提供的方法都是线程安全的,例如提供一个绘制色块图片的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
+ (UIImage *)imageWithColor:(UIColor *)color size:(CGSize)size {

UIGraphicsBeginImageContextWithOptions(size, NO, [UIScreen mainScreen].scale);
CGContextRef context = UIGraphicsGetCurrentContext();

CGContextSetFillColorWithColor(context, color.CGColor);
CGContextFillPath(context);

UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}

+ (void)drawImageWithColor:(UIColor *)color size:(CGSize)size completion:(void(^)(UIImage *img))comp {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

UIGraphicsBeginImageContextWithOptions(size, NO, [UIScreen mainScreen].scale);
CGContextRef context = UIGraphicsGetCurrentContext();

CGContextSetFillColorWithColor(context, color.CGColor);
CGContextFillPath(context);

UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
comp(image);
});
UIGraphicsEndImageContext();
});
}

GPU性能调优

有时候要用CAShapeLayer并不能完全代替CoreGraphics,比如创建一个绘图应用时。当我们绘制的轨迹越复杂,绘制的越多,就会越卡顿,帧数将会下降。这是由于每次移动手指绘制时,都会重绘之前的轨迹,即使场景大部分都没有改变

脏矩形自动更新

为了减少不必要的绘制,Mac OS和iOS设备将会把屏幕区分为需要重绘的区域和不需要重绘的区域。那些需要重绘的部分被称作「脏区域」。在实际应用中,鉴于非矩形区域边界裁剪和混合的复杂性,通常会区分出包含指定视图的矩形位置,而这个位置就是「脏矩形」,如果你可以高效确定指定系统需要重绘的脏矩形位置,那么可以调用-setNeedsDisplayInRect:来避免不必要的绘制而非调用-setNeedsDisplay

这里有一个例子,例如当我们创建了一个画笔,触碰屏幕则会将画笔size的矩形绘制到图层上,由于我们明确知道画笔的尺寸,那么在用户绘制时每次拖拽所产生的「脏矩形」我们都是可以准确计算的,然后告诉GPU我们只需要重绘画笔矩形而非重绘整个画布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
//get the starting point
CGPoint point = [[touches anyObject] locationInView:self];

//add brush stroke
[self addBrushStrokeAtPoint:point];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
//get the touch point
CGPoint point = [[touches anyObject] locationInView:self];

//add brush stroke
[self addBrushStrokeAtPoint:point];
}

- (void)addBrushStrokeAtPoint:(CGPoint)point {
//add brush stroke to array
[self.strokes addObject:[NSValue valueWithCGPoint:point]];

//set dirty rect
[self setNeedsDisplayInRect:[self brushRectForPoint:point]];
}

- (CGRect)brushRectForPoint:(CGPoint)point {
return CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE);
}

防止过度混合和绘制

GPU会放弃绘制那些完全被其他图层遮挡的像素,但是要计算出一个图层是否被遮挡也是相当复杂并且会消耗处理器资源。同样,重叠多个透明视图(图层)消耗的资源也是相当客观的。所以为了加速处理进程,不到必须时刻不要使用透明图层。任何情况下,你应该这样做:

  • 设置视图的backgroundColor属性为一个不透明的颜色
  • 设置opaque属性为YES

这样做可以使计算过程加速,在CPU处理阶段,Core Animation就可以处理好并抛弃那些完全被遮盖的图层

避免离屏渲染

当图层被指定为在未预合成之前不能直接在屏幕中绘制时,离屏渲染会被唤醒,这意味着图层必须在被显示之前在一个屏幕外上下文中被渲染,而对于GPU来说,这样的操作对性能是有较大损耗的。

会产生离屏渲染的操作:

  • 圆角(当和maskToBounds一起使用时)
  • 图层蒙板
  • 阴影

例如当一个列表视图中出现大量圆角视图快速滑动时,可以观察到GPU资源已经占满,而CPU资源消耗很少。这是由于CPU已经计算完所有图层信息提交IPC,而GPU负担了大量的离屏渲染任务

优化的方案首先是避免圆角和maskToBounds一起使用,非必须不适用图层蒙版,若无法避免那么就将性能开销转嫁给CPU

这里有几种处理方式,一种是对于需要切圆角的图片,不要使用CALayer.corner在图层上裁剪,而是在获取到图片资源后在子线程提交前再进行一次对图片裁切的异步绘制;第二种使用图层栅格化CALayer.shouldRasterize转化为位图