iOS 2D Graphic(2)—— Performance
在本系列上一篇《iOS 2D Graphic(1)—— Concept 基本概念和原理》中,我们已经了解了关于iOS 图形图像的基本要素。在过去相当长的一段时间里,较之于Android,优秀顺畅的UI操作体验一直是iOS引以为豪的地方。这个不仅和iOS的UI操作线程设计机制有关,也与iOS图形图像上对性能这部分的深度优化有关。但是虽然Apple替我们做了很多优化的动作,在实际开发中,如果不注意和图形图像相关的性能损失点,仍然会造成App的性能问题。这篇将重点关注如何处理图形图像的性能问题。
CPU Bound vs. GPU Bound
首先,我们需要理解两个不同的性能影响因素:CPU约束型(CPU bound) 和 GPU约束型(GPU bound)。
我们回过头来看看上一篇中提到的一个完整的Core Animation图形操作,需要经过哪几步:
搞清楚了这个步骤,那顾名思义CPU bound和GPU bound的概念,就意味着影响性能的操作可以分为两种:前者主要集中在CPU上,也就是说对应于上图中的1~2步;后者主要集中在GPU上,对应于上图的第3步骤。
这两种不同的场景,直接决定了使用不同的方式来性能,CPU bound的场合下,需要减轻CPU的压力,而把一部分工作转交给GPU更擅长的方式去处理;而在GPU bound的场合下,需要减轻GPU的压力,把一部分工作提前交给CPU去做预处理。当然了,你要是说那CPU和GPU都很忙怎么办?这种情况下,就可能需要你重新设计系统的架构,然后不断的调试,不断的验证了。
那么,怎么区分你的App在出现性能问题时,是CPU bound还是GPU bound呢?这个时候,你需要一些工具来帮助你分析问题的根源所在。最主要的工具就是Instrument。
Instrument
“工欲善其事,必先利其器”
-- 《论语》
Apple提供的Instrument是一个很强大的测试平台工具,打开Instrument,你可以看到很多小工具能针对性的提供不同的功能,这里主要强调和Graphic相关的两个:Core Animation instrument和OpenGL ES Driver instrument。
1. Core Animation Instrument
Core Animation Instrument.png选择Core Animation Instrument后,在面板内你能看到系统提供的默认两个不同的Profiler,一个是Core Animation(图中1),一个是Time Profiler(图中2)。Core Animation Profiler中,你能看到最主要的一个信息就是Frame Rate。这个值是判定你的App有没有UI性能问题的主要标准,一切的一切优化目标都是要求Frame Rate达到60帧每秒(60 fps)!所以当你看到这个值明显低于55-60的时候,你就可以确定你的App需要优化。
而Time Profiler则直观的显示了你的CPU Utilization,在这里你能看到最耗CPU时间的操作和调用。也就是说,这里是分析CPU Bound问题的切入点,在这里找到最耗时间的操作,然后再去找到应对措施。
Profilers in Core Animation Instrument.pngCore Animation 一栏在右下方,还有一个非常有用的工具集合:Color Debug Options(图中3)。这里有一系列的debug选项,这是辅助你找到和GPU Bound操作相关的信息入口。比较常用的选项包括:
- Color Blend Layers
这个选项的意思是,如果该区域有图层混合的操作,则标记成红色,混合的图层越多,颜色越深,否则为绿色。如果你看到你的界面有大量的深红色区域,则表示你当前的UI可能做颜色混合的操作会比较影响你的性能。图层混合主要是因为layer的透明度。
屏幕上每一个点都是一个像素,像素有R、G、B三种颜色构成,另外还有一个alpha值。如果某一块区域上覆盖了多个layer,最后的显示效果受到这些layer的共同混合(即Blending)的结果。举个例子,上层是蓝色(RGB=0,0,1),透明度A为50%,下层是红色(RGB=1,0,0), 不透明。那么最终的显示效果是紫色(RGB=0.5,0,0.5)。这种颜色的混合需要消耗一定的GPU资源,不透明图层越多,blending消耗越大。但是如果对于某一层layer的透明度设置为100%(不透明),则GPU会忽略下面所有的layer,从而节约了很多不必要的开销。
- Color Hits Green and Misses Red
这个选项的意思是,Core Animation是否有使用缓存进行绘制。如果成功使用了缓存,则标记成绿色,如果当前区域有缓存,但是当前缓存失效了,则标记成红色。这个选项主要和光栅化(Rasterization)相关。光栅化是将一个layer以及它的sub layer预先完成混合和渲染,并生成一个静态的位图(bitmap),然后加入缓存中。后续如果这个渲染结果不再变化,则可以复用这个缓存的位图直接绘制在屏幕上。这对于屏幕上有大量静态内容时,是很好的优化。后文将详细介绍Rasterization的部分。
- Color Copied Images
这个选项主要检查是否有使用不正确图片格式。iOS推荐的图片格式是不带Alpha通道的PNG和JPG格式。若是其它GPU不支持的色彩格式的图片则会标记为青色,此时只能先由CPU来进行转换处理,然后将处理完的图片交给GPU。青色是我们需要避免的,因为CPU实时进行处理图片可能会阻塞主线程。
- Color Offscreen-Rendered Yellow
这个选项是用来检查在当前UI中哪些区域的绘制必须使用离屏渲染(Offscreen Rendered)。离屏渲染,顾名思义,是指当前屏幕的绘制操作并不是直接发生在当前的帧缓冲区内,而是会合并/渲染图层树的一部分到一个新的缓冲区,然后该缓冲区被渲染到屏幕上。产生离屏渲染的原因有很多,它可以被 Core Animation 自动触发,也可以被应用程序强制触发。
一般情况下,你需要避免离屏渲染,因为这是很大的消耗。直接将图层合成到帧的缓冲区中(在屏幕上)比先创建屏幕外缓冲区,然后渲染到纹理中,最后将结果渲染到帧的缓冲区中要廉价很多。因为这其中涉及两次昂贵的环境转换(转换环境到屏幕外缓冲区,然后转换环境到帧缓冲区)。
关于离屏渲染,如果展开来说,又是一个相对而言比较复杂的话题,我将在下一篇文章《iOS 2D Graphic(3)—— Offscreen Rendering离屏渲染》中详细讨论和总结这部分的内容。
除了以上常用的选项外,还有一个你可能也会用的到的选项:
- Color Misaligned Images
这个选项检查了图片是否被放缩,像素是否对齐。被放缩的图片会被标记为黄色,像素不对齐则会标注为紫色。
黄色的场景比较多见,比如你将一个200200的图片塞到了一个100100的UIImageView里,那么图片自然就会被缩放;紫色的Misaligned Image场景一般很难见到,主要表示要绘制的点无法直接映射到频幕上的像素点,此时系统需要对相邻的像素点做anti-aliasing反锯齿计算。通常这种问题出在对某些View的Frame重新计算和设置时产生的。
2. OpenGL ES Driver Instrument
OpenGL ES Driver.png相对于前面介绍的Core Animation Profiler来说,OpenGL ES Driver的侧重点非常集中,它的目标很明确,就是针对GPU的使用进行检测。你可以在右下角的Panel里选择感兴趣的指标选项,然后在下方的列表里观察数值。一般而言,Device Utilization,Renderer Utilization和Core Animation Frame Per Second是你应该要关注的首选项,这些指标将直接反应当前GPU的使用率。如果你发现GPU的使用率明显偏高,那么很明显你面临了GPU Bound的情况。然后,你可以再用前文介绍的Core Animation Profiler,使用Color Debug去分析具体可能的原因。
OpenGL ES Driver 2.png优化策略和方法
那么当我们已经知道了Graphic的性能大致上分为CPU Bound和GPU Bound两种类型,并且我们也知道可以使用Instrument来分析具体的源头所在,那么我们能采取哪些措施和策略来解决问题呢?
为了能够清晰的阐述这些方法,我们需要思考下iOS中Animation的实现步骤,因为即使是非Animation的静态页面,我们也可以看作是只有一帧画面的特殊Animation,所以分析优化策略,可以对照着Animation的核心步骤来进行。
我们来看上一篇中出现过的描述一个完整的Animation的步骤的图:
Core Animation Pipe Line.png-
1. 创建Animation,并更新视图;
-
** 2. 计算Animation必要的数据并提交动画,具体分为以下四步:**
1) 布局:【CPU & I/O bound】
•Often has expensive view creation and layer graph management
•May need to do expensive data lookup
•May block on I/O or work done in another thread or process
2) 显示:【 CPU & Memory bound】
• -drawRect 调用在此进行
• String drawing or other expensive drawing
3)准备:【CPU bound】
• 图像解码转换,额外的动画操作;
4)提交:【CPU bound】
打包Layers及动画参数,这是递归操作,如果layer层级非常复杂,则代价比较昂贵。随后Layers通过IPC被送到Render Server -
** 3. Render Server进行最终渲染**
了解这上面这3大步骤,将整个Animation从准备到完成的过程分成了两个清晰的阶段来做性能评估:“动画的响应速度”(Responsiveness)和“动画的平滑性”(Smoothness),其中前者对应于1,2步,后者对应于第3步。于是,我们讨论优化策略都将在这两个大方向上做文章。
(1)响应速度优化
-
减少初始化(Do less set up)
• 尽量避免在layout期间让CPU做重度运算,或者其它阻塞操作;
• 数据上尽量使用in-memory caches;
• 如果需要做数据库查询,尽可能确保数据库已经对高性能要求的部分提前做了正确的索引;
• 尽可能重用cell和view;
• 在drawRect之外做初始化,并且全局只初始化一次然后复用。例如避免CGColors, CGPaths, clipShapes的重复创建,只初始化一次然后复用 -
减少绘制(Reduce drawing)
-
只重绘变化的部分
• 第一黄金原则:“避免使用drawRect:”:
• 尽可能使用CALayer的属性,而避免使用DrawRect重绘。经典案例就是设置背景颜色,使用backgroundColor而不是UIColor setFill:// bad
-(void)drawRect:(CGRect)rect {
[[UIColor redColor] setFill];
UIRectFill([self bounds]);
}
// good
[myView setBackgroundColor:[UIColor redColor]];
• 如果你必须重写drawRect:
,则尽可能调用setNeedsDisplayInRect:
而不是-setNeedsDisplay
。这将让Core Graphic自动为你的drawRect:代码创建clipRect:而无需改动任何其它代码,然后在绘制时自动忽略clipRect之外的部分,它能够显著提高性能;
• 如果你无法提前预知更新的视图范围,那么只在需要的时候调用-setNeedsDisplay
-
**正确处理图片(Be smart with images) **
• 使用UIImageView而不是直接绘制image。这是因为此时blending是发生在GPU中,而不是提前在CPU中混合,同时Core Animation能够高效的从UIImage(CGImage)中取出bitmap绘制到View中,然后自动的将bitmap进行缓存(Built-in bitmap caching)// bad
- (void)drawRect:(CGRect)rect
{
[self.image drawInRect:[self bounds]
blendMode:kCGBlendModeNormal alpha:1.0];
}
// good
myView.layer.contents = (id)[self.image CGImage];
• 尽可能使用没有Alpha通道的图片。这样能够最大限度的减少图层混合。
• 使用正确的图片格式。Apple强调在iOS中,PNG和JPEG是Apple官方推荐的格式,Xcode能够针对这两种格式做额外的优化,不要使用其它格式的图片比如TIFF等;
• 对UI上的缩略图使用单独的Image,而不是将原图缩放到小图里。这里对缩略图有一条规则“如果使用PNG能够在失真允许的情况下获得足够好的压缩效果,则使用PNG!”
• 关于缓存:
[UIImage imageNamed:]
将自动缓存在可删除的内存里(purgeable memory ),同时保存索引在 image table中以便复用;而[UIImage imageWithContentsOfFile:]
不会!
• 当你设置layer contents的时候,所有作为layer backstore的CGImages都会被缓存;
• 如果你手动创建一个CGImage,则打开kCGImageSourceShouldCache显式地建立缓存;
• 通常情况下,不要自己手动建立Image的缓存 - (void)drawRect:(CGRect)rect
关于图片的这部分,我们可以利用上文里提到的Instrument中的“Color Copied Image”debug选项,来检测是否有额外的图片操作是可以避免的。
-
异步绘制(Draw asynchronously)
这是一个比较少见的方法,但是Apple仍然提供了这一选择。当你使用-drawInContext:
为CALayer提供Content的时候,Core Animation 可以通过两种方式来进行渲染:
■ 普通绘图(Normal drawing)会同步的阻塞当前线程直至完成;
■ 异步绘图(Asynchronous drawing) 会将绘图命令发送到后台来完成渲染
异步方式默认是关闭的,因为它并不总是能够提高性能。通常当你需要在一个单一的很大的view context区域内完成images, rectangles, shadings等等的绘制会比较有效。如果想使用异步绘制,只需要在当前的绘制context layer内调用:
myView.layer.drawsAsynchronously = YES;
同样的,一定要测试才决定是否使用。
-
预处理数据(Speculative preparation )
提前对将要显示的内容进行计算查询,然后将数据放置在缓冲区里以便后面使用。一种经典的场景就是,一个长长的列表显示网络的图片,可以预先把后续需要加载的图片的下载,解码和绘制工作放在后台的线程里进行异步处理,然后等到需要显示时再在同步到UI上。关于这一点,下文第三部分将会重点强调这种并发的绘图操作的基本方式。(注意,这里的异步是数据的异步,而不是上文rendering本身的异步)
(2)动画平滑性优化
Animation的Step3发生在Render Server和GPU上:Render Server是以per layer, per frame的方式工作在CPU上,而Rendering本身是发生在GPU上。所以这一步同样是CPU Bound和GPU Bound同时存在。而影响动画的平滑性,更多的是GPU的负担过重,使用OpenGL ES instrument,查看Device Utilization的使用率,能够很好的指导你是否发生了GPU bound issue。记住我们一切一切的目标,就是60 fps!
-
减少Blending
使用Instrument的(Color Blended Layers)选项可以帮助我们定位此项优化操作的必要性。找到图层复杂的部分:
• 尝试进行视图层级精简;
• 尽可能的使用非透明图层/图片。因为Alpha通道的混合blending比绘制完全不透明的图层要慢很多 -
尝试扁平化Flattening
注意这里的扁平化不是说iOS 7倡导的UI设计的扁平化,而是说使用一些技术将立体的多层级Layer进行融合和渲染后绘制到单一视图上。如果你遇到的是GPU Bound的情景,Flattening view hierarchy 能够显著的帮助提高性能。
• 使用离屏缓冲区对内容进行flatten(即将当前视图内容刷到单独的Image data中)
• 尽可能缩短绘制图像时的CGPath;当需要画一个很大很复杂的CGPath时,尽可能只重绘你更新的那一部分,而不是整个path,而将其它的部分Flatten到bitmap中;
• 常见的Flatten方法:
1)Rasterization
对于一个单一的视图,如果视图内的Layer内容层级很复杂,但是内容(层级树和数据等)本身并不变化(或极少变化),则可以对base layer使用Rasterization(光栅化)。Rasterization其实是将当前layer及其所有sub layer的图像全部混合绘制到一个独立的bitmap中缓存起来,后续的显示将直接使用这个缓存的图片在硬件中进行组合而不再重新渲染。使用光栅化的方法很简单,在你需要的base layer上调用:
baseView.layer.rasterizationScale = [UIScreen mainScreen].scale
baseView.layer.shouldRasterize = true
但是使用rasterization需要注意:
■ 有限的缓存空间,大约是2.5x 屏幕的大小;
■ 内容一旦变化,缓存立即失效;
■ 如果当前显示超过100ms没有使用到当前缓存,则Rasterized images会被清除;
■ 任何你使用shouldRasterize
的地方都必须提前设定rasterizationScale
;
■ Rasterization发生在mask被应用之前,也就是说对于有mask的view,缓存的是被遮罩之前的内容;
你可能会想“Rasterization简直就是神器啊,那必须得用!” 别急,Rasterization并不是万能药!
实际上这是一个 time vs. memory trade-off,用存储空间交换渲染时间;同时是将部分Render server的工作转移到了CPU上;又由于在更新缓存内容时,有额外的offsceen操作,这些问题决定了太多的不合适的Rasterization会伤害到“响应性能responsiveness”,因此,对于非GPU-bound的场景,可能会适得其反!所以在做Rasterization之前,请确保在Core Animation instrument中使用 “Color cache hits/misses” 选项来测试你的想法。
2)Seperate Image
对于视图内的Layer或者内容变化频繁的场景,rasterization就不再适用了,因为如果做了Rasterization,但是缓存的cache没有被命中,将会造成比不缓存还要糟糕的消耗!这时可以将整个View的内容使用renderInContext绘制在一个独立的图片缓冲区内,然后在后续的绘制过程中,直接使用这张图片,而不是每次都重复绘制整个View层级。在后面的iPaint Demo里,我们将使用这个技术。
-
减少离屏渲染Offscreen Rendering
前文已经说过,离屏渲染因为会使得GPU从当前帧缓冲区之外额外的缓冲区进行绘制操作,然后切换环境把内容切换回当前帧缓冲区,会对性能造成非常大的影响。在Scroll/table view中几乎大部分常见的性能问题都是因为存在过多的离屏渲染造成的。因此在App中要尽可能的避免或者减少离屏渲染。造成离屏渲染的原因有很多,masking是最有可能的一个,阴影也是,而上文提到的rasterization也会至少产生一次offscreen rendering,但是并不能说rasterization就不能用,关键还是看使用的方式和场合。除此之外,还有其它一些情况会产生离屏渲染。本系列下一篇文章,将详细阐述离屏渲染的问题。
• 慎重使用阴影(Drop shadows)
这里单独强调一下阴影的问题。阴影是非常昂贵的渲染操作,尽可能的减少阴影的设计,如果必须使用阴影,则可以通过以下的方式来提高性能:
■ 使用shadowPath来预先定义阴影的形状,而不是让layer自己来判断阴影的区域:
view.layer.shadowColor = [UIColor grayColor].CGColor;
view.layer.shadowOffset = CGSizeMake(5, 5);
view.layer.shadowOpacity = 0.6;
// Very good to set the shadowpath explicitly
view.layer.shadowPath = [[UIBezierPath bezierPathWithRect:view.bounds] CGPath];
■ 对于静态的阴影视图,在使用上述方式生成阴影之后,再使用rasterization将阴影直接flatten成静态位图,这样后续的显示不用再重复绘制阴影,而是直接使用缓存显示。
view.layer.rasterizationScale = [UIScreen mainScreen].scale;
view.layer.shouldRasterize = YES;
在Core Animation Instrument中,可以通过“Color Offscreen-Rendered Yellow”选项来识别当前视图中发生offscreen rendering的区域。 -
认真对待滑动Scrolling
• 第一黄金原则:“重用cells and views”;
• 尽可能的减少layout和drawing的时间,也就是尽可能的减少视图层次复杂度和自定义drawRect:的消耗;
• 同样的,预先加载将要显示的视图数据,减少同步加载时间;
• 在视图结构不可避免的复杂情况时,尝试Flatten,但是需要测试验证;
• 及时取消已经滑出屏幕的cell的相关计算和绘制操作(见后文)
(3)并发
除了上面提到的具有针对性的2大部分内容,凡是提到“性能”两个字,有经验的人都会自然而然的想到另外两个字“并发”。并发实际上是充分利用了多核CPU的并行处理能力,让繁重的处理操作和界面响应操作能够分别在不同的线程里并行的完成,让界面的操作能够及时被响应而不被其它的耗时处理所堵塞。
根据长时高计算的操作的类别不同,可以将并发的方式分为“数据并发”和“绘图并发”:
-
并发数据处理(Process Data Concurrently)
Processing data blocks main thread.png
我们知道,iOS的所有UI事件响应(Touch Event)都是在主线程执行的,而主线程自动维护一个队列(Main Queue),所有的事件都会在队列里排队按照顺序执行。简单情况下,我们可能会将数据处理操作都放在主线程里去做,这样很直接很简单。但是在某些情况下,如果数据处理非常耗时,那么主线程会一直被阻塞在数据处理上,而UI的操作事件一直在排队无法执行,从而造成界面的卡顿现象:
如上图所示,Touch Event必须等待数据下载结束并处理完成后才能执行。这个时候,为了提高UI的性能,可以手动创建一个NSOperationQueue(你也可以使用async dispath_queue),将数据处理放到后台线程的队列里,当数据处理完成后,再将结果通过主线程更新到UI上:
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue setName:@”Data Processing Queue”];
[queue addOperationWithBlock:^{ processStock(someStock); }];
[queue addOperationWithBlock:^{
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
updateUI(someStock);
}];
}];
concurrent processing.png -
并发绘图(Draw Concurrently)
Blocking drawing.png
同样的道理可以类推到绘图操作上。常规情况,我们将所有的绘制都放在drawRect:
里,而drawRect:
是只能执行在主线程上。如果在drawRect:
做了大量的绘制操作,那么主线程会一直被阻塞,导致Touch Event不能被响应。
// Not so good if image drawing is very expensive
- (void)drawRect:(CGRect)rect {
[[UIColor greenColor] set];
UIRectFill([self bounds]);
[anImage drawAtPoint:CGPointZero];
}
这个时候,我们可以把绘制工作放在独立的线程队列里,然后把绘制结果生成一张图片,在通过主线程,把图片绘制在drawRect:
里达到同样的效果。
// Maybe good if drawing is very expensive
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue setName:@”Drawing Queue”];
[queue addOperationWithBlock:^{
UIImage *image = [self renderInImageOfSize:size]
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
[imageView setImage:image];
}];
}];- (UIImage *)renderInImageOfSize:(CGSize)size { UIGraphicsBeginImageContextWithOptions(size, NO, 0); [[UIColor greenColor] set]; UIRectFill([self bounds]); [anImage drawAtPoint:CGPointZero]; UIImage *i = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return i; }
在这种情况下,只需要注意以下两点:
•Drawing APIs在任何队列里调用都是安全的,不一定非得是主线程队列,但是必须确保begin和end在同一个上下文中(同一个operation中)
•最终必须在Main Queue中更新图片
-
适时取消并发操作(Canceling concurrent operations)
虽然有了并发的队列操作,也并不是说就不管不问放任Queue中的task去执行了,因为最终你还是需要更新UI的,为了让UI能够更快的响应用户的执行,我们需要让这个队列去更快更多去执行操作。因此在任务非常繁重的情况下,适时地取消队列中已经失效的操作是非常必要的。
举一个例子,如果你有一个table,每一个cell需要动态的显示一些图表(比如股票走势图,或者是从网络上下载更多的小图片),按照我们之前的建议,你已经把每一个cell的视图更新和绘制都放在了queue中,然后让这些queue中的task异步地去更新每一个cell,这很好,table能够继续响应用户的滑动了,但是当用户已经选择了一个具体的cell,不再对其它的内容感兴趣时,queue中还在继续执行很多计算或者是下载,当用户再添加新的操作到queue中,仍然还在等待那些他已经不感兴趣的cell内容的更新。
显然这是不必要的,这个时候,我们就可以取消队列中的操作来减轻queue的压力,使得它能够更快的响应后续其它的操作。
iOS提供了3种相关的取消操作API:
- [NSOperationQueue cancelAllOperations]
- [NSOperationQueue cancel]
- [NSOperation isCancelled]
• 使用[NSOperationQueue cancelAllOperations]
取消队列中的所有操作;
• 使用[NSOperation cancel]
取消单个操作;
注意上述两种方式都无法取消正在执行的operation,如果想让执行中的operation被中断,必须使用[NSOperation isCancelled]
来检测是否被取消:
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
NSBlockOperation *op = [[NSBlockOperation alloc] init];
__weak NSBlockOperation *weakOp = op;
[op addExecutionBlock:^{
for (int i = 0; i < 10000; i++) {
if ([weakOp isCancelled]) break;
processData(data[i]);
}
}];
[queue addOperation:op];
• 如果你在table view中使用了operation,可以在- tableView:didEndDisplayingCell:
中取消cell-related work。
2D Graphic Optimization Demo
在Apple的 **WWDC2012 Session 506 - "optimazing 2D graphics and animation performance" **上,有一个绘图Demo叫iPaint,很好的示例了多种不同的优化方法带来的效果。但是这个Demo并没有Code可以下载。于是我就模仿演讲者在Demo过程中提到的内容和屏幕上能够看到的一些片段的Code,自己重新写了一个Demo。你可以在Github上下载 iPaint Demo
iPaint_flatten_scale.gif因为篇幅的原因,我们只来看这个Demo的一个具体示例,你可以看到不同的开关对最终绘图的性能效果影响。
首先,在打开“Calculate Dirty Rect”开关计算更新区域并使用- setNeedDisplayInRect
之前,由于打开了Flatten Long Path选项,在绘画路径长到一定程度的时候,会将整个视图扁平化到一个单独的Image中,在第一个版本的实现上,我们可以看到效果非常差,FPS直接掉到10以下:
用Instrument查看OpenGL ES Driver,发现GPU的压力并不是很大,但是帧率很低:
Screen Shot 2016-06-28 at 5.03.56 PM.png
使用Time Profiler查看,发现CPU绝大部分的开销都在DrawImage上面:
Screen Shot 2016-06-28 at 4.51.05 PM.png那么在不改变当前Drawing策略的前提下,只是更改一个地方,打开Calculate Dirty Rect开关,每次drawInRect只是重新绘制当前更新的一小部分区域(红框内的区域),可以看到性能明显提升:
iPaint_flatten_scale_bad_dirty.gif Screen Shot 2016-06-28 at 5.08.48 PM.png但是,可以看到,帧率仍然不够理想,离目标60 fps还差得很远。观察CPU的使用,还是因为在Flatten Path的时候绘制图像的时间太长,可以在Time Profiler中,CGContextDrawImage仍然花了大部分时间。如果这个时候,关闭Flatten Path开关,帧率立即就上来了。
Screen Shot 2016-06-28 at 5.05.07 PM.png可是Flatten Path是有用的,否则随着Path的不断增长,帧率还是会下降,所以关键问题是在Flatten的时候,如何降低绘图的开销。这个问题一直困扰了我很久,因为WWDC视频的代码并没有显示作者是如何做Flatten的。(这个地方是不能用rasterization的,因为整个Layer的图层在不断变化,使用光栅是没有用的,必须手动将图层上的物件绘制到一张图片上)
最后,我终于发现了问题所在:因为在UIKit和Core Graphic的坐标系问题,直接使用UIGraphicsGetImageFromCurrentImageContext()
得到的图片是颠倒的,所以我原本使用的是一个Flip()函数,将CoreImage又重新绘制一次,这样颠倒2次就得到了正确的图片。可是这样会造成极大的性能损失,前面Time Profiler中检测到的CPU损耗原因就在此,于是我想到了额外的方法,是在drawRect中,将原先得到的颠倒的flatten image,使用Core Animation的转换矩阵CTM,直接将图片反转。只此改动,帧率立马到58左右!
所以从这点上,我们也许可以在上文中的优化策略中再增加一条:
- 尽量使用CTM变换矩阵来直接操作图形,而不要自己使用额外的计算和绘图方式
2016.8.12 补充优化内容:
关于CAShapeLayer和CALayer:
CAShapeLayer是一个通过矢量图形而不是bitmap来绘制的图层子类。你指定诸如颜色和线宽等属性,用CGPath来定义想要绘制的图形,最后CAShapeLayer就自动渲染出来了。当然,你也可以用Core Graphics直接向原始的CALyer的内容中绘制一个路径,相比直下,使用CAShapeLayer有以下一些优点:
- 渲染快速。CAShapeLayer使用了硬件加速,绘制同一图形会比用Core Graphics快很多。
- 高效使用内存。一个CAShapeLayer不需要像普通CALayer一样创建一个后备存储,所以无论有多大,都不会占用太多的内存。
- 不会被图层边界剪裁掉。一个CAShapeLayer可以在边界之外绘制。你的图层路径不会像在使用Core Graphics的普通CALayer一样被剪裁掉。
- 不会出现像素化。当你给CAShapeLayer做3D变换时,它不像一个有寄宿图的普通图层一样变得像素化。
总结
到此,此文中提到的各种优化场景和策略已经能够涵盖绝大多数情况下的问题,但是这些都是通用对策,并不是万能钥匙,只是给我们在遇到问题时提供对策的思路,至于真正的具体实施方案,对于每一个具体问题都是不同,我们需要不断的测试和调试来找到最佳的方案。
最后,用Apple WWDC上的两张图来简单总结一下遇到Graphic Performance问题的对策:
Paste_Image.png希望此文能帮助到你!
部分参考:
WWDC2012 Session 211 - Building Concurrent User Interfaces on iOS
WWDC2012 Session 238 - iOS App Performance Graphics and Animation
WWDC2012 Session 506 - Optimizing 2D Graphics and Animation Performance
WWDC2014 Session 419 - Advanced Graphics and Animation Performance
Designing for iOS: Graphics and Performance
Mastering UIKit Performance
iOS 保持界面流畅的技巧
When should I set layer.shouldRasterize to YES
WWDC心得与延伸:iOS图形性能
UIKit性能调优实战讲解
Layer Trees vs. Flat Drawing – Graphics Performance Across iOS Device Generations
2016.7.20 完稿于南京