iOS 之界面卡顿原因分析及解决方案

随着移动互联网向纵深发展,用户变得越来越关心应用的体验,开发者必须关注应用性能所带来的用户流失问题。

据统计,有十种应用性能问题危害最大,分别为:连接超时、闪退、卡顿、崩溃、黑白屏、网络劫持、交互性能差、CPU 使用率问题、内存泄露、不良接口。

卡顿虽然不像闪退一样致命,但也会带给用户极差的体验,甚至导致用户卸载应用,可是却常常被我们忽视。

图像显示原理

在开始理解卡顿、掉帧及绘制原理前,首先让我们先了解下图像的显示原理。

图像的显示可以简单理解成先经过 CPU 的计算/排版/编解码等操作,然后交由 GPU 去完成渲染放入缓冲中,当视频控制器接受到 vSync 时会从缓冲中读取已经渲染完成的帧并显示到屏幕上。

CPU: 负责对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics)

GPU: 负责纹理的渲染(将数据渲染到屏幕)

垂直同步技术: 让 CPU 和 GPU 在收到 vSync 信号后再开始准备数据,防止撕裂感和跳帧,通俗来讲就是保证每秒输出的帧数不高于屏幕显示的帧数。

双缓冲技术: iOS 是双缓冲机制,前帧缓存和后帧缓存,CPU 计算完 GPU 渲染后放入缓冲区中,当 GPU 下一帧已经渲染完放入缓冲区,且视频控制器已经读完前帧,GPU 会等待 vSync (垂直同步信号)信号发出后,瞬间切换前后帧缓存,并让 CPU 开始准备下一帧数据。

安卓4.0后采用三重缓冲,多了一个后帧缓冲,可降低连续丢帧的可能性,但会占用更多的 CPU 和 GPU。

在显示器中是固定的频率,比如 iOS 中是每秒60帧(60FPS),即每帧16.7ms

从上图中可以看出,每两个 VSync 信号之间有时间间隔(16.7ms),在这个时间内,CPU 主线程计算布局,解码图片,创建视图,绘制文本,计算完成后将内容交给 GPU,GPU 变换,合成,渲染(详细可学习 OpenGL 相关课程),放入帧缓冲区。

假如16.7ms内,CPU 和 GPU 没有来得及生产出一帧缓冲,那么这一帧会被丢弃,显示器就会保持不变,继续显示上一帧内容,这就将导致导致画面卡顿
所以无论 CPU、GPU,哪个消耗时间过长,都会导致在16.7ms内无法生成一帧缓存。

卡顿、掉帧优化方案切入点

CPU

CPU 在准备下一帧的所做的工作非常多导致耗时,基于减轻CPU工作时长和压力来达到一个优化效果。

  1. 部分对象的创建、调整和销毁可以放到子线程去做;

  2. 预排版( 布局计算、文本计算),这些计算也可以放到子线程去做,这样主线程也可以有更多的时间去响应用户的交互;

  3. 预渲染(文本等异步绘制、图片编解码等);

  4. 尽量用轻量级的对象,比如用不到事件处理的地方使用 CALayer 取代 UIView;

  5. 尽量提前计算好布局(例如cell行高);

  6. 不要频繁地调用和调整 UIView 的相关属性,比如 frame、bounds、transform 等属性,尽量减少不必要的调用和修改 (UIView 的显示属性实际都是 CALayer 的映射,而 CALayer 本身是没有这些属性的,都是初次调用属性时通过 resolveInstanceMethod 添加并创建 Dictionry 保存的,耗费资源);

  7. Autolayout 会比直接设置 frame 消耗更多的 CPU 资源,当视图数量增长时会呈指数级增长;

  8. 图片的 size 最好刚好跟 UIImageView 的 size 保持一致,减少图片显示时的处理计算;

  9. 控制一下线程的最大并发数量;

  10. 文本处理(尺寸计算、绘制、CoreText 和 YYText);

  11. 计算文本宽高 boundingRectWithSize:options:context: 和文本绘制 drawWithRect:options:context: 放在子线程操作;

  12. 使用 CoreText 自定义文本空间,在对象创建过程中可以缓存宽高等信息,避免像 UILabel/UITextView 需要多次计算(调整和绘制都要计算一次),且 CoreText 直接使用了 CoreGraphics 占用内存小,效率高。(如:YYText

  13. 图片处理(解码、绘制): 图片都需要先解码成 bitmap 才能渲染到 UI 上,iOS 创建 UIImage,不会立刻进行解码,只有等到显示前才会在主线程进行解码,固可以使用 Core Graphics 中的 CGBitmapContextCreate 相关操作提前在子线程中进行强制解压缩获得位图。

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
30
31
32
33
34
35
// 参考 SDWebImage 的使用:

CGImageRef imageRef = image.CGImage;
// device color space
CGColorSpaceRef colorspaceRef = SDCGColorSpaceGetDeviceRGB();
BOOL hasAlpha = SDCGImageRefContainsAlpha(imageRef);
// iOS display alpha info (BRGA8888/BGRX8888)
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;

size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);

// kCGImageAlphaNone is not supported in CGBitmapContextCreate.
// Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast
// to create bitmap graphics contexts without alpha info.
CGContextRef context = CGBitmapContextCreate(NULL,
width,
height,
kBitsPerComponent,
0,
colorspaceRef,
bitmapInfo);
if (context == NULL) {
return image;
}

// Draw the image into the context and retrieve the new bitmap image without alpha
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
CGImageRef imageRefWithoutAlpha = CGBitmapContextCreateImage(context);
UIImage *imageWithoutAlpha = [[UIImage alloc] initWithCGImage:imageRefWithoutAlpha scale:image.scale orientation:image.imageOrientation];
CGContextRelease(context);
CGImageRelease(imageRefWithoutAlpha);

return imageWithoutAlpha;

GPU

  1. 纹理渲染:假如说我们触发了离屏渲染,例如我们设置圆角时对 maskToBounds 的设置,包括一些阴影、蒙层等都会触发 GPU 层面的离屏渲染,对于这种情况下,GPU 对于纹理渲染的工作量就会非常的大,我们可以基于此对 GPU 进行优化,就是尽量减少离屏渲染,我们也可以通过 CPU 的异步绘制来减轻 GPU 的压力。

  2. 视图混合:比如说我们视图层级比较复杂,视图之间层层叠加,那么 GPU 就要做每一个视图的合成,合成每一个像素点的像素值,如果我们可以减少视图的层级,也是可以减轻 GPU 的压力,我们也可以通过 CPU 的异步绘制机制来达到一个提交的位图本身就是一个层级比较少的位图。

  3. 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示

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

  5. 减少透明的视图(alpha<1),不透明的就设置 opaque 为 YES,GPU 就不会去进行 alpha 的通道合成。

离屏渲染

在 OpenGL 中,GPU 有2种渲染方式

  1. On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作;
  2. Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。

离屏渲染消耗性能的原因

离屏渲染指的是 GPU 在当前屏幕缓冲区以外开辟了一个缓冲区进行渲染操作
当前屏幕渲染不需要额外创建新的缓存,也不需要开启新的上下文,相对于离屏渲染性能更好。但是受当前屏幕渲染的局限因素限制(只有自身上下文、屏幕缓存有限等),当前屏幕渲染有些情况下的渲染解决不了的,就使用到离屏渲染。

离屏渲染对性能的的代价是很高的,主要体现在:

  • 创建了新的缓冲区
  • 上下文的频繁切换

导致产生离屏渲染的原因:

  • 圆角: layer.masksToBounds = YES; layer.cornerRadius 大于0
  • 光栅化: layer.shouldRasterize = YES
  • 遮罩: layer.mask
  • 阴影: layer.shadow
  • 抗锯齿
  • 不透明
  • 复杂形状设置圆角等
  • 渐变

卡顿监控

Xcode 自带 Instruments

在开发阶段,可以直接使用 Instrument 来检测性能问题,Time Profiler 查看与 CPU 相关的耗时操作,Core Animation 查看与 GPU 相关的渲染操作。

FPS(CADisplayLink)

正常情况下,App 的 FPS 只要保持在 50~60 之间,用户就不会感到界面卡顿。通过向主线程添加 CADisplayLink 我们可以接收到每次屏幕刷新的回调,从而统计出每秒屏幕刷新次数。这种方案最常见,例如 YYFPSLabel,且只用了CADisplayLink,实现成本较低,但由于只能在 CPU 空闲时才去回调,无法精确采集到卡顿时调用栈信息,可以在开发阶段作为辅助手段使用。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
//
// YYFPSLabel.m
// YYKitExample
//
// Created by ibireme on 15/9/3.
// Copyright (c) 2015 ibireme. All rights reserved.
//

#import "YYFPSLabel.h"
//#import <YYKit/YYKit.h>
#import "YYText.h"
#import "YYWeakProxy.h"

#define kSize CGSizeMake(55, 20)

@implementation YYFPSLabel {
CADisplayLink *_link;
NSUInteger _count;
NSTimeInterval _lastTime;
UIFont *_font;
UIFont *_subFont;

NSTimeInterval _llll;
}

- (instancetype)initWithFrame:(CGRect)frame {
if (frame.size.width == 0 && frame.size.height == 0) {
frame.size = kSize;
}
self = [super initWithFrame:frame];

self.layer.cornerRadius = 5;
self.clipsToBounds = YES;
self.textAlignment = NSTextAlignmentCenter;
self.userInteractionEnabled = NO;
self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];

_font = [UIFont fontWithName:@"Menlo" size:14];
if (_font) {
_subFont = [UIFont fontWithName:@"Menlo" size:4];
} else {
_font = [UIFont fontWithName:@"Courier" size:14];
_subFont = [UIFont fontWithName:@"Courier" size:4];
}

_link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
return self;
}

- (void)dealloc {
[_link invalidate];
}

- (CGSize)sizeThatFits:(CGSize)size {
return kSize;
}

- (void)tick:(CADisplayLink *)link {
if (_lastTime == 0) {
_lastTime = link.timestamp;
return;
}

_count++;
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
_lastTime = link.timestamp;
float fps = _count / delta;
_count = 0;

CGFloat progress = fps / 60.0;
UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];

NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];
[text yy_setColor:color range:NSMakeRange(0, text.length - 3)];
[text yy_setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
text.yy_font = _font;
[text yy_setFont:_subFont range:NSMakeRange(text.length - 4, 1)];

self.attributedText = text;
}

@end

参考资料

iOS 保持界面流畅的技巧
屏幕成像原理
iOS 性能优化总结
质量监控-卡顿检测