一、卡顿的原理
从过去的 CRT 显示器原理说起。CRT 的电子枪按照上面方式,从上到下一行行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。尽管现在的设备大都是液晶显示屏,但原理仍然没有变。
通常来说,计算机系统中 CPU、GPU、显示器是以上面这种方式协同工作的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。
在 VSync 信号到来后,系统图形服务会通过CADisplayLink(用于同步屏幕刷新频率的计时器)等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。
垂直同步时间16.67ms,60帧就是1s。最新的iPhone13 pro的刷新率是120Hz,所以同步时间是8.33ms
CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。
二、界面卡顿的检测
2.1、FPS监控
主要通过CADIsplayLink实现。参照YYKit中的YYFPSLabel,借助link的时间差,来计算一次刷新刷新所需的时间,然后通过 刷新次数 / 时间差 得到刷新频次,并判断是否其范围,通过显示不同的文字颜色来表示卡顿严重程度。
什么是CADisplayLink
CADisplayLink是CoreAnimation提供的另一个类似于NSTimer的类,它总是在屏幕完成一次更新之前启动,它的接口设计的和NSTimer很类似,所以它实际上就是一个内置实现的替代,但是和timeInterval以秒为单位不同,CADisplayLink有一个整型的frameInterval属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。但是如果动画的代码执行起来超过了六十分之一秒,你可以指定frameInterval为2,就是说动画每隔一帧执行一次(一秒钟30帧)或者3,也就是一秒钟20次,等等。
YYFPSLabel实现源理
CADisplayLink可以以屏幕刷新的频率调用指定selector,而且iOS系统中正常的屏幕刷新率为60Hz(60次每秒),所以使用 CADisplayLink 的 timestamp 属性,配合 timer 的执行次数计算得出FPS数。 刷新频率 = 次数/时间
2.2、RunLoop检测
@interface LGBlockMonitor (){
CFRunLoopActivity activity;
}
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;
@end
@implementation LGBlockMonitor
+ (instancetype)sharedInstance {
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (void)start{
[self registerObserver];
[self startMonitor];
}
static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
LGBlockMonitor *monitor = (__bridge LGBlockMonitor *)info;
monitor->activity = activity;
// 发送信号
dispatch_semaphore_t semaphore = monitor->_semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)registerObserver{
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
//NSIntegerMax : 优先级最小
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
NSIntegerMax,
&CallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
- (void)startMonitor{
// 创建信号
_semaphore = dispatch_semaphore_create(0);
// 在子线程监控时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
// 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
long st = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
if (st != 0)
{
if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting)
{
if (++self->_timeoutCount < 2){
NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
continue;
}
// 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
NSLog(@"检测到超过两次连续卡顿");
}
}
self->_timeoutCount = 0;
}
});
}
创建个runloop观察者,在回调中发送信号量,在子线程接受信号量,无线循环等待,间隔一秒接受信号量,检测到事务执行_timeoutCount++,超过指定时间打印。
2.3、第三方库存
卡顿检测第三方库 swift:ANREye
三、界面卡顿优化
3.1、预排版
预排版:提前计算布局,如cell的高度,(提前计算,后面直接使用)
我们可以单独在一个预排版的子线程去做一些事情:
- frame的计算
- 控件层级的部署
- 渲染所需数据的处理
- Model模型的数据解析等
尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。
3.2、Autolayout
Autolayout在大部分情况下也能很好的提升开发效率,但是Autolayout对于复杂视图来说常常会产生严重的性能问题。随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。
所以复杂的页面最好使用纯代码来布局。
3.3、预解码 & 预渲染
图片要显示,就要加载一个UIImage,UIImage是一个模型,里面包含Data Buffer 和 imageBuffer,然后由Controller控制UIImage显示在UIImageView上面的。其中Data Buffer 进行解码然后缓存到imageBuffer里面,然后才可以由Frame Buffer进行渲染。
图片的解码
用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。目前常见的网络图片库都自带这个功能。
sdwebimage 关于解码的处理
// decode the image in coder queue
dispatch_async(self.coderQueue, ^{
@autoreleasepool {
UIImage *image = SDImageLoaderDecodeImageData(imageData, self.request.URL, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context);
CGSize imageSize = image.size;
if (imageSize.width == 0 || imageSize.height == 0) {
[self callCompletionBlocksWithError:[NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorBadImageData userInfo:@{NSLocalizedDescriptionKey : @"Downloaded image has 0 pixels"}]];
} else {
[self callCompletionBlocksWithImage:image imageData:imageData error:nil finished:YES];
}
[self done];
}
});
苹果官方文档中,建议使用下采样(Downsampleing)的技术,来加载图片,减少imageBuffer的大小
图像的绘制
图像的绘制通常是指用那些以CG开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是[UIView drawRect:]里面了。由于CoreGraphic方法通常都是线程安全的,所以图像的绘制就可以很容易的放到后台线程进行。一个简单的异步绘制的过程大致如下(实际情况会比这个复杂得多,但原理基本一致):
- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}
在列表中加载图片
我们在开发中,一般会对图片进行子线程异步加载,在后台进行解码和下采样。在列表中,有时会加载很多图片,此时应该注意线程爆炸问题。
线程爆炸
当我们要求系统去做比CPU能够做的工作更多的工作时 就会发生这种情况,比如我们要显示8张图片,但我们只有两个CPU,就不能一次完成所有这些工作,无法在不存在的CPU上进行并行处理,为了避免向一个全局队列中异步的分配任务时发生死锁,GCD 将创建新线程来捕捉我们要求它所做的工作,然后CPU将花费大量时间,在这些线程之间进行切换 ,尝试在所有工作上取得我们要求操作系统为我们做的渐进式进展,在这些线程之间不停切换,实际上是相当大的销,现在不是简单地将工作分派到全局异步队列之一,而是创建一个串行队列,在预取的方法中,异步的将工作分派到该队列,它的确意味着单个图像的加载,可能要比以前晚才能开始取得进展,但CPU将花费更少的时间,在它可以做的小任务之间来回切换。在SDWebImage中,解码的队_coderQueue.maxConcurrentOperationCount = 1就是一个串行队列。这样就很好的解决了多图片异步解码时,线程爆炸问题。
3.4、按需加载
例如在TableView中滑动时不加载图片,使用默认占位图,而是在滑动停止时加载,只加载前几行
按需加载一般配合缓存来使用
3.5、异步渲染
参考**Graver**
从文本计算、样式排版渲染、图片解码,再到绘制,实现了全程异步化。
3.6、对象创建、调整、销毁
- 尽量做到
懒加载。不用的对象不进行创建 - 减少对
UIView和CALayer的属性修改 - 有
大量对象释放时,是非常耗时的,尽量挪到子线程去释放
3.7、开发中的一些优化tips
- 尽量将多张图合为一张进行显示
- 尽量减少视图数量和层次
- 减少使用离屏渲染,离屏渲染:
CALayer的border、圆角、阴影、遮罩(mask),最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性 避免使用透明view- 避免使用
addView给cell动态添加view