性能优化是一个很重要的一部分,我们首先看CPU和GPU的部分,想知道CPU和GPU是怎么优化的,就必须要明白CPU和GPU的原理.

1. 屏幕的成像原理

成像原理.png

首先从过去的 CRT 显示器原理说起。CRT 的电子枪按照上面方式,从上到下一行行扫描,扫描完成后显示器就呈现(一帧画面),随后电子枪回到初始位置继续下一次扫描。

为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。

显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。尽管现在的设备大都是液晶显示屏了,但原理仍然没有变。

显示流程图.png

通常来说,计算机系统中 CPU、GPU、显示器是以上面这种方式协同工作的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。

在最简单的情况下,帧缓冲区只有一个,这时帧缓冲区的读取和刷新都都会有比较大的效率问题。为了解决效率问题,显示系统通常会引入两个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。

双缓冲虽然能解决效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象。

为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

下图就是iOS应用界面渲染到展示的流程:

iOS应用界面渲染到展示1.jpg

iOS应用界面渲染到展示2.png

Display 的上一层便是图形处理单元 GPU,GPU 是一个专门为图形高并发计算而量身定做的处理单元。这也是为什么它能同时更新所有的像素,并呈现到显示器上。它并发的本性让它能高效的将不同纹理合成起来。我们将有一小块内容来更详细的讨论图形合成。关键的是,GPU 是非常专业的,因此在某些工作上非常高效。比如,GPU 非常快,并且比 CPU 使用更少的电来完成工作。通常 CPU 都有一个普遍的目的,它可以做很多不同的事情,但是合成图像在 CPU 上却显得比较慢。

一. 卡顿产生的原因

由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

CPU(红色)——>GPU(蓝色)

1.CPU完成计算,提交给GPU渲染,这是来个垂直同步信号,则会将渲染的内容显示到屏幕上。

2.CPU计算时间正常,CPU渲染时间短,等待VSync

3.CPU计算时间正常或慢,GPU渲染时间长,这时来了VSync,而这一帧还没有渲染完,那么就会出现掉帧现象,屏幕回去显示上一帧的画面。这样就产生了卡顿。

4.而当下一帧VSync出现时,丢掉的那一帧画面才会出现。

图片卡顿原理.png

因此,我们需要平衡 CPU 和 GPU 的负荷避免一方超负荷运算。为了做到这一点,我们首先得了解 CPU 和 GPU 各自负责哪些内容。

二. CPU和GPU的职责

iOS成像层次.jpg

在 iOS 系统中,图像内容展示到屏幕的过程需要 CPU 和 GPU 共同参与。

CPU 负责计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。

随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。

之后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。

A. CPU卡顿分析

a. 布局计算

视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这个地方基本就不会产生性能问题了。

不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center 等属性的调整上。上面也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。

b. 对象创建

对象创建过程伴随着内存分配、属性设置、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,如果视图元素不需要响应触摸事件,用 CALayer 会更加合适。

通过 Storyboard 创建视图对象还会涉及到文件反序列化操作,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择。

c. Autolayout

Autolayout 是苹果本身提倡的技术,在大部分情况下也能很好的提升开发效率,但是 Autolayout 对于复杂视图来说常常会产生严重的性能问题。随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。具体数据可以看这个文章:http://pilky.me/36/。 如果你不想手动调整 frame 等属性,你可以用一些工具方法替代(比如常见的 left/right/top/bottom/width/height 快捷属性),或者使用 ComponentKit、AsyncDisplayKit 等框架。

d. 文本计算

如果一个界面中包含大量文本(比如微博、微信朋友圈等),文本的宽高计算会占用很大一部分资源,并且不可避免。

一个比较常见的场景是在 UITableView 中,heightForRowAtIndexPath

这个方法会被频繁调用。这里的优化就是尽量避免每次都重新进行文本的行高计算,缓存高度即可。

e. 文本渲染

文本分部.jpg

屏幕上能看到的所有文本内容控件,包括 UIWebView,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。

f.图像的绘制

图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示的过程。前面的模块图里介绍了 CoreGraphic 是作用在 CPU 之上的,因此调用 CG 开头的方法消耗的是 CPU 资源。我们可以将绘制过程放到后台线程,然后在主线程里将结果设置到 layer 的 contents 中。代码如下:

- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}

g.图片的解码

Once an image file has been loaded, it must then be decompressed. This decompression can be a computationally complex task and take considerable time. The decompressed image will also use substantially more memory than the original.(图片被加载后需要解码,图片的解码是一个复杂耗时的过程,并且需要占用比原始图片还多的内存资源)

①.为什么图片需要解码

把图片从PNG或JPEG等格式中解压出来,得到像素数据。如果GPU不支持这种颜色各式,CPU需要进行格式转换。

比如应用中有一些从网络下载的图片,而GPU恰好不支持这个格式,这就需要CPU预先进行格式转化。SDwebImageDecoder就是这个作用。

②.默认延迟解码

当你用 UIImage 或 CGImageSource 的那几个方法创建图片时,为了节省内存,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。

如果想要绕开这个机制,可以使用 ImageIO (怎么使用?)或者提前在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。目前常见的网络图片库都自带这个功能。

③.不一定是默认延迟解码

常用的 UIImage 加载方法有 imageNamed和 imageWithContentsOfFile。其中 imageNamed加载图片后会马上解码,并且系统会将解码后的图片缓存起来,但是这个缓存策略是不公开的,我们无法知道图片什么时候会被释放。因此在一些性能敏感的页面,我们还可以用 static 变量 hold 住 imageNamed加载到的图片避免被释放掉,以空间换时间的方式来提高性能。

imageWithContentsOfFile解码后的UIImage对象如果作为临时变量被释放了,则它下次仍然会解码。

//图片解码的代码
- (void)image
{
UIImageView *imageView = [[UIImageView alloc] init];
imageView.frame = CGRectMake(100, 100, 100, 56);
[self.view addSubview:imageView];
self.imageView = imageView;
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 获取CGImage
CGImageRef cgImage = [UIImage imageNamed:@"timg"].CGImage;
// alphaInfo
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// bitmapInfo
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
// size
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
// context
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
// draw
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
// get CGImage
cgImage = CGBitmapContextCreateImage(context);
// into UIImage
UIImage *newImage = [UIImage imageWithCGImage:cgImage];
// release
CGContextRelease(context);
CGImageRelease(cgImage);
// back to the main thread
dispatch_async(dispatch_get_main_queue(), ^{
self.imageView.image = newImage;
});
});
}

我们来总结一下处理CPU的卡顿:

1.尽量用轻量级的对象,比如用不到事件处理的地方,可以考虑使用CAlayer取代UIView;能用基本数据类型,就别用NSNumber类型。

2.不要频繁地跳用UIVIew的相关属性,比如frame、bounds、transform等属性,尽量减少不必要的修改。

3.尽量提前计算好布局,在有需要时一次性调整对应的布局,不要多次修改属性。

4.Autolayout会比直接设置frame消耗更多的CPU资源。

5.图片的size最好刚好跟UIImageView的size保持一致。

6.控制一下线程的最大并发数量。

7.尽量把耗时的操作放到子线程。

8.文本处理(尺寸的计算,绘制)。

9.图片处理(解码、绘制)。

B. GPU卡顿分析

1.On-SCreen Rendering:当前屏幕渲染,在当前用语显示的屏幕缓冲区进行渲染操作。

2.Off-Screen Rendring: 离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。

相对于 CPU 来说,GPU 能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合并渲染,然后输出到屏幕上。宽泛的说,(大多数 CALayer 的属性都是用 GPU 来绘制)。

a. 以下一些操作会降低 GPU 绘制的性能:

①.大量几何结构

所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。

避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。

另外当图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。目前来说,iPhone 4S 以上机型,(纹理尺寸上限都是 4096x4096),更详细的资料可以看这里:iosres.com。所以,尽量不要让图片和视图的大小超过这个值。

②.视图以及图层的混合

屏幕上每一个点都是一个像素,像素有R、G、B三种颜色构成(有时候还带有alpha值)。如果某一块区域上覆盖了多个layer,最后的显示效果受到这些layer的共同影响。举个例子,上层是蓝色(RGB=0,0,1),透明度为50%,下层是红色(RGB=1,0,0)。那么最终的显示效果是紫色(RGB=0.5,0,0.5)。

公式:

0.5 0 0.5
R = S + D * (1 - Sa) = 0 + 0 * (1 - 0.5) = 0
0 1 0.5

当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,(应用应当尽量减少视图数量和层次,并且减少不必要的透明视图)

b.离屏渲染

离屏渲染是指图层在被显示之前,GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。离屏渲染耗时是发生在离屏这个动作上面,而不是渲染。为什么离屏这么耗时?原因主要有创建缓冲区和上下文切换。创建新的缓冲区代价都不算大,付出最大代价的是上下文切换。

①. 上下文切换

不管是在GPU渲染过程中,还是一直所熟悉的进程切换,上下文切换在哪里都是一个相当耗时的操作。首先我要保存当前屏幕渲染环境,然后切换到一个新的绘制环境,申请绘制资源,初始化环境,然后开始一个绘制,绘制完毕后销毁这个绘制环境,如需要切换到On-Screen Rendering或者再开始一个新的离屏渲染重复之前的操作。

②. 渲染流程

我们先看看最基本的渲染通道流程:

iOS UI Arch.jpg

我们再来看看需要Offscreen Render的渲染通道流程:

masking process.jpg

一般情况下,OpenGL会将应用提交到Render Server的动画直接渲染显示(基本的Tile-Based渲染流程),但对于一些复杂的图像动画的渲染并不能直接渲染叠加显示,而是需要根据Command Buffer分通道进行渲染之后再组合,这一组合过程中,就有些渲染通道是不会直接显示的;对比基本渲染通道流程和Masking渲染通道流程图,我们可以看到到Masking渲染需要更多渲染通道和合并的步骤;而这些没有直接显示在屏幕的上的通道(如上图的 Pass 1 和 Pass 2)就是Offscreen Rendering Pass。

Offscreen Render为什么卡顿,从上图我们就可以知道,Offscreen Render需要更多的渲染通道,而且不同的渲染通道间切换需要耗费一定的时间,这个时间内GPU会闲置,当通道达到一定数量,对性能也会有较大的影响;

为什么会产生离屏渲染?

首先,OpenGL提交一个命令到Command Buffer,随后GPU开始渲染,渲染结果放到Render Buffer中,这是正常的渲染流程。【但是有一些复杂的效果无法直接渲染出结果,它需要分步渲染最后再组合起来】,比如添加一个蒙版(mask)。

会造成 offscreen rendering 的原因有:

阴影(UIView.layer.shadowOffset/shadowRadius/…)

圆角(当 UIView.layer.cornerRadius 和 UIView.layer.maskToBounds 一起使用时)

图层蒙板(Mask)

开启光栅化(shouldRasterize = true,同时设置 rasterizationScale)

③. Mask

一个图层可以有一个和它相关联的 mask(蒙板),mask 是一个拥有 alpha 值的(位图)(不是矢量图,所以矢量图是不能作为遮罩)。只有在 mask 中显示出来的(即图层中的部分)才会被渲染出来。

使用阴影时同时设置 shadowPath 就能避免离屏渲染大大提升性能,圆角触发的离屏渲染可以用 CoreGraphics 将图片处理成圆角来避免。

CALayer 有一个 shouldRasterize 属性,将这个属性设置成 true 后就开启了光栅化。

④. 光栅化

光栅化其实是一种将几何图元变为二维图像的过程。

你模型的那些顶点在经过各种矩阵变换后也仅仅是顶点。而由顶点构成的图形要在屏幕上显示出来,除了需要顶点的信息以外,还需要确定构成这个图形的所有像素的信息。

光栅化优缺点

开启光栅化后会将图层绘制到一个屏幕外的图像,然后这个图像将会被缓存起来并绘制到实际图层的 contents 和子图层,对于有很多的子图层或者有复杂的效果应用,这样做就会比重绘所有事务的所有帧来更加高效。但是光栅化原始图像需要时间,而且会消耗额外的内存。

光栅化也会带来一定的性能损耗,是否要开启就要根据实际的使用场景了,图层内容频繁变化时不建议使用。最好还是用 Instruments 比对开启前后的 FPS 来看是否起到了优化效果。

我们来总结一下:

离屏渲染消耗性能的原因:

①.需要创建新的缓冲区;

②.离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕切换到离屏;等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕;

哪些操作会出发离屏渲染?

①.光栅化,layer.shouldRasterize = YES;

②.遮罩,layer.mask;

③.圆角,同时设置layer.maskToBounds = Yes,Layer.cornerRadis 大于

考虑通过CoreGraphics绘制裁剪圆角,或者美工提供圆角图片;

④.阴影,layer.shadowXXX,如果设置了layer.shadowPath就不会产生离屏渲染;

处理GPU的卡顿:

①.尽量减少视图数量和层次。

②.GPU能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸。

③.尽量避免段时间内大量图片的显示,尽可能将多张图片合成一张图片显示。

④.减少透明的视图(alpha<1),不透明的就设置opaque为yes。

⑤.尽量避免出现离屏渲染。

3. 卡顿检测

平时所说的“卡顿”主要是因为在主线程执行了比较耗时的操作

可以添加Observer到主线程RunLoop中,通过监听RunLoop状态切换的耗时,以达到监控卡顿的目的