本文主要介绍界面卡顿的原理以及优化
界面卡顿
通常来说,计算机中的显示过程是下面这样的,通过CPU
、GPU
、显示器
协同工作来将图片显示到屏幕上
1、CPU计算好显示内容,提交至GPU
2、GPU经过渲染完成后将渲染的结果放入
FrameBuffer
(帧缓存区)3、随后
视频控制器
会按照VSync
信号逐行读取FrameBuffer
的数据4、经过可能的数模转换传递给显示器进行显示
最开始时,FrameBuffer只有一个,这种情况下FrameBuffer的读取和刷新有很大的效率问题,为了解决这个问题,引入了双缓存区
。即双缓冲机制
。在这种情况下,GPU
会预先渲染好一帧放入FrameBuffer
,让视频控制器读取,当下一帧渲染好后,GPU会直接将视频控制器的指针指向第二个FrameBuffer
。
双缓存机制虽然解决了效率问题,但是随之而言的是新的问题,当视频控制器还未读取完成时,例如屏幕内容刚显示一半,GPU将新的一帧内容提交到FrameBuffer,并将两个FrameBuffer而进行交换后,视频控制器就会将新的一帧数据的下半段显示到屏幕上,造成屏幕撕裂
现象
为了解决这个问题,采用了垂直同步信号机制
。当开启垂直同步后,GPU会等待显示器的VSync信号发出后,才进行新的一帧渲染和FrameBuffer更新。而目前iOS设备中采用的正是双缓存区+VSync
更多的关于屏幕卡顿渲染流程,请查看屏幕卡顿 及 iOS中OpenGL渲染架构分析文章
屏幕卡顿原因
下面我们来说说,屏幕卡顿的原因
在 VSync
信号到来后,系统图形服务会通过 CADisplayLink
等机制通知 App,App 主线程开始在CPU中计算显示内容。随后 CPU 会将计算好的内容提交到 GPU 去,由GPU进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区
去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示
,而这时显示屏会保留之前的内容不变。所以可以简单理解掉帧
为过时不候
如下图所示,是一个显示过程,第1帧在VSync到来前,处理完成,正常显示,第2帧在VSync到来后,因为CPU耗时较多,来不及显示,此时屏幕不刷新,依旧显示第1帧,此时就出现了掉帧
情况,渲染时就会出现明显的卡顿现象
从图中可以看出,CPU和GPU不论是哪个阻碍了显示流程,都会造成掉帧
现象,所以为了给用户提供更好的体验,在开发中,我们需要进行卡顿检测
以及相应的优化
卡顿监控
卡顿监控的方案一般有两种:
FPS监控
:为了保持流程的UI交互,App的刷新拼搏应该保持在60fps
左右,其原因是因为iOS
设备默认的刷新频率是60次/秒
,而1次刷新(即VSync
信号发出)的间隔是1000ms/60 = 16.67ms
,所以如果在16.67ms
内没有准备好下一帧数据,就会产生卡顿主线程卡顿监控
:通过子线程监测主线程的RunLoop,判断两个状态(kCFRunLoopBeforeSources
和kCFRunLoopAfterWaiting
)之间的耗时是否达到一定阈值
FPS监控
FPS的监控,参照YYKit
中的YYFPSLabel
,主要是通过CADisplayLink
实现。借助link
的时间差,来计算一次刷新刷新所需的时间,然后通过 刷新次数 / 时间差
得到刷新频次,并判断是否其范围,通过显示不同的文字颜色来表示卡顿严重程度。代码实现如下:
class CJLFPSLabel: UILabel {
fileprivate var link: CADisplayLink = {
let link = CADisplayLink.init()
return link
}()
fileprivate var count: Int = 0
fileprivate var lastTime: TimeInterval = 0.0
fileprivate var fpsColor: UIColor = {
return UIColor.green
}()
fileprivate var fps: Double = 0.0
override init(frame: CGRect) {
var f = frame
if f.size == CGSize.zero {
f.size = CGSize(width: 80.0, height: 22.0)
}
super.init(frame: f)
self.textColor = UIColor.white
self.textAlignment = .center
self.font = UIFont.init(name: "Menlo", size: 12)
self.backgroundColor = UIColor.lightGray
//通过虚拟类
link = CADisplayLink.init(target: CJLWeakProxy(target:self), selector: #selector(tick(_:)))
link.add(to: RunLoop.current, forMode: RunLoop.Mode.common)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
link.invalidate()
}
@objc func tick(_ link: CADisplayLink){
guard lastTime != 0 else {
lastTime = link.timestamp
return
}
count += 1
//时间差
let detla = link.timestamp - lastTime
guard detla >= 1.0 else {
return
}
lastTime = link.timestamp
//刷新次数 / 时间差 = 刷新频次
fps = Double(count) / detla
let fpsText = "\(String.init(format: "%.2f", fps)) FPS"
count = 0
let attrMStr = NSMutableAttributedString(attributedString: NSAttributedString(string: fpsText))
if fps > 55.0 {
//流畅
fpsColor = UIColor.green
}else if (fps >= 50.0 && fps <= 55.0){
//一般
fpsColor = UIColor.yellow
}else{
//卡顿
fpsColor = UIColor.red
}
attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: fpsColor], range: NSMakeRange(0, attrMStr.length - 3))
attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: UIColor.white], range: NSMakeRange(attrMStr.length - 3, 3))
DispatchQueue.main.async {
self.attributedText = attrMStr
}
}
}
如果只是简单的监测,使用FPS
足够了。
主线程卡顿监控
除了FPS,还可以通过RunLoop
来监控,因为卡顿的是事务,而事务是交由主线程
的RunLoop
处理的。
我们知道iOS App基于RunLoop运行,我们先来看看RunLoop简化后的代码。
// 1.进入loop
__CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled)
// 2.RunLoop 即将触发 Timer 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
// 3.RunLoop 即将触发 Source0 (非port) 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
// 4.RunLoop 触发 Source0 (非port) 回调。
sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle)
// 5.执行被加入的block
__CFRunLoopDoBlocks(runloop, currentMode);
// 6.RunLoop 的线程即将进入休眠(sleep)。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
// 7.调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort)
// 进入休眠
// 8.RunLoop 的线程刚刚被唤醒了。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting
// 9.如果一个 Timer 到时间了,触发这个Timer的回调
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
// 10.如果有dispatch到main_queue的block,执行bloc
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
// 11.如果一个 Source1 (基于port) 发出事件了,处理这个事件
__CFRunLoopDoSource1(runloop, currentMode, source1, msg);
// 12.RunLoop 即将退出
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
实现思路:理清楚Runloop的运行机制,就很容易明白处理事件主要有两个时间段 kCFRunLoopBeforeSources
发送之后和 kCFRunLoopAfterWaiting
发送之后。
dispatch_semaphore_t
是一个信号量机制,信号量到达会继续向下进行,否则等待,利用这个特性我们判断卡顿出现的条件为 在信号量发送 kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting后进行了大量的操作,在一段时间内没有再发送信号量,导致超时。也就是说主线程通知状态长时间的停留在这两个状态上了。转换为代码就是判断有没有超时,超时了,判断当前停留的状态是不是这两个状态,如果是,就判定为卡顿。
这样就能解释通为什么要用这两个信号量判断卡顿。这个也是微信卡顿三方matrix
的原理
以下是一个简易版RunLoop监控的实现
@interface AKStuckMonitor ()
{
int timeoutCount;
CFRunLoopObserverRef observer;
@public
dispatch_semaphore_t semaphore;
CFRunLoopActivity activity;
}
@end
@implementation FQLAPMStuckMonitor
+ (instancetype)sharedInstance{
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
AKStuckMonitor *moniotr = (__bridge AKStuckMonitor*)info;
moniotr->activity = activity;
dispatch_semaphore_t semaphore = moniotr->semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)stop{
if (!observer)
return;
CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
CFRelease(observer);
observer = NULL;
}
- (void)start{
if (observer)
return;
// 信号
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), ^{
float time = 50;
while (YES)
{
long st = dispatch_semaphore_wait(self->semaphore, dispatch_time(DISPATCH_TIME_NOW, time * NSEC_PER_MSEC));
if (st != 0)
{
if (!self->observer)
{
self->timeoutCount = 0;
self->semaphore = 0;
self->activity = 0;
return;
}
if (self->activity==kCFRunLoopBeforeSources || self->activity==kCFRunLoopAfterWaiting)
{
if (++self->timeoutCount < 5)
continue;
NSlog(@"检测到卡顿");
}
}
self->timeoutCount = 0;
}
});
}
@end
也可以直接使用三方库
Swift
的卡顿检测第三方ANREye,其主要思路是:创建子线程进行循环监测,每次检测时设置标记置为true,然后派发任务到主线程,标记置为false,接着子线程睡眠超过阈值时,判断标记是否为false,如果没有,说明主线程发生了卡顿OC
可以使用 微信matrix、滴滴DoraemonKit
界面优化
CPU层面的优化
1、尽量
提前计算视图布局
,即预排版
,例如cell的行高,提前计算保存视图的Rect2、尽量
用轻量级的对象
代替重量级的对象,可以对性能有所优化,例如 不需要相应触摸事件的控件,用CALayer
代替UIView
-
3、尽量减少对
UIView
和CALayer
的属性修改CALayer内部并没有属性,当调用属性方法时,其内部是通过运行时
resolveInstanceMethod
为对象临时添加一个方法,并将对应属性值保存在内部的一个Dictionary中,同时还会通知delegate、创建动画等,非常耗时UIView
相关的显示属性,例如frame、bounds、transform等,实际上都是从CALayer映射来的,对其进行调整时,消耗的资源比一般属性要大
4、当有大量对象释放时,也是非常耗时的,尽量挪到后台线程去释放
5、
Autolayout
在简单页面情况下们可以很好的提升开发效率,但是对于复杂视图而言,会产生严重的性能问题,随着视图数量的增长,Autolayout带来的CPU消耗是呈指数上升的。所以尽量使用代码布局
。如果不想手动调整frame等,也可以借助三方库,例如Masonry(OC)、SnapKit(Swift)、ComponentKit、AsyncDisplayKit等
-
6、文本处理的优化:当一个界面有大量文本时,其行高的计算、绘制也是非常耗时的
-
1)如果对文本没有特殊要求,可以使用UILabel内部的实现方式,且需要放到子线程中进行,避免阻塞主线程
计算文本宽高:
[NSAttributedString boundingRectWithSize:options:context:]
文本绘制:
[NSAttributedString drawWithRect:options:context:]
2)自定义文本控件,利用
TextKit
或最底层的CoreText
对文本异步绘制。并且CoreText
对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整和绘制都需要计算一次)。CoreText直接使用了CoreGraphics占用内存小,效率高
-
-
7、图片处理(解码 + 绘制)
1)当使用
UIImage
或CGImageSource
的方法创建图片时,图片的数据不会立即解码,而是在设置时解码(即图片设置到UIImageView/CALayer.contents
中,然后在CALayer
提交至GPU渲染前,CGImage
中的数据才进行解码)。这一步是无可避免
的,且是发生在主线程
中的。想要绕开这个机制,常见的做法是在子线程中先将图片绘制到CGBitmapContext
,然后从Bitmap
直接创建图片,例如SDWebImage
三方框架中对图片编解码的处理。这就是Image的预解码
当使用CG开头的方法绘制图像到画布中,然后从画布中创建图片时,可以将图像的
绘制
在子线程
中进行
-
8、图片优化
1)尽量使用
PNG
图片,不使用JPGE
图片2)通过
子线程预解码,主线程渲染
,即通过Bitmap
创建图片,在子线程赋值image3)优化图片大小,尽量避免动态缩放
9、尽量
避免使用透明view
,因为使用透明view,会导致在GPU中计算像素时,会将透明view下层图层的像素也计算进来,即颜色混合
处理,可以参考六、OpenGL 渲染技巧:深度测试、多边形偏移、 混合这篇文章中提及的混合
10、
按需加载
,例如在TableView中滑动时不加载图片,使用默认占位图,而是在滑动停止时加载11、少使用
addView
给cell
动态添加view
GPU层面优化
相对于CPU而言,GPU主要是接收CPU提交的纹理+顶点,经过一系列transform,最终混合并渲染,输出到屏幕上。
1、尽量
减少在短时间内大量图片的显示
,主要是因为当有大量图片进行显示时,无论是CPU的计算还是GPU的渲染,都是非常耗时的,很可能出现掉帧的情况2、尽量避免图片的尺寸超过
4096×4096
,因为当图片超过这个尺寸时,会先由CPU进行预处理,然后再提交给GPU处理,导致额外CPU资源消耗3、尽量减少视图数量和层次,主要是因为视图过多且重叠时,GPU会将其混合,混合的过程也是非常耗时的
4、尽量避免离屏渲染,可以查看这篇文章四、深入剖析【离屏渲染】原理
5、异步渲染,例如可以将cell中的所有控件、视图合成一张图片进行显示。可以参考Graver三方框架
注:上述这些优化方式的落地实现,需要根据自身项目进行评估,合理的使用进行优化