iOS 图形性能优化

4,116 阅读16分钟

引言

当一个产品渐渐成熟,我们便开始重视产品性能的优化。而这其中图形性能的优化在iOS客户端占比较重要的部分。这里我们将介绍Core Animation的运行机制,首先我们不要被它的名字误导了,Core Animation不是只用来做动画的,iOS视图的显示都是通过它来完成的,所以我们想要优化图形性能必须了解Core Animation。下面我们根据苹果WWDC视频讲解来认识Core Animation工作机制,据此分析具体卡顿的原因,如何避免这些问题造成的卡顿,并且结合实际情况说明从哪些方面优化可以事半功倍。

Core Animation 工作机制

1.png-c
如上图所示,Core Animation在App将图层数据提交到应用外进程Render Server,这是Core Animation的服务端,把数据解码成GPU可执行的指令交给GPU执行。可以看出一个问题渲染服务并不是在App进程内进行的,也就是说渲染部分我们无法进行优化,我们可以优化的点只能在第一个提交事务的阶段。那么这个阶段Core Animation到底做了什么呢?下面我们一起来看看!

Commit Transaction

提交事务分为四个阶段:布局、显示、准备、提交。

Core Animation.png-c

  • 布局阶段 当调用addSubview时layer被加入到layer tree中,layoutSubviews被调用,创建view。同时还会进行数据查找,例如app做了本地化,label要显示这些本地化字符串必须从本地化文件中查找到对应语言的布局,这就涉及了I/O操作。所以这里主要是CPU工作,而瓶颈也会是CPU。
  • 显示阶段 在这个阶段如果你重写了drawRect方法,Core Graphics会进行绘制渲染工作。为视图绘制寄宿图即contents。但是drawRect里绘制的内容不会立即显示出来,而是先备换窜起来,等需要的时候被更新到屏幕上。如手动调用setNeedsDisplay或sizeThatFits被调用,也可以设置cententMode属性值为UIViewContentModeRedraw当每次bounds改变会自动调用setNeedsDisplay方法。这个阶段主要是CPU和内存的消耗,很多人喜欢用Core Graphics的方法来绘制图形,认为可以提高性能,后面我们会说明这个方法的弊端。
  • 准备阶段 这里的工作主要是图片的解码,因为大部分都是编码后的图片,要读取原始数据必须经过编码过程。并且当我们使用了iOS不支持的图片格式,即不支持硬编码,就需要进行转化工作,也是比较耗时的。所以这里就是GPU消耗,如果进行软解码也要消耗CPU。
  • 提交阶段 最后一个阶段负责打包图层数据并发送到我们上面说的渲染服务中。这个过程是一个递归操作,图层树越复杂越是需要消耗更多资源。像CALaler有很多隐式动画属性也会在这里提交,省去了多次动画属性进程间的交互,提高了性能。

优化

根据上面我们所提到4个阶段,我们看看哪些因素会影响到App的性能,并且如何优化可以提高我们App的性能。

混合

平时我们写代码的时候,往往会给不同的CALayer添加不同的颜色,不同的透明度,我们最后看到是所有这些层CALayer混合出的结果。

那么在iOS中是如何进行混合的?前面我们说明了每个像素都包含了R(红)、G(绿)、B(蓝)和R(透明度),GPU要计算每个像素混合来的RGB值。那么如何计算这些颜色的混合值呢?假设在正常混合模式下,并且是像素对齐的两个CALayer,混合计算公式如下:

R = S + D * ( 1 – Sa )

苹果的文档中有对每个参数的解释:

The blend mode constants introduced in OS X v10.5   represent the Porter-Duff blend modes. The symbols in the   equations for these blend modes are:

    * R is the premultiplied result

    * S is the source color, and includes alpha

    * D is the destination color, and includes alpha

    * Ra, Sa, and Da are the alpha components of R, S, and D

R就是得到的结果色,S和D是包含透明度的源色和目标色,其实就是预先乘以透明度后的值。Sa就是源色的透明度。iOS为我们提供了多种的Blend mode:

 /* Available in Mac OS X 10.5 & later. R, S, and D are, respectively,
   premultiplied result, source, and destination colors with alpha; Ra,
   Sa, and Da are the alpha components of these colors.

   The Porter-Duff "source over" mode is called `kCGBlendModeNormal':
     R = S + D*(1 - Sa)

   Note that the Porter-Duff "XOR" mode is only titularly related to the
   classical bitmap XOR operation (which is unsupported by
   CoreGraphics). */

kCGBlendModeClear,                  /* R = 0 */
kCGBlendModeCopy,                   /* R = S */
kCGBlendModeSourceIn,               /* R = S*Da */
kCGBlendModeSourceOut,              /* R = S*(1 - Da) */
kCGBlendModeSourceAtop,             /* R = S*Da + D*(1 - Sa) */
kCGBlendModeDestinationOver,        /* R = S*(1 - Da) + D */
kCGBlendModeDestinationIn,          /* R = D*Sa */
kCGBlendModeDestinationOut,         /* R = D*(1 - Sa) */
kCGBlendModeDestinationAtop,        /* R = S*(1 - Da) + D*Sa */
kCGBlendModeXOR,                    /* R = S*(1 - Da) + D*(1 - Sa) */
kCGBlendModePlusDarker,             /* R = MAX(0, (1 - D) + (1 - S)) */
kCGBlendModePlusLighter             /* R = MIN(1, S + D) */

似乎计算也不是很复杂,但是这只是一个像素覆盖另一个像素简单的一步计算,而正常情况我们现实的界面会有非常多的层,每一层都会有百万计的像素,这都要GPU去计算,负担是很重的。

像素对齐

像素对齐就是视图上像素和屏幕上的物理像素完美对齐。上面我们说混合的时候,假设的情况是多个layer是在每个像素都完全对齐的情况下来进行计算的,如果像素不对齐的情况下,GPU需要进行Anti-aliasing反抗锯齿计算,GPU的负担就会加重。像素对齐的情况下,我们只需要把所有layer上的单个像素进行混合计算即可。

那么什么原因造成像素不对齐?主要有两点:

  1. 图片大小和UIImageView大小不符合2倍3倍关系时,如一张12x12二倍,18x18三倍的图,UIimageView的size为6x6才符合像素对齐。
  2. 边缘像素不对齐,即起始坐标不是整数,可以使用CGRectIntegral()方法去除小数位。 这两点都有可能造成像素不对齐。如果想获得更好的图形性能,作为开发者要尽可能得避免这两种情况。

不透明

上面我们说过一个混合计算的公式:

R = S + D * ( 1 – Sa )

如果Sa值为1,也就是源色对应的像素不透明。那么得到R = S,这样就只需要拷贝最上层的layer,不需要再进行复杂的计算了。因为下面层的layer全部是可不见的,所以GPU无需进行混合计算了。如何让GPU知道这个图像是不透明的呢?如果使用的是CALayer,那么要把opaque属性设置成YES(默认是NO)。而若只用的是UIView,opaque默认属性是YES。当GPU知道是不透明的时候,只会做简单的拷贝工作,避免了复杂的计算,大大减轻了GPU的工作量。

如果加载一个没有alpha通道的图片,opaque属性会自动设置为YES。但是如果是一个每个像素alpha值都为100%的图片,尽管此图不透明但是Core Animation依然会假定是否存在alpha值不为100%的像素。

解码

上一篇文章我们有说到,一般在Core Animation准备阶段,会对图片进行解码操作,即把压缩的图像解码成位图数据。这是一个很消耗CPU的事情。系统是在图片将要渲染到屏幕之前再进行解码,而且默认是在主线程中进行的。所以我们可以将解码放在子线程中进行,下面简单列举一种解码方式:

NSString *picPath = [[NSBundle mainBundle] pathForResource:@"tests" ofType:@"png"];
NSData *imageData = [NSData dataWithContentsOfFile:picPath];//读取未解码图片数据
        
CGImageSourceRef imageSourceRef = CGImageSourceCreateWithData((__bridge CFTypeRef)imageData, NULL);
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(imageSourceRef, 0, (CFDictionaryRef)@{(id)kCGImageSourceShouldCache:@(NO)});
CFRelease(imageSourceRef);
size_t width = CGImageGetWidth(imageRef);//获取图片宽度
size_t height = CGImageGetHeight(imageRef);//获取图片高度
CGColorSpaceRef colorSpace = CGImageGetColorSpace(imageRef);
        
size_t bitsPerComponent = CGImageGetBitsPerComponent(imageRef);//每个颜色组件占的bit数
size_t bitsPerPixel = CGImageGetBitsPerPixel(imageRef);//每个像素占几bit
size_t bytesPerRow = CGImageGetBytesPerRow(imageRef);//位图数据每行占多少bit
CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(imageRef);
        
CGDataProviderRef dataProvider = CGImageGetDataProvider(imageRef);
CFRelease(imageRef);
CFDataRef dataRef = CGDataProviderCopyData(dataProvider);//获得解码后数据
CGDataProviderRef newProvider = CGDataProviderCreateWithCFData(dataRef);
CFRelease(dataRef);
        
CGImageRef newImageRef = CGImageCreate(width, height, bitsPerComponent, bitsPerPixel, bytesPerRow, colorSpace, bitmapInfo, newProvider, NULL, false, kCGRenderingIntentDefault);
CFRelease(newProvider);
        
UIImage *image = [UIImage imageWithCGImage:newImageRef scale:2.0 orientation:UIImageOrientationUp];
CFRelease(newImageRef);

另外,在iOS7之后苹果提供了一个属性kCGImageSourceShouldCacheImmediately,在CGImageSourceCreateImageAtIndex方法中,设置kCGImageSourceShouldCacheImmediatelykCFBooleanTrue的话可以立刻开始解压缩,默认为kCFBooleanFalse。当然也像AFNetworking 中使用void CGContextDrawImage(CGContextRef __nullable c, CGRect rect, CGImageRef __nullable image)方法也可以实现解码,具体实现不在此赘述。

字节对齐

我们前面说像素对齐时,简单介绍了字节对齐。那么到底什么是字节对齐?为什么要字节对齐?和我们优化图形性能有什么关系呢?

字节对齐是对基本数据类型的地址做了一些限制,即某种数据类型对象的地址必须是其值的整数倍。例如,处理器从内存中读取一个8个字节的数据,那么数据地址必须是8的整数倍。

对齐是为了提高读取的性能。因为处理器读取内存中的数据不是一个一个字节读取的,而是一块一块读取的一般叫做cache lines。如果一个不对齐的数据放在了2个数据块中,那么处理器可能要执行两次内存访问。当这种不对齐的数据非常多的时候,就会影响到读取性能了。这样可能会牺牲一些储存空间,但是对提升了内存的性能,对现代计算机来说是更好的选择。

在iOS中,如果这个图像的数据没有字节对齐,那么Core Animation会自动拷贝一份数据做对齐处理。这里我们可以提前做好字节对齐。在方法CGBitmapContextCreate(void * __nullable data, size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow, CGColorSpaceRef __nullable space, uint32_t bitmapInfo)中,有一个参数bytesPerRow,意思是指定要使用的位图每行内存的字节数,ARMv7架构的处理器的cache lines是32byte,A9处理器的是64byte,这里我们要使bytesPerRow为64的整数倍。具体可以参考官方文档Quartz 2D Programming GuideWWDC 2012 Session 238 "iOS App Performance: Graphics and Animations"。字节对齐,在一般情况下,感觉对性能的影响很小,没必要的情况不要过早优化。

离屏渲染

离屏渲染(Off-Screen Rendering)是指GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。离屏渲染是很消耗性能的,因为首先要创建屏幕外缓冲区,还要进行两次上下文环境切换。先切换到屏幕外环境,离屏渲染完成后再切换到当前屏幕,上下文的切换是很高昂的消耗。产生离屏渲染的原因就是这些图层不能直接绘制在屏幕上,必须进行预合成。

产生离屏渲染的情况大概有几种: 1.cornerRadiusmasksToBounds(UIView中是clipToBounds)一起使用的时候,单独使用不会触发离屏渲染。cornerRadius只对背景色起作用,所以有contents的图层需要对其进行裁剪。 2.为图层设置mask(遮罩)。 3.layer的allowsGroupOpacity属性为YES且opacity小于1.0,GroupOpacity是指子图层的透明度值不能大于父图层的。 4.设置了shadow(阴影)。

上面这几种情况都是GPU的离屏渲染,还有一种特殊的CPU离屏渲染。只要实现Core Graphics绘制API会产生CPU的离屏渲染。因为它也不是直接绘制到屏幕上的,而且先创建屏幕外的缓存。

我们如何解决这几个产生离屏渲染的问题呢?首先,GroupOpacity对性能几乎没有影响,在此就不多说了。圆角是一个无法避免的,网上有很多例子是用Core Graphics绘制来代替系统圆角的,但是Core Graphics是一种软件绘制,利用的是CPU,性能上要差上不少。当然在CPU利用率不是很高的界面是个不错的选择,但是有时候某个界面可能需要CPU去做其他消耗很大的事情,如网络请求。这个时候时候在用Core Graphics绘制大量的圆角图形就有可能出现掉帧。这种情况怎么办呢?最好的就是设计师直接提供圆角图像。还有一种折中的方法就是在混合图层,在原图层上覆盖一个你要的圆角形状的图层,中间需要显示的部分是透明的,覆盖的部分和周围背景一致。

对于shadow,如果图层是个简单的几何图形或者圆角图形,我们可以通过设置shadowPath来优化性能,能大幅提高性能。示例如下:

imageView.layer.shadowColor = [UIColor grayColor].CGColor;
imageView.layer.shadowOpacity = 1.0;
imageView.layer.shadowRadius = 2.0;
UIBezierPath *path = [UIBezierPath bezierPathWithRect:imageView.frame];
imageView.layer.shadowPath = path.CGPath;

我们还可以通过设置shouldRasterize属性值为YES来强制开启离屏渲染。其实就是光栅化(Rasterization)。既然离屏渲染这么不好,为什么我们还要强制开启呢?当一个图像混合了多个图层,每次移动时,每一帧都要重新合成这些图层,十分消耗性能。当我们开启光栅化后,会在首次产生一个位图缓存,当再次使用时候就会复用这个缓存。但是如果图层发生改变的时候就会重新产生位图缓存。所以这个功能一般不能用于UITableViewCell中,cell的复用反而降低了性能。最好用于图层较多的静态内容的图形。而且产生的位图缓存的大小是有限制的,一般是2.5个屏幕尺寸。在100ms之内不使用这个缓存,缓存也会被删除。所以我们要根据使用场景而定。

Instruments

上面我们说了这么多性能相关的因素,那么我们怎么进行性能的测试,怎么知道哪些因素影响了图形性能?苹果很人性得为我们提供了一个测试工具Instruments。可以在Xcode->Open Develeper Tools->Instruments中找到,我们看到这里面有很多的测试工具,像大家可能常用的检测内存泄漏的Leaks,在这里我们就讨论下Core Animation这个工具的使用。

Core Animation工具用来监测Core Animation性能。提供可见的FPS值。并且提供几个选项来测量渲染性能,下面我们来说明每个选项的能: Color Blended Layers:这个选项如果勾选,你能看到哪个layer是透明的,GPU正在做混合计算。显示红色的就是透明的,绿色就是不透明的。

Color Hits Green and Misses Red:如果勾选这个选项,且当我们代码中有设置shouldRasterize为YES,那么红色代表没有复用离屏渲染的缓存,绿色则表示复用了缓存。我们当然希望能够复用。

Color Copied Images:按照官方的说法,当图片的颜色格式GPU不支持的时候,即不是32bit的颜色格式,Core Animation会 拷贝一份数据让CPU进行转化。例如从网络上下载了8bit的颜色格式的图片,则需要CPU进行转化,这个区域会显示成蓝色。还有一种情况会触发Core Animation的copy方法,就是字节不对齐的时候。

Color Misaligned Images:勾选此项,如果图片需要缩放则标记为黄色,如果没有像素对齐则标记为紫色。像素对齐我们已经在上面有所介绍。

Color Offscreen-Rendered Yellow:用来检测离屏渲染的,如果显示黄色,表示有离屏渲染。当然还要结合Color Hits Green and Misses Red来看,是否复用了缓存。

Color OpenGL Fast Path Blue:这个选项对那些使用OpenGL的图层才有用,像是GLKView或者 CAEAGLLayer,如果不显示蓝色则表示使用了CPU渲染,绘制在了屏幕外,显示蓝色表示正常。

Flash Updated Regions:当对图层重绘的时候回显示黄色,如果频繁发生则会影响性能。可以用增加缓存来增强性能。官方文档Improving Drawing Performance有所说明。

总结

结合前面两章内容,我们发现,一个简单的图片显示在屏幕上,要经过很多步骤,并且有许多硬件的参与。最主要的就是CPU和GPU,协调他们之间的工作是高性能得关键。

因为图形的性能和两者都有关系,CPU主要负责软解码、I/O相关、布局的计算等工作,如果使用Core Graphics绘图API那么也会用到CPU。GPU的主要责任就是合成渲染。为了能够得到最好的性能,我们就要找出是哪个限制了性能,CPU过度利用还是GPU负担太大。通过苹果给出的Instruments里面的测试工具,我们在真机上一次次的测试,才能正确的判断出无法保证画面60FPS的原因。必须平衡两者,才能达到最好的性能。

下面我们总结几个优化点: 1.尽量使用iOS优化处理的图片格式,减少CPU软解码的负担。 2.能不透明的不要使用透明度,减少混合计算。 3.不要让图层过于复杂,不然增加了处理图层,打包传送到渲染服务的工作量,GPU渲染负担也会增大。 4.最好不要使用离屏渲染,必须使用的话最好能够复用缓存,离屏渲染对性能影响是最大的。 5.布局不要过于复杂,如果必须要复杂的布局,可以提前缓存布局数据。 6.不要滥用多线程,因为创建和销毁线程不仅增加CPU任务量,而且会消耗内存。

最后需要说明的就是不要过早和过度得优化,过犹不及。过早优化得不偿失,反而耗时耗力。过度优化有时候适得其反。

视频参考:WWDC 2015’s session 233:Advanced Touch Input on iOS

文档参考:objccn