在日常开发中经常会出现卡顿的现象(丢帧),给用户的感觉很不好。那么这个现象是怎样产生的,如何检测到掉帧,要怎样去优化呢?本文将针对这几个问题进行分析
界面渲染流程
在界面的渲染过程中CPU和GPU起了比较重要的作用
CPU与GPU
-
CPU全名是Central Processing Unit(中央处理器),程序在加载资源、对象的创建和销毁、对象属性的调整、布局计算、Autolayout、文本渲染,文本的计算和排版、图片格式转码和解码、图像的绘制(Core Graphics)时,都需要依赖CPU来执行 -
GPU全名是Graphics Processing Unit(图像处理器),它是一个专门为图形高并发计算而量身定做的处理单元,比CPU使用更少的电来完成工作并且GPU的浮点计算能力要超出CPU很多。相对于CPU来说,GPU能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合(合成)并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。GPU的渲染性能要比CPU高效很多,同时对系统的负载和消耗也更低一些
在开发中,我们应该尽量让CPU负责主线程的UI调动,把图形显示相关的工作交给GPU来处理,当涉及到光栅化等一些工作时,CPU也会参与进来。
渲染过程
-
在讲渲染原理之前先介绍下
CRT显示器原理。-
CRT的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。然后电子枪回到初始位置进行下一次扫描。为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。 -
当电子枪
换行进行扫描时,显示器会发出一个 水平同步信号(horizonal synchronization),简称HSync; -
而当一帧画面绘制完成后,电子枪
回复到原位,准备画下一帧前,显示器会发出一个 垂直同步信号(vertical synchronization),简称VSync。显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。 -
CRT的电子枪扫描过程如下图所示: -
虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。
-
-
界面渲染的流程如下:
CPU计算->GPU渲染->帧缓冲区->视频控制器读取帧缓冲区的数据->显示器,如下图:
双缓冲机制+VSync
-
如果
GPU渲染后,因为某些原因没有存入帧缓冲区,这样就形成了等待,为了解决了这个问题,就产生了双缓冲机制,也就是前帧和后帧。- 当
GPU渲染完一帧后就会存入帧缓存区,然后视频控制器去读取缓冲帧缓存,同时GPU去渲染另一帧并存入另一个帧缓存区,这样来回的切换读取来显示界面,如下图:
- 这个切换也不是任意时间切的,我们常见的都是一秒渲染
60帧,也就是VSync每隔16.67ms发送一次信号 - 所以,视频控制器会按照
VSync信号逐帧读取帧缓冲区的数据
- 当
卡顿
卡顿产生原理
-
我们知道
VSync每隔16.67ms发送一次信号,两次发送信号之间cpu进行了计算,gpu渲染后存入帧缓冲区,那么如果计算的步骤花的时间比较长,那么存入帧缓存的渲染部分就比较少了,当视频控制器读取帧缓存时没有读全,此时就产生了丢帧,也就是卡顿。 -
卡顿过程如下图:
卡顿检测
- 每秒渲染帧数用
FPS(Frames Per Second)来表示,通常60fps为最佳,我们可以检测App的FPS来观察App是否流畅。
可以使用以下几种方式检测App的FPS:
CADisplayLink
-
系统在每次发送
VSync时,就会触发CADisplayLink,我们可以统计每秒发送VSync的数量来查看App的FPS是否稳定,主要代码如下:@interface ViewController () @property (nonatomic, strong) CADisplayLink *link; @property (nonatomic, assign) NSTimeInterval lastTime; // 每隔1秒记录一次时间 @property (nonatomic, assign) NSUInteger count; // 记录VSync1秒内发送的数量 @end self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkAction:)]; [_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; - (void)linkAction: (CADisplayLink *)link { if (_lastTime == 0) { _lastTime = link.timestamp; return; } _count++; NSTimeInterval delta = link.timestamp - _lastTime; if (delta < 1) return; _lastTime = link.timestamp; float fps = _count / delta; _count = 0; NSLog(@"🎈 fps : %f ", fps); }
RunLoop
-
在 Runloop原理 中,我们详细的分析了
Runloop,它的退出和进入实质都是Observer的通知,我们可以监听Runloop的状态,并在相关回调里发送信号,如果在设定的时间内能够收到信号说明是流畅的;如果在设定的时间内没有收到信号,说明发生了卡顿。具体实现如下:@interface WSBlockMonitor () { CFRunLoopActivity activity; } @property (nonatomic, strong) dispatch_semaphore_t semaphore; @property (nonatomic, assign) NSUInteger timeoutCount; @end - (void)registerObserver{ CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL}; //NSIntegerMax : 优先级最小 CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, NSIntegerMax, &CallBack, &context); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); } static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { WSBlockMonitor *monitor = (__bridge WSBlockMonitor *)info; monitor->activity = activity; // 发送信号 dispatch_semaphore_t semaphore = monitor->_semaphore; dispatch_semaphore_signal(semaphore); } - (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) // 即将处理sources,即将进入休眠 { if (++self->_timeoutCount < 2) { NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount); continue; } // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印! // 可在此处记录卡顿堆栈信息,进行排查 NSLog(@"检测到超过两次连续卡顿"); } } self->_timeoutCount = 0; } }); } // 调用方法 - (void)start{ [self registerObserver]; [self startMonitor]; }- 主要在主线程监听
Runloop即将处理事物和即将休眠两种状态,子线程监控时长,如果连续两次1秒内没有收到信号,说明发生了卡顿,此时可以记录卡顿的堆栈以便于排查。
- 主要在主线程监听
微信matrix
- 微信的
matrix也是借助runloop实现的,大体流程上面Runloop相同,它使用退火算法优化捕获卡顿的效率,防止连续捕获相同的卡顿,并且通过保存最近的20个主线程堆栈信息,获取最近最耗时堆栈。所以需要准确的分析卡顿原因可以借助微信matrix来分析卡顿。
滴滴DoraemonKit
DoraemonKit的卡顿检测方案并没有对runloop操作,它也是while循环中根据一定的状态判断,通过主线程中不断对发送信号semaphore,循环中等待信号的时间为5秒,等待超时则说明主线程卡顿,并进行相关上报。
优化方案
在检测到卡顿后,接下来就应该去进行相关的优化,我们可以通过以下几种方案
预排版
-
在开发中,布局我们通常选择用
Masonry或SnapKit,他们都是基于AutoLayout来实现的,自动布局通常在赋值过后才决定UI控件的大小。而根据苹果的介绍,相对于AutoLayout,frame产生的消耗要小的多 -
例如在复杂结构的
UITableViewCell中,赋值过后会产生UI控件的大小,如果cell比较多会进行频繁的计算,这样就会消耗性能。这种情况我们可以在请求完数据时,就计算好各个控件的Rect,然后在数据赋值时,也将各个控件的frame进行赋值,这样会大大减少计算。这个方案也叫做预排版,具体代码如下: -
数据
DataModel代码@interface DataModel : NSObject @property (nonatomic, strong) NSString *name; @end -
布局
LayoutModel代码// .h @interface LayoutModel : NSObject @property (nonatomic, assign) CGRect nameRect; @property (nonatomic, strong) DataModel *data; @property (nonatomic, assign) CGFloat height; - (instancetype)initWithModel:(DataModel *)model; // 初始化代码 @end // .m - (instancetype)initWithModel:(DataModel *)model { self = [super init]; if (self) { self.data = model; // 根据数据计算相关控件的size CGSize size = [self getSizeWithContent:model.name]; self.nameRect = CGRectMake(15, 100, size.width, size.height); self.height = 200; // 计算cell高度 } return self; } - (CGSize)getSizeWithContent: (NSString *)content { //根据文字计算size ... return CGSizeMake(100, 40); }layoutModel初始化时,要传入对应的model,然后根据model中的相关字段计算相应的size,然后讲相应的rect赋值给layoutModel中的相关字段。
-
cell代码// .h - (void)configCellWithModel: (LayoutModel *)model; // .m @interface TestCell () @property (nonatomic, strong) UILabel *nameLbl; @end @implementation TestCell - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; if (self) { self.nameLbl = [UILabel new]; [self.contentView addSubview:_nameLbl]; } return self; } - (void)configCellWithModel:(LayoutModel *)model { self.nameLbl.frame = model.nameRect; // frame 赋值 self.nameLbl.text = model.data.name; // 数据赋值 }cell中的控件创建完后设置相关的颜色字体,然后在数据赋值时同时对frame进行赋值
-
VC代码@property (nonatomic, strong) NSMutableArray<LayoutModel *> *dataSource; // 模拟网络请求 dispatch_async(dispatch_get_global_queue(0, 0), ^{ NSDictionary *dataDic; // 网络请求数据 NSMutableArray<DataModel *> *modelArray = [NSMutableArray array]; for (NSDictionary *dic in dataDic[@"data"]) { DataModel *model = [DataModel yyModel: dic]; // 相关的json转model方法 [modelArray addObject:model]; } self.dataSource = [NSMutableArray arrayWithCapacity:modelArray.count]; for (DataModel *model in modelArray) { LayoutModel *layout = [[LayoutModel alloc] initWithModel:model]; // 根据数据model,初始化layoutModel,并进行控件的layout计算 [self.dataSource addObject:layout]; } // 计算完成后reloadData dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView reloadData]; }); }); - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return _dataSource.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { NSString *identifier = @"cellID"; TestCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier]; if (!cell) { cell = [[TestCell alloc] initWithStyle:(UITableViewCellStyleDefault) reuseIdentifier:identifier]; } [cell configCellWithModel:self.dataSource[indexPath.row]]; return cell; } - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { LayoutModel *model = self.dataSource[indexPath.row]; return model.height; }vc中主要是网络请求后创建数据model后,然后根据将dataModel创建layoutModel并进行相关计算,完成后再reloadData,这样就一次性将相关的布局计算好,滑动cell时只是进行赋值,无需额外的布局耗时计算
预解码&预渲染
-
预解码主要是对图像视频类进行优化,例如
UIImage,它的加载流程如下:Data Buffer(数据缓冲区)解码后缓存到Image Buffer(影响缓冲区),然后存入帧缓冲区再进行渲染。- 解码的过程是比较消耗资源的,所以可以将解码工作放到子线程,提前做好一些解码工作
- 图片解码具体的做法可以参考
SDWebImage中的处理,而音视频的解码可以参考FFmpeg
按需加载
- 按需加载顾名思义就是需要时再加载,例如
TableView,在滑动时每出现一个cell就会走cellForRow里的赋值方法,有些cell刚出现后又马上在界面消失,像这种可以监听滑动的状态,当滑动停止时根据tableView的visibleCells获取当前可见cell,然后对这些cell进行赋值,这样也节省了很多的开销。
异步渲染
-
异步渲染就是在子线程把需要绘制的图形提前处理好,然后将处理好的图像数据直接返给主线程使用,这样可以降低主线程的压力。
-
异步渲染操作的是
layer层,将展示的内容通过UIGraphics画成一张image然后展示在layer.content上 -
我们知道绘制会执行
drawRect:方法,在方法中查看堆栈得知:- 在堆栈中得知
CALayer在调用display方法后回去调用绘制相关的方法,根据流程我们来实现一个简单的绘制:
// WSLyer.m - (void)display { // 创建context CGContextRef context = (__bridge CGContextRef)[self.delegate performSelector:@selector(createContext)]; [self.delegate layerWillDraw:self]; // 绘制的准备工作 [self drawInContext:context]; //绘制 [self.delegate displayLayer:self]; // 展示位图 [self.delegate performSelector:@selector(closeContext)]; // 结束绘制 } // WSView.m - (void)drawRect:(CGRect)rect { // Drawing code } + (Class)layerClass { return [WsLayer class]; } // 创建context - (CGContextRef)createContext { UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale); CGContextRef context = UIGraphicsGetCurrentContext(); return context; } - (void)layerWillDraw:(CALayer *)layer { // 绘制的准备工作 } - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx { [super drawLayer:layer inContext:ctx]; // 形状 CGContextMoveToPoint(ctx, self.bounds.size.width / 2- 20, 20); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 20, 20); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 40, 60); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 40, 60); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 20, 20); CGContextSetFillColorWithColor(ctx, UIColor.magentaColor.CGColor); CGContextSetStrokeColorWithColor(ctx, UIColor.magentaColor.CGColor); // 描边 CGContextDrawPath(ctx, kCGPathFillStroke); // 文字 [@"无双" drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 70, 80, 24) withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:15],NSForegroundColorAttributeName: UIColor.blackColor}]; // 图片 [[UIImage imageNamed:@"buou"] drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 100, 60, 50)]; } // 主线程渲染 - (void)displayLayer:(CALayer *)layer { UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); dispatch_async(dispatch_get_main_queue(), ^{ layer.contents = (__bridge id)(image.CGImage); }); } // 结束context - (void)closeContext { UIGraphicsEndImageContext(); }- 在
VC中只需要添加view对象即可
// ViewController.m WsView *view = [[WsView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)]; view.backgroundColor = UIColor.yellowColor; [self.view addSubview:view];运行结果和层级关系如下:
- 在堆栈中得知
-
异步渲染处理起来相对要复杂些,具体的实践可以参照美团的 Graver,或者 YYAsyncLayer 这两个异步渲染框架。