iOS应用千万级架构:性能优化与卡顿监控
2020-07-14 15:00 jiangys 阅读(3573) 评论(0) 编辑 收藏 举报CPU和GPU
在屏幕成像的过程中,CPU和GPU起着至关重要的作用
CPU(Central Processing Unit,中央处理器) 对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics)
GPU(Graphics Processing Unit,图形处理器) 纹理的渲染
另:在iOS中是双缓冲机制,有前帧缓存、后帧缓存
屏幕成像原理
GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。CRT 的电子枪按照上面方式,从上到下一行行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;
简单来说,就是产生一个VSync,之后不断的进行水平同步信号HSync将屏幕显示完,再产生下一个VSync,再不断的进行水平同步信号HSync将屏幕显示完,重复这样的操作。
按照60FPS的刷帧率,每隔16ms就会有一次VSync信号。1秒是1000ms,1000/60 = 16。
卡顿的原因分析
- 如图第3步:VSync信号回来时,GPU还没有完成相应的工作,这一帧将会丢失
- 如图第4步:当第3步丢失了,可能会导致第4步操作缺失,这一步也会丢帧
- 主线程在进行大量I/O操作:为了方便代码编写,直接在主线程去写入大量数据;
- 主线程在进行大量计算:代码编写不合理,主线程进行复杂计算;
- 大量UI绘制:界面过于复杂,UI绘制需要大量时间;
- 主线程在等锁:主线程需要获得锁A,但是当前某个子线程持有这个锁A,导致主线程不得不等待子线程完成任务。
卡顿优化
CPU资源消耗分析
1、对象创建:对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗CPU资源。尽量采取轻量级对象,尽量放到后台线程处理,尽量推迟对象的创建时间。(如UIView / CALayer)
2、对象调整:frame、bounds、transform及视图层次等属性调整很耗费CPU资源。尽量减少不必要属性的修改,尽量避免调整视图层次、添加和移除视图。
3、布局计算:随着视图数量的增长,Autolayout带来的CPU消耗会呈指数级增长,所以尽量提前算好布局,在需要时一次性调整好对应属性。
4、文本渲染:屏幕上能看到的所有文本内容控件,包括UIWebView,在底层都是通过CoreText排版、绘制为位图显示的。常见的文本控件,其排版与绘制都是在主线程进行的,显示大量文本是,CPU压力很大。对此解决方案唯一就是自定义文本控件,用CoreText对文本异步绘制。(很麻烦,开发成本高)
5、图片解码:当用UIImage或CGImageSource创建图片时,图片数据并不会立刻解码。图片设置到UIImageView或CALayer.contents中去,并且CALayer被提交到GPU前,CGImage中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。SD_WebImage处理方式:在后台线程先把图片绘制到CGBitmapContext中,然后从Bitmap直接创建图片。
6、图像绘制:图像的绘制通常是指用那些以CG开头的方法把图像绘制到画布中,然后从画布创建图片并显示的一个过程。CoreGraphics方法是线程安全的,可以异步绘制,主线程回调。
7、控制一下线程的最大并发数量
GPU资源消耗分析
1、纹理混合:尽量减少短时间内大量图片的显示,尽可能将多张图片合成一张进行显示。GPU能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸
2、视图混合:尽量减少视图层次和数量,减少透明的视图(alpha<1),不透明的就设置opaque为YES。
3、图形生成:尽量避免离屏渲染,尽量采用异步绘制,尽量避免使用圆角、阴影、遮罩等属性。必要时用静态图片实现展示效果,也可尝试光栅化缓存复用属性。
什么是离屏渲染?
在OpenGL中,GPU有2种渲染方式
- On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作
- Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作
离屏渲染消耗性能的原因
- 需要创建新的缓冲区
- 离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕
哪些操作会触发离屏渲染?
- 光栅化:layer.shouldRasterize = YES
- 遮罩:layer.mask
- 圆角:同时设置layer.masksToBounds = YES、layer.cornerRadius大于0。考虑通过CoreGraphics绘制裁剪圆角,或者叫美工提供圆角图片
- 阴影:layer.shadowXXX,如果设置了layer.shadowPath就不会产生离屏渲染
画圆角避免离屏渲染
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]; }
查看离屏渲染,模拟器可以选中“Debug - Color Off-screen Rendered”开启调试,真机可以用Instruments检测,“Instruments - Core Animation - Debug Options - Color Offscreen-Rendered Yellow”开启调试,开启后,有离屏渲染的图层会变成高亮的黄色。
卡顿检测
原理
平时所说的“卡顿”主要是因为在主线程执行了比较耗时的操作,可以添加Observer到主线程RunLoop中,通过监听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释放并新建AutoreleasePool: _objc_autoreleasePoolPop(); _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); }
那么,我们卡顿监控在 Runloop 的起始最开始和结束最末尾位置添加 Observer,从而获得主线程的开始和结束状态。卡顿监控起一个子线程定时检查主线程的状态,当主线程的状态运行超过一定阈值则认为主线程卡顿,从而标记为一个卡顿。
分析实现
使用Runloop进行卡顿监控之后,需要定义一个阀值来判定卡顿的出现,并记录下来,上报到服务器
比如:
1、主程序 Runloop 超时的阈值是 2 秒,子线程的检查周期是 1 秒。每隔 1 秒,子线程检查主线程的运行状态;如果检查到主线程 Runloop 运行超过 2 秒则认为是卡顿,并获得当前的线程快照。
2、假定连续5次超时50ms认为卡顿(当然也包含了单次超时250ms)
可参考的核心代码:
// 开始监听 - (void)startMonitor { if (observer) { return; } // 创建信号 semaphore = dispatch_semaphore_create(0); NSLog(@"dispatch_semaphore_create:%@",[BGPerformanceMonitor getCurTime]); // 注册RunLoop状态观察 CFRunLoopObserverContext 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 the timeout occurred. 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 - 即将处理source 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; } }); }
也可以查看一个开源库:LXDAppFluecyMonitor ,里面有打印出堆栈信息。
实际项目使用
当前,实际项目使用,是使用腾讯微信的开源库,Matrix,说明wiki:Matrix-iOS 卡顿监控
上传到服务器之后,需要进行日志符号化堆栈解析,可参考:iOS crash 日志堆栈解析
解析成我们想要看懂的样子,如:
主要分析一下最顶的主线程出现的卡顿位置,再结合代码去查看。