图像的显示可以理解为先经过CPU的计算、排版、编解码等操作,然后交有GPU去完成渲染放入缓冲中,当视频控制器受到vSync时会从缓冲中读取已经渲染完成的帧并显示到屏幕上。
屏幕显示的过程:CPU计算显示内容,例如视图创建、布局计算、图片解码、文本绘制等;接着CPU将计算好的内容提交到GPU进行合成、渲染。然后GPU把渲染结果提交到帧缓冲区,等待VSync信号到来时显示到屏幕上。如果此时下一个VSync信号到来时,CPU或者GPU没有完成相应的工作时,那一帧就会丢失,就会看到屏幕卡顿。
按照60FPS的帧率,每隔16ms就会有一次VSync信号,1秒是1000ms,1000/60 = 16
iOS默认刷新频率是60HZ,所以GPU渲染只要达到60fps就不会产生卡顿。如果在60fps(16.67ms)内没有准备好下一帧数据就会使画面停留在上一帧。
只要能使CPU的计算和GPU的渲染能在规定时间内完成,就不会出现卡顿。所以目标是减少CPU和GPU的资源消耗。
卡顿造成的原因是CPU和GPU导致的掉帧引起的:
减少计算,减少耗时操作
减少渲染
离屏渲染对GPU资源消耗极大。在OpenGL中,GPU有两种渲染方式,分别是屏幕渲染(On-Screen Rending)和离屏渲染(Off-Screen Rendering),区别在于渲染操作是在当前用于显示的屏幕缓冲区进行还是新开辟一个缓冲区进行渲染,渲染完成后再在当前显示的屏幕展示。
离屏渲染消耗性能的原因,在于需要创建新的缓冲区,并且在渲染的整个过程中,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕,造成了资源到极大消耗。
一些会触发离屏渲染的操作:
画圆角避免离屏渲染:
CAShapeLayer与UIBezierPath配合画圆角
- (void)drawCornerPicture{
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];
imageView.image = [UIImage imageNamed:@"1"];
// 开启图片上下文
// UIGraphicsBeginImageContext(imageView.bounds.size);
// 一般使用下面的方法
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0);
// 绘制贝塞尔曲线
UIBezierPath *bezierPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:100];
// 按绘制的贝塞尔曲线剪切
[bezierPath addClip];
// 画图
[imageView drawRect:imageView.bounds];
// 获取上下文中的图片
imageView.image = UIGraphicsGetImageFromCurrentImageContext();
// 关闭图片上下文
UIGraphicsEndImageContext();
[self.view addSubview:imageView];
}
使用Core Graphics绘制圆角
- (void)circleImage{
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(200, 400, 200, 200)];
imageView.image = [UIImage imageNamed:@"001.jpeg"];
// NO代表透明
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, 0.0);
// 获得上下文
CGContextRef ctx = UIGraphicsGetCurrentContext();
// 添加一个圆
CGRect rect = CGRectMake(0, 0, imageView.bounds.size.width, imageView.bounds.size.height);
CGContextAddEllipseInRect(ctx, rect);
// 裁剪
CGContextClip(ctx);
// 将图片画上去
// [imageView drawRect:rect];
[imageView.image drawInRect:rect];
imageView.image = UIGraphicsGetImageFromCurrentImageContext();
// 关闭上下文
UIGraphicsEndImageContext();
[self.view addSubview:imageView];
}
在开发阶段,可以直接使用Instrument来检测性能问题,TimeProfiler查看与CPU相关的耗时操作,CoreAnimation查看与GPU相关的渲染操作。
比如查看离屏渲染,模拟器中选中"Debug - Color Off-screen Rendered"开启调试,真机用Instrments - Core Animation - Debug Options - Color Offscreen - Rendered Yellow开启调试,开启后,有离屏渲染的图层会变成高亮的黄色。
通常情况下,屏幕会保持60hz/s的刷新率,每次刷新时会发出一个屏幕刷新信号,通过CADisplayLink可以注册一个与刷新信号同步的回调处理。可以通过屏幕刷新机制来展示fps值。
@implemention ViewController {
UILable *_fpsLabel;
CADisplayLink *_link;
NSTimeInterval _lastTime;
float _fps;
}
- (void)startMonitoring {
if (_link) {
[_link removeFromRunloop:[NSRunloop mainRunloop] forMode:NSRunloopCommonModes];
[_link invalidate];
_link = nil;
}
_link = [CADisplayLink displayLinkWithTarget:self selector:@selector(fpsDisplayLinkAction:)];
[_link addToRunloop:[NSRunloop mainRunloop] forMode:NSRunloopCommonModes];
}
- (void)fpsDisplayLinkAction:(CADisplaylink *)link {
if (_lastTime == 0) {
_lastTime = link.timestamp;
return;
}
self.count++;
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
_lastTime = link.timestamp;
_fps = _count / delta;
self.count = 0;
_fpsLabel.text = [NSString stringWithFormat:@"FPS: %.0f", _fps];
}
卡顿发生时,fps会有明显下滑,但转场动画等特殊场景也存在下滑情况;
采集精度低,回调总是需要cpu空闲时才能处理,无法及时采集调用栈的信息;
会有性能损耗,监听屏幕刷新会频繁唤醒runloop,闲置状态下有一定损耗;
实现成本低,单纯的采用CADisplayLink实现;
更适用于开发阶段。
原理:卡顿是在主线程进行了耗时的操作,可以添加Observer到主线程的Runloop中,通过Runloop状态切换的耗时,达到监控卡顿的目的。
通知Observer即将进入Runloop
Loop
-> 通知Observer即将处理事件
-> 处理事件
-> 通知Observer线程即将休眠
-> 休眠,等待被唤醒
通知Observer即将退出Runloop
其中核心方法CFRunloopRun简化后的逻辑大概是这样的:
/// 1. 通知Observers即将进入Runloop
/// 此处有Observer会创建AutoReleasePool:_objc_autoreleasePoolPush()
_CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION_(kCFRunloopEntry);
do {
/// 2. 通知Observers:即将触发Timer的回调
_CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION_(kCFRunloopBeforeTimers);
/// 3. 通知Observers: 即将触发Source(非基于Port的,Source0)回调。
_CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION_(kCFRunloopBeforeSources);
_CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
/// 4. 触发Source0(非基于port的)回调。
_CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION_(source0);
/// 5. GCD处理main block;
_CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
/// 6. 通知Observers即将进入休眠,
/// 此处有Observer释放并新建AutorealasePool: _objc_autorelasePoolPop(); _objc_autoreleasePoolPush()
_CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION_(kCFRunloopBeforeWaiting);
/// 7. sleep to wait msg.
mach_msg() -> mach_msg_trap();
/// 8. 通知Observers线程被唤醒
_CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION_(kCFRunloopAfterWaiting);
/// 9. 如果是被timer唤醒的,回调timer
_CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION(timer);
/// 9. 如果是被dispatch唤醒的,执行所有调用dispatch_async等方法放入main queue的block
_CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block);
/// 9. 如果Runloop是被Source1(基于port的)的事件唤醒了,处理这个事件。
_CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);
} while (...);
/// 10. 通知Observers,即将退出Runloop
/// 此处有Observer释放AutoreleasePool: _objc_autoreleasePoolPop();
_CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit);
不难发现NSRunloop调用方法是在kCFRunloopBeforeSources和kCFRunloopBeforeWaiting之间,以及kCFRunloopAfterWaiting之后,如果这两个时间内耗时太长,就可以判定出此时主线程卡顿。
所以在Runloop的最开始和结束最末尾的位置添加Observer,从而获得主线程的开始和结束状态。卡顿监控起一个子线程定时检查主线程的状态,当主线程的状态运行超过一定的阈值,则认为主线程卡顿,从而标记为一个卡顿。
分析实现:
使用Runloop进行卡顿监控,定义一个阈值判断卡顿的出现,记录下来上报到服务器。
比如:
// 开始监听
- (void)startMonitor {
if (observer) {
return;
}
// 创建信号
semaphore = dispatch_semaphore_create(0);
NSLog(@"dispatch_semaphore_create: %@", [PerformanceMonitor getCurTime];);
// 注册Runloop状态观察
CFRunloopContextObserver context = {0, (__bridge void*)self, NULL, NULL};
// 创建Run loop observer对象
//第一个参数用于分配observer对象的内存
//第二个参数用以设置observer所要关注的事件,详见回调函数myRunLoopObserver中注释
//第三个参数用于标识该observer是在第一次进入run loop时执行还是每次进入run loop处理时均执行
//第四个参数用于设置该observer的优先级
//第五个参数用于设置该observer的回调函数
//第六个参数用于设置该observer的运行环境
observer = CFRunloopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
// 在子线程监控时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES) { // 有信号的话,就查询Runloop的状态
// 假定连续5次超时50ms认为卡顿(当然也包含了单次超时250ms)
// 因为下面runloop状态改变的回调方法runLoopObserverCallback中会将信号量递增1,所以每次runloop状态改变后,下面的语句都会执行一次。
// dispatch_semaphore_wait: Returns zero on success, or non-zero if timeout occured.
long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC));
NSLog(@"dispatch_semaphore_wait: st=%ld, time:%@", st, [self getCurTime]);
if (st != 0) { // 信号量超时了 - 即 runloop 的状态长时间没有发生变更,长期处于某一个状态下
if (!observer) {
timeoutCount = 0;
semaphore = 0;
activity = 0;
return;
}
NSLog(@"st = %ld, activity = %lu, timeoutCount = %d, time: %@", st, activity, timeoutCount, [self getCurTime]);
// kCFRunLoopBeforeSources - 即将处理Sources
// kCFRunLoopAfterWaiting - 刚从休眠中唤醒
// 获取kCFRunLoopBeforeSources和kCFRunLoopBeforeWaiting,再到kCFRunLoopAfterWaiting的状态就可以知道是否有卡顿的情况。
// kCFRunLoopBeforeSources: 停留在这个状态,表示在做很多事情
if (activity == kCFRunLoopBeforeSources ||
activity == kCFRunLoopAfterWaiting) {
if (++timeoutCount < 5) {
continue; // 不足5次,直接continue当次循环,不将timeoutCount置为0
}
// 收集Crash信息也可用于实时获取各线程的调用堆栈
PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
PLCrashReporter *crashReporter = [[PLCrashReporter alloc] initWithConfiguration:config];
NSData *data = [crashReporter generateLiveReport];
PLCrashReport *reporter = [[PLCrashReport alloc] initWithData:data error:NULL];
NSString *report = [PLCrashReportTextFormatter stringValueForCrashReport:reporter withTextFormat:PLCrashReportTextFormatiOS];
NSLog(@"---------卡顿信息\n%@\n--------------",report);
}
}
NSLog(@"dispatch_semaphore_wait timeoutCount = 0,time:%@",[self getCurTime]);
timeoutCount = 0;
};
};
}
source0处理的是app内部事件,包括UI事件,每次处理的开始和结束的耗时决定了当前页面刷新是否正常。即kCFRunloopBeforeSources和kCFRunLoopAfterWaiting之间。因此创建一个子线程去监听主线程状态变化,通过dispatch_semaphore在主线程进入上面两个状态时发送信号量,子线程设置超时时间循环等待信号量,若超时时间后还未收到主线程发出的信号量即可判断为卡顿。
根据卡顿发生时,主线程无响应的原理,创建子线程去循环ping主线程,ping之前先设置卡顿标志为True,再派发到主线程执行后设置标志为false,子线程在设置阈值时间内休眠结束后,根据标志判断主线程有无响应。准确性和性能损耗与ping频率成正比。
private class AppPingThread: Thread {
private let semaphore = DispathchSemaphore(value: 0)
// 判断主线程是否卡顿的标志
private var isMainThreadBlock = True
private var threshould: Double = 0.4
fileprivate var handler: (() -> Void)?
func start(threshould: Double, handler: @escaping AppPingThreadCallback) {
self.handler = handler
self.threshould = threshould
self.start()
}
override func main() {
while self.isCancelled == false {
self.isMainThreadBlock = true
// 主线程去重置标识
DispatchQueue.main.async {
self.isMainThreadBlock = false
self.semaphore.signal()
}
Thread.sleep(forTimeInterval: self.threshould)
// 若标志未重置成功则说明再设置的阈值时间内主线程未响应
if self.isMainThredBlock {
// 采集卡顿调用栈信息
self.handler?()
}
_ = self.semaphore.wait(timeout: DispatchTime.distantFuture)
}
}
}
参考:
https://juejin.cn/post/6844904004053368846
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。