iOS性能优化之帧率优化

影响帧率的原因

屏幕显示图像的原理

一般而言,计算机系统中CPU、GPU以及显示器以以上这种方式协同工作。CPU计算好需要会显示的内容提交给GPU,GPU渲染完成后将渲染结果存入缓存区,随后视频控制器根据VSync信号,将缓冲区的内容显示到显示器上。

在这个过程中,CPU的主要职责是计算GPU的主要职责是渲染

影响帧率的原因

* CPU的资源消耗原因
  1. 对象的创建

对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗资源。尽量用轻量级的对象代替重量对象,可以对内存有所优化。比如CALayer比UIView要轻量很多,因为不需要相应触摸事件。如果对象不涉及UI操作尽量放到后台线程去创建。,另外通过Storyboard创建视图对象时,要比直接通过代码创建对象消耗非常多的资源。

  1. 对象的调整

    对象的调整也是消耗CPU资源的原因之一。特别指出:CALayer内部并没有属性,当调用属性方法时,它内部是通过Runtime的resolveInstanceMethod为对象临时添加一个方法,并把对应属性值保存在内部的一个Dictionary中,同时还会通知delegate、创建动画等等,非常消耗资源,UIView关于显示相关属性的调整实际上都是对CALayer属性的调整。

  2. 对象的销毁

对象销毁虽然对资源的消耗不多,但是积累起来也不容忽视。通常容器类持有大量对象时,其销毁时资源的消耗就非常明显。如果对象能放到后台释放,那就放到后台释放。为了避免编译器警告,把对象捕获到Block,再扔到后台队列随便发送个消息就OK。

1
2
3
4
5
SArray *tmp = self.array;
self.array = nil;
dispatch_async(queue, ^{
[tmp class];
});
  1. 布局计算

布局计算是App种最为常见的资源消耗的地方,如果能在后台提前计算好布局、并对布局进行缓存,那么就不会产生性能问题了。还有要尽量避免多次频繁的计算UIView.frame/bounds/center 等属性。因为调整这些属性非常消耗性能。

  1. 文本计算

如果一个页面中包含大量文本,文本的宽高会占用很大一部分资源。可以使用[NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程。

  1. 文本渲染

常见的文本控件(UILabel、UITextView等)、其排版和绘制都是在主线程进行的,当文本数量很大时,CPU压力非常大。对此的解决方案是,自定义文本控件,用TextKit或者CoreText对文本异步绘制。可以参考YYTextKit。

  1. 图片的解码

UIImage或者CGImageSource创建的图片,并不会立刻解码。当图片设置到UIImageView或者CALayer.contents中,且CALayer被提交到CPU前,CGImage才会被解码,这一步发生在主线程。想要绕开这个机制,常见的做法是在后台线程将图片绘制到CGBitmapContext中,然后冲Bitmap中直接创建图片。目前常见的图片库都带有这个功能。

  1. 图像的绘制

图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。大致过程如下:

1
2
3
4
5
6
7
8
9
10
11
- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}
* GPU的资源消耗
  1. 纹理的渲染

所有的bitmap,包括图片、文本、栅格化的内容,最终都是有内存提交到显存,绑定为GPU Texture。不论是提交显存的过程,还是GPU调整和渲染Texture的过程都要消耗不少GPU资源。 短时间内显示大量图片时(比如UITableView 存在大量图片并快速滑动时),CPU占用了率很低,GPU却非常高,界面仍然会掉帧。针对这种情况只能将减少短时间显示大量图片,尽可能将多张图片合成一张进行显示。
另外当图片过大,超过GPU的最大纹理尺寸时,需要先交由CPU进行预处理。这对CPU个GPU都会带来额外的消耗。

  1. 视图的混合

当多个视图重叠在一起显示时,GPU首先会把它们混合到一起,再进行渲染。如果视图结构过于复杂,混合过程也会消耗很多GPU资源。最好的办法是,尽量减少视图层级和数量,并在不透明的视图里标明opaque属性以避免无用的Alpha通道合成

  1. 图形的生成

离屏渲染,CALayer的 border、圆角、阴影、遮罩,CASharpLayer的矢量图显示,通常会触发离屏渲染。而离屏渲染通常发生在GPU中。开启 CALayer.shouldRasterize属性会使视图渲染内容被缓存起来,下次绘制的时候可以直接显示缓存。而最彻底的解决办法是:把需要显示的图型在后台线程绘制为图片,避免使用圆角、阴影遮罩等属性。

如何发现被定位卡顿问题

在了解了影响帧率的原因以及解决办法后,我们可以在开发过程中应该提高警惕,尽量避免写影响性能的代码。但是当我们接手一个新的业务模块时,应该如何发现卡顿问题并定位呢?

卡顿检测工具

目前业界比较通用的卡顿检测方案是:FPS检测 + 主线程RunLoop检测

其主要原理是,开辟一个子线程检测主线程RunLoop,检测kCFRunLoopBeforeSources与kCFRunLoopAfterWaiting这两个状态间的耗时,并综合FPS指标的来检测卡顿。

监听主线程RunLoop:

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
- (void)start
{

// 创建信号 用于阻塞子线程
semaphore = dispatch_semaphore_create(0);

// 注册RunLoop状态观察
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);

// 开启子线程监控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 16*NSEC_PER_MSEC));
if (st != 0)
{
//主线程超时,综合FPS判断是佛卡顿,并dump堆栈信息
}
}
});
}

FPS 检测基于DisplayLink计算帧率。
卡顿堆栈信息获取,这里就不展开了,可以参考这篇文章获取任意线程调用栈的那些事,分析的很详细

买家版帧率优化实践

买家版feed流中最为常见的卡片,包含了大量的图片以及文字,卡片视图数量很多,层级也很复杂。大量图片以及文本的绘制可以说是影响帧率的最大因素。因此初步的优化方案是,引入ASDisplayKit异步渲染框架,替换文本控件和图片控件。ASDisplayKit的核心思想是将所有能异步执行的操作如:文本和布局的计算、渲染、图片的解码、绘制等通通从主线程移到子线程。

在引入ASDisplayKit后,页面帧率有所提升,但卡顿情况仍较为严重。而页面的卡片类型多大七八种,而每个卡片的实现代码少则大大几百行,多则一千多行。如果单纯代码Review查找性能瓶颈的代码,是一件相当低效的事情,因此引入了卡顿检测工具。引入卡顿检测工具后,发现:虽然使用了FDTemplateLayoutCell缓存了Cell高度避免重复计算,但是卡片所有文本宽高计算,并且没有缓冲,每次视图进入屏幕时都需要重新计算,而文本宽高的计算恰恰又是影响性能重要原因之一。

AutoLayout也是影响性能的一个因素,买家版的卡片也大量使用了AutoLayout,如果将卡片的布局移到异步线程,那么性能还会有所提升。

综上,买家版的性能瓶颈主要在于:文本宽高的计算、大量文本的绘制、大量图片的解码绘制以及AutoLayout带来的性能影响。

卡顿检测发现的其他问题:1. 主线程读取缓存并反序列化;2. VDTrick 遍历UIController.view 的所有子view;3. 网络请求回调时,主线程进行耗时的卡片高度计算操作。3.API的使用不当