面试题引发的思考:
Q: 列表卡顿的原因?如何优化?
- 卡顿主要是因为在 主线程 执行了 比较耗时 的操作。
Q: 卡顿解决方法?
- CPU:
- 尽量用轻量级的对象:
CALayer
、int
等; - 不要频繁调用
UIView
的相关属性; - 提前计算好布局;
Autolayout
会比直接设置frame
消耗更多的CPU资源;- 图片的
size
最好刚好跟UIImageView
的size
保持一致; - 控制一下线程的 最大并发数量;
- 尽量把 耗时的操作 放到 子线程,充分利用CPU的多核。
- 尽量用轻量级的对象:
- GPU:
- 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示;
- GPU能处理的最大纹理尺寸是4096x4096;
- 尽量减少视图数量和层级;
- 减少透明的视图(
alpha<1
),不透明的就设置opaque
为YES
; - 尽量避免出现离屏渲染。
Q: 如何检测卡顿?
- 通过添加
Observer
到 主线程RunLoop 中,监听 RunLoop状态切换 的耗时,以达到监控卡顿的目的。
Q: 何为离屏渲染?
- 当前屏幕渲染:GPU在 当前屏幕缓冲区 进行渲染操作;
- 离屏渲染:GPU在 当前屏幕缓冲区以外 新开辟一个缓冲区进行渲染操作。
Q: 离屏渲染触发时机?
- 图层属性的混合体 在没有预合成之前 不能直接在屏幕中绘制,此时会触发离屏渲染。
Q: 为什么离屏渲染消耗性能?为何要避免离屏渲染?
- 创建新的缓冲区;
- 多次切换上下文切换。
- 触发离屏渲染时,会增加GPU的工作量,可能会导致CPU和GPU的总工作耗时超出16.7ms,进而导致UI的卡顿和掉帧。
Q: 哪些操作会触发离屏渲染?
- 圆角:
cornerRadius
和masksToBounds
同时设置时;
解决方案:UI提供圆角图片、CoreGraphics绘制裁剪圆角; - 阴影:
layer.shadowXXX
;
如果设置了layer.shadowPath
就不会产生离屏渲染。 - 光栅化:
layer.shouldRasterize = YES
; - 图层蒙版:
layer.mask
。
1. 屏幕成像原理
(1) CPU和GPU的作用
在屏幕成像过程中,CPU和GPU起着至关重要的作用:
- CPU (Central Processing Unit,中央处理器):
作用:对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics);- GPU (Graphics Processing Unit,图形处理器):
作用:纹理的渲染。
(2) 屏幕成像流程
屏幕成像流程如下:
- CPU进行UI布局、文本计算、图片解码等等,计算完毕将数据提交给GPU;
- GPU对数据进行渲染,渲染完毕将数据放到 帧缓存 里面;
- 视频控制器 从 帧缓存 读取数据;并将数据显示到 屏幕 上。
在iOS中是双缓冲机制,有前帧缓存、后帧缓存。上图的帧缓存有两块区域,当一块区域满了或一块区域正在进行其他操作,此时GPU会使用另外一块区域缓存,提升效率。
(3) 屏幕成像原理
屏幕成像原理如下:
手机屏幕上的动画是通过一帧一帧(或者说一页)数据组成的。
- 当屏幕需要显示一帧数据的时候,会发送一个垂直同步信号;然后逐行发送水平同步信号,直到填充整个屏幕,此时一帧数据就显示完成。
- 接下来再发送一个垂直同步信号;然后逐行发送水平同步信号,直到完成这一帧。
- 当所有帧数据发送完成之后,这些帧连起来就是屏幕上的动画了。
2. 卡顿产生原因
如上图,红色箭头是CPU计算需要的时间,蓝色箭头是GPU渲染需要的时间。
注意:发送一个垂直同步信号,就会立即把CPU计算和GPU渲染完成的数据从帧缓存中读取显示到屏幕上,并且立即开始下一帧的操作。
第一帧的数据需要显示,发送一个垂直同步信号,此时将CPU计算和GPU渲染完成的数据从帧缓存中读取显示到屏幕上,就完成了第一帧的显示。
第二帧的数据CPU计算和GPU渲染的比较快,在下一次垂直同步信号来之后,将CPU计算和GPU渲染完成的数据从帧缓存中读取显示到屏幕上,完成了第二帧的显示。
第三帧的数据CPU计算和GPU渲染的比较慢,在下一次垂直同步信号来到之后还没处理完毕,此时帧缓存里面还是第二帧的数据,将第二帧的数据从帧缓存中读取显示到屏幕上,如此便产生掉帧现象,也就是我们说的卡顿。
第三帧的数据会在下一帧的垂直同步信号来到之后再显示到屏幕上,慢了一帧的时间。
3. 卡顿解决方法
卡顿解决方法是尽可能减少CPU、GPU资源消耗。
- 一般帧率FPS(Frames Per Second每秒传输帧数)达到60FPS就会感觉不到卡顿;
- 按照60FPS的刷帧率,每隔16ms就会有一次VSync信号(1000ms / 60 = 16.667ms)。
(1) 卡顿优化 CPU
1> CPU卡顿优化方式
尽量用轻量级的对象,比如无需事件处理的地方使用
CALayer
取代UIView
、能用int
就不用NSNumber
;不要频繁地调用
UIView
的相关属性,比如frame
、bounds
、transform
等属性,尽量减少不必要的修改;尽量提前计算好布局,在有需要时一次性调整对应的属性,不要多次修改属性;
Autolayout
会比直接设置frame
消耗更多的CPU资源,因为Autolayout
本身性能就不是很高;图片的
size
最好刚好跟UIImageView
的size
保持一致,如果不一致CPU就会对图片进行伸缩操作,这样比较消耗CPU资源;控制一下线程的最大并发数量,不要无限制的并发,这样比较消耗CPU资源;
尽量把耗时的操作放到子线程,充分利用CPU的多核。
2> 耗时操作:文本处理(尺寸计算、绘制)
比如:boundingRectWithSize
计算文字宽高是可以放到子线程去计算的,或者drawWithRect
文本绘制,也是可以放到子线程去绘制的,如下:
- (void)text {
// 此类操作可以放到子线程
// 文字计算
[@"text" boundingRectWithSize:CGSizeMake(100, MAXFLOAT)
options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
// 文字绘制
[@"text" drawWithRect:CGRectMake(0, 0, 100, 100)
options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
}
3> 耗时操作:图片处理(解码、绘制)
我们经常会写如下代码加载图片:
- (void)image {
UIImageView *imageView = [[UIImageView alloc] init];
imageView.frame = CGRectMake(100, 100, 100, 56);
//加载图片
imageView.image = [UIImage imageNamed:@"timg"];
[self.view addSubview:imageView];
self.imageView = imageView;
}
通过imageNamed
加载图片,加载完成后是不会直接显示到屏幕上面的,因为加载后的是经过压缩的图片二进制,当真正想要渲染到屏幕上的时候再拿到图片二进制解码成屏幕显示所需要的格式,然后进行渲染显示,而这种解码一般默认是在主线程操作的,如果图片数据比较多比较大的话也会产生卡顿。
一般的做法是在子线程提前解码图片二进制,不需要主线程解码,这样在图片渲染显示之前就已经解码出来了,主线程拿到解码后的数据进行渲染显示就可以了,这样主线程就不会卡顿。网上很多图片处理框架都有这个异步解码功能的。下面演示一下:
上面代码,不单单通过imageNamed
加载的本地图片可以提前渲染,通过imageWithContentsOfFile
加载的网络图片也可以这样进行提前渲染,只要获取到UIImage
对象都可以对UIImage
对象进行提前渲染。
(2) 卡顿优化 GPU
1> GPU卡顿优化方式
尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示,这样只渲染一张图片,渲染更快;
GPU能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸;
尽量减少视图数量和层级,视图层级太多会增加渲染时间;
减少透明的视图(
alpha<1
),不透明的就设置opaque
为YES
,因为一旦有透明的视图就会进行很多混合计算增加渲染的资源消耗;尽量避免出现离屏渲染。
2> 离屏渲染
在OpenGL中,GPU有2种渲染方式:
- On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作;
- Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。
当前用于显示的屏幕缓冲区就是下图的帧缓存:
Q: 离屏渲染触发时机?
- 图层属性的混合体 在没有预合成之前 不能直接在屏幕中绘制时,会触发离屏渲染。
Q: 为什么离屏渲染消耗性能?为何要避免离屏渲染
- 创建新的缓冲区;
- 多次切换上下文切换。
- 触发离屏渲染时,会增加GPU的工作量,可能会导致CPU和GPU的总工作耗时超出16.7ms,进而导致UI的卡顿和掉帧。
Q: 哪些操作会触发离屏渲染?
- 圆角:
cornerRadius
和masksToBounds
同时设置时;
解决方案:UI提供圆角图片、CoreGraphics绘制裁剪圆角; - 阴影:
layer.shadowXXX
如果设置了layer.shadowPath
就不会产生离屏渲染。 - 光栅化:
layer.shouldRasterize = YES
; - 图层蒙版:
layer.mask
;
Q: 为什么要开辟新的缓冲区?
因为上面进行的那些操作比较耗性能、资源,当前屏幕缓冲区不够用(就算是双缓冲机制也不够用),所以才会开辟新的缓冲区。
(3) 卡顿检测
“卡顿”主要是因为在主线程执行了比较耗时的操作;
通过添加Observer
到主线程RunLoop中,监听RunLoop状态切换的耗时,以达到监控卡顿的目的。
如下图,主线程的大部分操作,比如点击事件的处理,view
的计算、 绘制等基本上都在source0
和source1
。我们只要监控一下从结束休眠(步骤08)处理soure1
(步骤09-C)一直到绕回来处理source0
(步骤05), 如果发现中间消耗的时间比较长,那么就可以证明这些操作比较耗时。
借助可以监控哪个方法卡顿的第三方库
// TODO: ----------------- ViewController类 -----------------
- (void)viewDidLoad {
[super viewDidLoad];
//开启卡顿检测
[[LXDAppFluecyMonitor sharedMonitor] startMonitoring];
[self.tableView registerClass: [UITableViewCell class] forCellReuseIdentifier: @"cell"];
}
#pragma mark -
#pragma mark - UITableViewDataSource
- (NSInteger)tableView: (UITableView *)tableView numberOfRowsInSection: (NSInteger)section {
return 1000;
}
- (UITableViewCell *)tableView: (UITableView *)tableView cellForRowAtIndexPath: (NSIndexPath *)indexPath {
UITableViewCell * cell = [tableView dequeueReusableCellWithIdentifier: @"cell"];
cell.textLabel.text = [NSString stringWithFormat: @"%lu", indexPath.row];
if (indexPath.row > 0 && indexPath.row % 30 == 0) {
// 模拟卡顿
sleep(2.0);
}
return cell;
}
运行以上代码,打印结果如下:
由打印结果可知:可以检测到cellForRowAtIndexPath
的卡顿。
下面简单介绍下LXDAppFluecyMonitor框架的核心代码:
LXDAppFluecyMonitor框架里面就两个文件:
LXDBacktraceLogger文件里面是关于方法调用栈的一些代码;
LXDAppFluecyMonitor文件就是卡顿检测文件。
进入LXDAppFluecyMonitor文件的startMonitoring
方法: