1. 卡顿原理

界面的显示:
image.png

  • CPU:用于计算,将结果提交GPU

  • GPU:用于渲染,将结果放入FrameBuffer(帧缓冲)

  • Video Controller(视频控制器)会根据Vsync(垂直同步)信号,逐行读取FrameBuffer中的数据

  • 经过数模转换传递给Monitor(显示器)进行显示

1.1 屏幕撕裂

界面图像的展示,会不断从帧缓冲区读取一帧一帧的数据进行显示

当遇到耗时的计算或渲染情况,导致从帧缓冲区获取的下一帧数据还没有准备好

此时显示的还是旧数据,但显示过程中,下一帧数据准备完毕,导致部分显示的又是新数据,这样就会造成屏幕撕裂

1.2 界面卡顿

为了解决这种问题,苹果使用双缓冲机制 + 垂直同步信号,使用两个帧缓冲区存储GPU处理结果,当屏幕显示其中一个缓存区内容时,另一个缓冲区继续等待下一个缓冲结果,两个缓冲区依次进行交替

这样的优化可以解决屏幕撕裂,但也出现了新的问题,掉帧
image.png

  • 当屏幕重复显示同一帧数据就是掉帧,我们看到的效果就是界面卡顿

产生掉帧的情况:收到垂直信号后,CPUGPU还没有将下一帧数据放到对应的帧缓冲区。导致屏幕显示的仍是当前画面

2. 卡顿检测

2.1 YYFPSLabel

YYKit框架中,提供了一个检测刷新频率的YYFPSLabel控件

  1. #import "YYFPSLabel.h"
  2. #import "YYKit.h"
  3. #define kSize CGSizeMake(55, 20)
  4. @implementation YYFPSLabel {
  5. CADisplayLink *_link;
  6. NSUInteger _count;
  7. NSTimeInterval _lastTime;
  8. UIFont *_font;
  9. UIFont *_subFont;
  10. NSTimeInterval _llll;
  11. }
  12. - (instancetype)initWithFrame:(CGRect)frame {
  13. if (frame.size.width == 0 && frame.size.height == 0) {
  14. frame.size = kSize;
  15. }
  16. self = [super initWithFrame:frame];
  17. self.layer.cornerRadius = 5;
  18. self.clipsToBounds = YES;
  19. self.textAlignment = NSTextAlignmentCenter;
  20. self.userInteractionEnabled = NO;
  21. self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];
  22. _font = [UIFont fontWithName:@"Menlo" size:14];
  23. if (_font) {
  24. _subFont = [UIFont fontWithName:@"Menlo" size:4];
  25. } else {
  26. _font = [UIFont fontWithName:@"Courier" size:14];
  27. _subFont = [UIFont fontWithName:@"Courier" size:4];
  28. }
  29. _link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
  30. [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
  31. return self;
  32. }
  33. - (void)dealloc {
  34. [_link invalidate];
  35. }
  36. - (CGSize)sizeThatFits:(CGSize)size {
  37. return kSize;
  38. }
  39. // 60 vs 16.67ms
  40. // 1/60 * 1000
  41. - (void)tick:(CADisplayLink *)link {
  42. if (_lastTime == 0) {
  43. _lastTime = link.timestamp;
  44. return;
  45. }
  46. _count++;
  47. NSTimeInterval delta = link.timestamp - _lastTime;
  48. if (delta < 1) return;
  49. _lastTime = link.timestamp;
  50. float fps = _count / delta;
  51. _count = 0;
  52. CGFloat progress = fps / 60.0;
  53. UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];
  54. NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];
  55. [text setColor:color range:NSMakeRange(0, text.length - 3)];
  56. [text setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
  57. text.font = _font;
  58. [text setFont:_subFont range:NSMakeRange(text.length - 4, 1)];
  59. self.attributedText = text;
  60. }
  61. @end
  • 使用CADisplayLink同步屏幕刷新频率的计时器

  • 通过刷新次数 / 时间差,计算出刷新频率

  • 刷新频率一般每秒刷新60次,平均每16.67ms刷新一次。以此监听是否出现掉帧的情况

2.2 RunLoop卡顿检测

通过监听主RunLoop的事务变化进行卡顿检测

  1. #import "LGBlockMonitor.h"
  2. @interface LGBlockMonitor (){
  3. CFRunLoopActivity activity;
  4. }
  5. @property (nonatomic, strong) dispatch_semaphore_t semaphore;
  6. @property (nonatomic, assign) NSUInteger timeoutCount;
  7. @end
  8. @implementation LGBlockMonitor
  9. + (instancetype)sharedInstance {
  10. static id instance = nil;
  11. static dispatch_once_t onceToken;
  12. dispatch_once(&onceToken, ^{
  13. instance = [[self alloc] init];
  14. });
  15. return instance;
  16. }
  17. - (void)start{
  18. [self registerObserver];
  19. [self startMonitor];
  20. }
  21. static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
  22. {
  23. LGBlockMonitor *monitor = (__bridge LGBlockMonitor *)info;
  24. monitor->activity = activity;
  25. // 发送信号
  26. dispatch_semaphore_t semaphore = monitor->_semaphore;
  27. dispatch_semaphore_signal(semaphore);
  28. }
  29. - (void)registerObserver{
  30. CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};
  31. //NSIntegerMax : 优先级最小
  32. CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
  33. kCFRunLoopAllActivities,
  34. YES,
  35. NSIntegerMax,
  36. &CallBack,
  37. &context);
  38. CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
  39. }
  40. - (void)startMonitor{
  41. // 创建信号
  42. _semaphore = dispatch_semaphore_create(0);
  43. // 在子线程监控时长
  44. dispatch_async(dispatch_get_global_queue(0, 0), ^{
  45. while (YES)
  46. {
  47. // 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
  48. long st = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
  49. if (st != 0)
  50. {
  51. if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting)
  52. {
  53. if (++self->_timeoutCount < 2){
  54. NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
  55. continue;
  56. }
  57. // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
  58. NSLog(@"检测到超过两次连续卡顿");
  59. }
  60. }
  61. self->_timeoutCount = 0;
  62. }
  63. });
  64. }
  65. @end
  • 定义信号量,在全局并发队列中,加入异步函数,创建while死循环,内部让信号量进入休眠状态,定义一秒的超时时间

  • 监听主RunLoop的所有事务,在回调方法中,对信号量发送释放的通知

  • 如果信号量时间,检查Observer的状态。如果是处理Sources或处于唤醒状态,证明还在做事情,将超时次数+1

  • 如果连续两次,视为卡顿

2.3 Matrix

一款微信研发并日常使用的应用性能接入框架,支持iOS, macOSAndroid

通过接入各种性能监控方案,对性能监控项的异常数据进行采集和分析,输出相应的问题分析、定位与优化建议,从而帮助开发者开发出更高质量的应用

监控范围包括:崩溃卡顿和内存监控

  • WCCrashBlockMonitorPlugin:基于KSCrash框架开发,具有业界领先的卡顿堆栈捕获能力,同时兼备崩溃捕获能力

  • WCMemoryStatPlugin:一款性能优化到极致的爆内存监控工具,能够全面捕获应用爆内存时的内存分配以及调用堆栈情况

核心原理
image.png

  • 添加主RunLoop的监听,创建子线程,监听卡顿

2.4 DoraemonKit

DoKit是一款面向泛前端产品研发全生命周期的效率平台,包含常用工具、性能检测、视觉工具

其中性能检测可以对帧率、CPU、内存、流量监控、卡顿等信息进行监控

DoKit对于卡顿的监控方案,自定义DoraemonPingThread,重写线程的main方法

  1. - (void)main {
  2. //判断是否需要上报
  3. __weak typeof(self) weakSelf = self;
  4. void (^ verifyReport)(void) = ^() {
  5. __strong typeof(weakSelf) strongSelf = weakSelf;
  6. if (strongSelf.reportInfo.length > 0) {
  7. if (strongSelf.handler) {
  8. double responseTimeValue = [[NSDate date] timeIntervalSince1970];
  9. double duration = (responseTimeValue - strongSelf.startTimeValue)*1000;
  10. strongSelf.handler(@{
  11. @"title": [DoraemonUtil dateFormatNow].length > 0 ? [DoraemonUtil dateFormatNow] : @"",
  12. @"duration": [NSString stringWithFormat:@"%.0f",duration],//单位ms
  13. @"content": strongSelf.reportInfo
  14. });
  15. }
  16. strongSelf.reportInfo = @"";
  17. }
  18. };
  19. while (!self.cancelled) {
  20. if (_isApplicationInActive) {
  21. self.mainThreadBlock = YES;
  22. self.reportInfo = @"";
  23. self.startTimeValue = [[NSDate date] timeIntervalSince1970];
  24. dispatch_async(dispatch_get_main_queue(), ^{
  25. self.mainThreadBlock = NO;
  26. verifyReport();
  27. dispatch_semaphore_signal(self.semaphore);
  28. });
  29. [NSThread sleepForTimeInterval:self.threshold];
  30. if (self.isMainThreadBlock) {
  31. self.reportInfo = [DoraemonBacktraceLogger doraemon_backtraceOfMainThread];
  32. }
  33. dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 5.0 * NSEC_PER_SEC));
  34. {
  35. //卡顿超时情况;
  36. verifyReport();
  37. }
  38. } else {
  39. [NSThread sleepForTimeInterval:self.threshold];
  40. }
  41. }
  42. }
  • 间隔一段时间去主线程执行任务,类似于心跳监听

3. 优化方案

我们除了一些常规方案之外

  • 避免离屏渲染

  • 避免使用透明UIView

  • 尽量使用PNG图片

还可以尝试以下几种更见效的方案

3.1 预排版

预排版的目的,避免在UITableViewheightForRowAtIndexPath方法中进行高度的计算

当网络请求拿到数据源时,使用异步任务将数据转换为Model,同时按照业务需求,提前将Cell的高度计算出来

案例:

  1. - (void)loadData{
  2. //外面的异步线程:网络请求的线程
  3. dispatch_async(dispatch_get_global_queue(0, 0), ^{
  4. //加载`JSON 文件`
  5. NSString *path = [[NSBundle mainBundle] pathForResource:@"timeLine" ofType:@"json"];
  6. NSData *data = [[NSData alloc] initWithContentsOfFile:path];
  7. NSDictionary *dicJson=[NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil];
  8. //数据转换为Model
  9. for (id json in dicJson[@"data"]) {
  10. LGTimeLineModel *timeLineModel = [LGTimeLineModel yy_modelWithJSON:json];
  11. [self.timeLineModels addObject:timeLineModel];
  12. }
  13. //预排版
  14. for (LGTimeLineModel *timeLineModel in self.timeLineModels) {
  15. LGTimeLineCellLayout *cellLayout = [[LGTimeLineCellLayout alloc] initWithModel:timeLineModel];
  16. [self.layouts addObject:cellLayout];
  17. }
  18. dispatch_async(dispatch_get_main_queue(), ^{
  19. [self.timeLineTableView reloadData];
  20. });
  21. });
  22. }

3.2 预解码

程序中加载来自云端的图片,最长用的就是SDWebImage框架。该框架除了使用便捷之外,还对图片加载进行了预解码等优化

日常开发中遇到的UIImage,并不是真正的图片,而是一个模型
image.png

UIImage的二进制流存储在DataBuffer中,经过decode解码,加载到imageBuffer中,最终进入FrameBuffer才能被渲染
image.png

当使用UIImageCGImageSource的方法创建图片时,图片的数据不会立即解码,而是在设置UIImageView.image时解码

将图片设置到UIImageView/CALayer.contents中,然后在CALayer提交至GPU渲染前,CGImage中的数据才进行解码

如果任由系统处理,这一步则无法避免,并且会发生在主线程中。如果想避免这个机制,在子线程先将图片绘制到CGBitmapContext,然后从Bitmap中创建图片

SDWebImage框架中对图片的预解码处理,就是优化了这个系统机制

打开SDWebImageDownloaderOperation.m文件
image.png

  • 使用异步任务,加入到自定义串行队列中,对图片进行预解码处理

将二进制流转为CGImageSourceRef

  1. CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);

CGImageSourceRef中,读取图片宽高,Exif等信息,生成CGImageRef格式
image.png

3.3 按需加载

如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定3行加载

  1. - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
  2. [needLoadArr removeAllObjects];
  3. }
  4. - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{
  5. NSIndexPath *ip = [self indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
  6. NSIndexPath *cip = [[self indexPathsForVisibleRows] firstObject];
  7. NSInteger skipCount = 8;
  8. if (labs(cip.row-ip.row)>skipCount) {
  9. NSArray *temp = [self indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.width, self.height)];
  10. NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];
  11. if (velocity.y<0) {
  12. NSIndexPath *indexPath = [temp lastObject];
  13. if (indexPath.row+3<datas.count) {
  14. [arr addObject:[NSIndexPath indexPathForRow:indexPath.row+1 inSection:0]];
  15. [arr addObject:[NSIndexPath indexPathForRow:indexPath.row+2 inSection:0]];
  16. [arr addObject:[NSIndexPath indexPathForRow:indexPath.row+3 inSection:0]];
  17. }
  18. } else {
  19. NSIndexPath *indexPath = [temp firstObject];
  20. if (indexPath.row>3) {
  21. [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-3 inSection:0]];
  22. [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-2 inSection:0]];
  23. [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-1 inSection:0]];
  24. }
  25. }
  26. [needLoadArr addObjectsFromArray:arr];
  27. }
  28. }

在滑动结束时进行Cell的渲染

  1. - (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView{
  2. scrollToToping = YES;
  3. return YES;
  4. }
  5. - (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView{
  6. scrollToToping = NO;
  7. [self loadContent];
  8. }
  9. - (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView{
  10. scrollToToping = NO;
  11. [self loadContent];
  12. }
  13. //用户触摸时第一时间加载内容
  14. - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
  15. if (!scrollToToping) {
  16. [needLoadArr removeAllObjects];
  17. [self loadContent];
  18. }
  19. return [super hitTest:point withEvent:event];
  20. }
  21. - (void)loadContent{
  22. if (scrollToToping) {
  23. return;
  24. }
  25. if (self.indexPathsForVisibleRows.count<=0) {
  26. return;
  27. }
  28. if (self.visibleCells && self.visibleCells.count>0) {
  29. for (id temp in [self.visibleCells copy]) {
  30. VVeboTableViewCell *cell = (VVeboTableViewCell *)temp;
  31. [cell draw];
  32. }
  33. }
  34. }

处理方式要根据需求自行处理,毕竟这种方式会影响体验,大部分仅针对滑动时的图片加载进行优化

3.4 异步渲染

UIViewCALayer各负其职:

  • UIView

    • UIView基于UIKit框架

    • 负责界面布局和子视图的管理

    • 可以处理用户触摸事件

    • 负责绘制图形和动画操作

  • CALayer

    • CALayer基于CoreAnimation

    • 只负责显示,且显示的是位图

    • 不能处理用户的触摸事件

    • 可用于iOS平台的UIKit框架,也可用于Mac OSX系统下的APPKit框架

UIViewCALayer的关系:

  • UIView基于UIKit框架,可以处理用户触摸事件,并管理子视图

  • CALayer基于CoreAnimation,而CoreAnimation是基于QuartzCode的。所以CALayer只负责显示,不能处理用户的触摸事件

  • CALayer继承于NSObject,而UIView继承于UIResponder,所以UIVIew相比CALayer多了事件处理功能

  • 从底层来说,UIView属于UIKit的组件,而UIKit的组件到最后都会被分解成layer,存储到图层树中

  • 在应用层面来说,需要与用户交互时,使用UIView,不需要交互时,使用两者都可以

CALayer基于CoreAnimation,其中CoreAnimationg是一个复合引擎,主要的职责包括渲染、构建和动画实现
image.png

CoreAnimation的渲染流程:
image.png

案例:

自定义LGView,继承于UIView

  1. #import <UIKit/UIKit.h>
  2. #import "LGLayer.h"
  3. @interface LGView : UIView
  4. - (CGContextRef)createContext;
  5. - (void)closeContext;
  6. @end
  7. @implementation LGView
  8. // Only override drawRect: if you perform custom drawing.
  9. // An empty implementation adversely affects performance during animation.
  10. - (void)drawRect:(CGRect)rect {
  11. // Drawing code, 绘制的操作, BackingStore(额外的存储区域产于的) -- GPU
  12. }
  13. // 这一个操作分解
  14. // 1: view layer
  15. ////子视图的布局
  16. //- (void)layoutSubviews{
  17. // [super layoutSubviews];
  18. //}
  19. + (Class)layerClass{
  20. return [LGLayer class];
  21. }
  22. - (void)layoutSublayersOfLayer:(CALayer *)layer{
  23. [super layoutSublayersOfLayer:layer];
  24. [self layoutSubviews];
  25. }
  26. - (CGContextRef)createContext{
  27. UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
  28. CGContextRef context = UIGraphicsGetCurrentContext();
  29. return context;
  30. }
  31. - (void)layerWillDraw:(CALayer *)layer{
  32. //绘制的准备工作,do nontihing
  33. //
  34. }
  35. //////绘制的操作
  36. - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
  37. [super drawLayer:layer inContext:ctx];
  38. [[UIColor redColor] set];
  39. //Core Graphics
  40. UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(self.bounds.size.width / 2- 20, self.bounds.size.height / 2- 20, 40, 40)];
  41. CGContextAddPath(ctx, path.CGPath);
  42. CGContextFillPath(ctx);
  43. }
  44. //////layer.contents = (位图)
  45. - (void)displayLayer:(CALayer *)layer{
  46. UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
  47. dispatch_async(dispatch_get_main_queue(), ^{
  48. layer.contents = (__bridge id)(image.CGImage);
  49. });
  50. }
  51. - (void)closeContext{
  52. UIGraphicsEndImageContext();
  53. }
  54. @end

自定义LGLayer,继承于CALayer

  1. #import <QuartzCore/QuartzCore.h>
  2. @interface LGLayer : CALayer
  3. @end
  4. @implementation LGLayer
  5. //前面断点调用写下的代码
  6. - (void)layoutSublayers{
  7. if (self.delegate && [self.delegate respondsToSelector:@selector(layoutSublayersOfLayer:)]) {
  8. //UIView
  9. [self.delegate layoutSublayersOfLayer:self];
  10. }else{
  11. [super layoutSublayers];
  12. }
  13. }
  14. //绘制流程的发起函数
  15. - (void)display{
  16. // Graver 实现思路
  17. CGContextRef context = (__bridge CGContextRef)([self.delegate performSelector:@selector(createContext)]);
  18. [self.delegate layerWillDraw:self];
  19. [self drawInContext:context];
  20. [self.delegate displayLayer:self];
  21. [self.delegate performSelector:@selector(closeContext)];
  22. }
  23. @end

所有的渲染流程都可以放在子线程异步执行,最终显示layer.contents = (__bridge id)(image.CGImage)切换到主线程执行即可

异步渲染的框架推荐:Graver

一款高效的UI渲染框架,它以更低的资源消耗来构建十分流畅的UI界面。渲染整个过程除画板视图外完全没有使用UIKit控件,最终产出的结果是一张位图(Bitmap),视图层级、数量大幅降低

Graver的渲染流程:
image.png

最终呈现效果:
image.png

总结

界面的显示:

  • CPU:用于计算,将结果提交GPU

  • GPU:用于渲染,将结果放入FrameBuffer(帧缓冲)

  • Video Controller(视频控制器)会根据Vsync(垂直同步)信号,逐行读取FrameBuffer中的数据

  • 经过数模转换传递给Monitor(显示器)进行显示

屏幕撕裂:

  • 界面图像的展示,会不断从帧缓冲区读取一帧一帧的数据进行显示

  • 当遇到耗时的计算或渲染情况,导致从帧缓冲区获取的下一帧数据还没有准备好

  • 此时显示的还是旧数据,但显示过程中,下一帧数据准备完毕,导致部分显示的又是新数据,这样就会造成屏幕撕裂

界面卡顿:

  • 苹果使用双缓冲机制 + 垂直同步信号,使用两个帧缓冲区存储GPU处理结果,当屏幕显示其中一个缓存区内容时,另一个缓冲区继续等待下一个缓冲结果,两个缓冲区依次进行交替

  • 产生掉帧的情况:收到垂直信号后,CPUGPU还没有将下一帧数据放到对应的帧缓冲区。导致屏幕显示的仍是当前画面

  • 当屏幕重复显示同一帧数据就是掉帧,我们看到的效果就是界面卡顿

卡顿检测:

  • YYFPSLabel:检测刷新频率

  • RunLoop卡顿检测:通过监听主RunLoop的事务变化进行卡顿检测

  • Matrix:一款微信研发并日常使用的应用性能接入框架,监控范围包括崩溃卡顿和内存监控

  • DoraemonKit:一款面向泛前端产品研发全生命周期的效率平台,包含常用工具、性能检测、视觉工具

优化方案

  • 预排版:避免在UITableViewheightForRowAtIndexPath方法中进行高度的计算

  • 预解码:提前对图片进行解码操作

  • 按需加载:处理方式要根据需求自行处理,毕竟这种方式会影响体验,大部分仅针对滑动时的图片加载进行优化

  • 异步渲染:让UIViewCALayer各负其职,渲染流程都可以放在子线程异步执行,最终显示CGImage切换到主线程执行即可

    • Graver异步渲染框架:渲染整个过程除画板视图外完全没有使用UIKit控件,最终产出的结果是一张位图(Bitmap),视图层级、数量大幅降低