iOS之性能优化·优化App界面渲染与保持界面流畅性的技巧

Posted Forever_wj

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS之性能优化·优化App界面渲染与保持界面流畅性的技巧相关的知识,希望对你有一定的参考价值。

一、界面渲染流程

① 渲染流程分析
  • 计算机中的显示过程通常是通过 CPU、GPU、显示器协同工作来将图片显示到屏幕上,如下图所示:

在这里插入图片描述

  • 苹果为了解决图片撕裂的问题使用了 VSync + 双缓冲区的形式,就是显示器显示完成一帧的渲染的时候会向发送一个垂直信号 VSync,收到这个这个垂直信号之后显示器开始读取另外一个帧缓冲区中的数据而 App 接到垂直信号之后开始新一帧的渲染。
    • CPU 计算好显示内容,提交至 GPU;
    • GPU 经过渲染完成后将渲染的结果放入 FrameBuffer(帧缓存区);
    • 随后视频控制器会按照 VSync 信号逐行读取 FrameBuffer 的数据;
    • 经过可能的数模转换传递给显示器进行显示。
  • 最开始时,FrameBuffer 只有一个,这种情况下 FrameBuffer 的读取和刷新的效率问题会受到很大的影响,双缓冲机制就可以很好的解决这个问题:GPU 会预先渲染好一帧放入 FrameBuffer,让视频控制器读取,当下一帧渲染好后,GPU 会直接将视频控制器的指针指向第二个 FrameBuffer。
  • 双缓存机制虽然解决了效率问题,但是随之而言的是新的问题,当视频控制器还未读取完成时,例如屏幕内容刚显示一半,GPU 将新的一帧内容提交到 FrameBuffer,并将两个 FrameBuffer 而进行交换后,视频控制器就会将新的一帧数据的下半段显示到屏幕上,造成屏幕撕裂现象。
  • 为了解决这个问题,采用了垂直同步信号机制。当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和 FrameBuffer 更新,而目前 ios 设备中采用的正是双缓存区 + VSync。
② 屏幕卡顿原因
  • 在 VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变,中间这个等待的过程就造成了掉帧,也就是会卡顿。
  • 如下图所示,是一个显示过程,第 1 帧在 VSync 到来前,处理完成,正常显示,第 2 帧在 VSync 到来后,仍在处理中,此时屏幕不刷新,依旧显示第 1 帧,此时就出现了掉帧情况,渲染时就会出现明显的卡顿现象:

在这里插入图片描述

二、卡顿检测

① FPS 监控
  • 苹果的 iPhone 推荐的刷新率是60Hz,也就是每秒中刷新屏幕 60 次,也就是每秒中有 60 帧渲染完成,差不多每帧渲染的时间是 1000/60 = 16.67 毫秒整个界面会比较流畅,一般刷新率低于 45Hz ,在 16.67ms 内没有准备好下一帧数据,就会出现明显的卡顿现象。
  • FPS 的监控可以通过 YYFPSLabel 来实现,该原理主要是依靠 CADisplayLink 来实现的,通过 CADisplayLink 来监听每次屏幕刷新并获取屏幕刷新的时间,借助link的时间差,来计算一次刷新刷新所需的时间,然后通过“刷新次数 / 时间差”得到刷新频次,然后使用次数(也就是1)除以每次刷新的时间间隔得到 FPS,并判断是否其范围,通过显示不同的文字颜色来表示卡顿严重程度,具体源码如下:
	#import "YYFPSLabel.h"
	#import "YYKit.h"
	
	#define kSize CGSizeMake(55, 20)
	
	@implementation YYFPSLabel {
	  CADisplayLink *_link;
	  NSUInteger _count;
	  NSTimeInterval _lastTime;
	  UIFont *_font;
	  UIFont *_subFont;
	
	  NSTimeInterval _llll;
	}
	
	- (instancetype)initWithFrame:(CGRect)frame {
	  if (frame.size.width == 0 && frame.size.height == 0) {
	      frame.size = kSize;
	  }
	  self = [super initWithFrame:frame];
	
	  self.layer.cornerRadius = 5;
	  self.clipsToBounds = YES;
	  self.textAlignment = NSTextAlignmentCenter;
	  self.userInteractionEnabled = NO;
	  self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];
	
	  _font = [UIFont fontWithName:@"Menlo" size:14];
	  if (_font) {
	      _subFont = [UIFont fontWithName:@"Menlo" size:4];
	  } else {
	      _font = [UIFont fontWithName:@"Courier" size:14];
	      _subFont = [UIFont fontWithName:@"Courier" size:4];
	  }
	
	  //YYWeakProxy 这里使用了虚拟类来解决强引用问题
	  _link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
	  [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
	  return self;
	}
	
	- (void)dealloc {
	  [_link invalidate];
	}
	
	- (CGSize)sizeThatFits:(CGSize)size {
	  return kSize;
	}
	
	- (void)tick:(CADisplayLink *)link {
	  if (_lastTime == 0) {
	      _lastTime = link.timestamp;
	      NSLog(@"sdf");
	      return;
	  }
	
	  // 次数
	  _count++;
	  // 时间
	  NSTimeInterval delta = link.timestamp - _lastTime;
	  if (delta < 1) return;
	  _lastTime = link.timestamp;
	  float fps = _count / delta;
	  _count = 0;
	
	  CGFloat progress = fps / 60.0;
	  UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];
	
	  NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];
	  [text setColor:color range:NSMakeRange(0, text.length - 3)];
	  [text setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
	  text.font = _font;
	  [text setFont:_subFont range:NSMakeRange(text.length - 4, 1)];
	
	  self.attributedText = text;
	}
	
	@end
  • FPS 只用在开发阶段的辅助性的数值,因为它会频繁唤醒 runloop,如果 runloop 在闲置的状态被 CADisplayLink 唤醒则会消耗性能。
  • FPS 的监控,具体实现逻辑如下:
	class YDWFPSLabel: UILabel {
	
	    fileprivate var link: CADisplayLink = {
	        let link = CADisplayLink.init()
	        return link
	    }()
	    
	    fileprivate var count: Int = 0
	    fileprivate var lastTime: TimeInterval = 0.0
	    fileprivate var fpsColor: UIColor = {
	        return UIColor.green
	    }()
	    fileprivate var fps: Double = 0.0
	    
	    override init(frame: CGRect) {
	        var f = frame
	        if f.size == CGSize.zero {
	            f.size = CGSize(width: 80.0, height: 22.0)
	        }
	        
	        super.init(frame: f)
	        
	        self.textColor = UIColor.white
	        self.textAlignment = .center
	        self.font = UIFont.init(name: "Menlo", size: 12)
	        self.backgroundColor = UIColor.lightGray
	        //通过虚拟类
	        link = CADisplayLink.init(target: YDWWeakProxy(target:self), selector: #selector(tick(_:)))
	        link.add(to: RunLoop.current, forMode: RunLoop.Mode.common)
	    }
	    
	    required init?(coder: NSCoder) {
	        fatalError("init(coder:) has not been implemented")
	    }
	    
	    deinit {
	        link.invalidate()
	    }
	    
	    @objc func tick(_ link: CADisplayLink){
	        guard lastTime != 0 else {
	            lastTime = link.timestamp
	            return
	        }
	        
	        count += 1
	        // 时间差
	        let detla = link.timestamp - lastTime
	        guard detla >= 1.0 else {
	            return
	        }
	        
	        lastTime = link.timestamp
	        // 刷新次数 / 时间差 = 刷新频次
	        fps = Double(count) / detla
	        let fpsText = "\\(String.init(format: "%.2f", fps)) FPS"
	        count = 0
	        
	        let attrMStr = NSMutableAttributedString(attributedString: NSAttributedString(string: fpsText))
	        if fps > 55.0 {
	            // 流畅
	            fpsColor = UIColor.green
	        }else if (fps >= 50.0 && fps <= 55.0){
	            // 一般
	            fpsColor = UIColor.yellow
	        }else{
	            // 卡顿
	            fpsColor = UIColor.red
	        }
	        
	        attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: fpsColor], range: NSMakeRange(0, attrMStr.length - 3))
	        attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: UIColor.white], range: NSMakeRange(attrMStr.length - 3, 3))
	        
	        DispatchQueue.main.async {
	            self.attributedText = attrMStr
	        }
	    }
	}
② 通过 RunLoop 检测卡顿
  • 通过监听主线程 Runloop 一次循环的时间来判断是否卡顿,这里需要配合使用 GCD 的信号量来实现,设置初始化信号量为 0,然后开一个子线程等待信号量的触发,也是就是在子线程的方法里面调用 dispatch_semaphore_wait 方法设置等待时间是 1 秒,然后主线程的 Runloop 的 Observer 回调方法中发送信号也就是调用 dispatch_semaphore_signal 方法,此时时间可以置为 0 了,如果是等待时间超时则看此时的 Runloop 的状态是否是 kCFRunLoopBeforeSources 或者是 kCFRunLoopAfterWaiting,如果在这两个状态下两秒则说明有卡顿,详细代码如下:
	#import "YDWBlockMonitor.h"
	
	@interface YDWYDWlockMonitor (){
	  CFRunLoopActivity activity;
	}
	
	@property (nonatomic, strong) dispatch_semaphore_t semaphore;
	@property (nonatomic, assign) NSUInteger timeoutCount;
	
	@end
	
	@implementation YDWBlockMonitor
	
	+ (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)
	{
	  YDWBlockMonitor *monitor = (__bridge YDWBlockMonitor *)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{
	  // 创建信号c
	  _semaphore = dispatch_semaphore_create(0);
	  // 在子线程监控时长
	  dispatch_async(dispatch_get_global_queue(0, 0), ^{
	      while (YES)
	      {
	          // 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
	          // 没有接收到信号底层会先对信号量进行减减操作,此时信号量就变成负数
	          // 所以开始进入等到,等达到了等待时间还没有收到信号则进行加加操作复原信号量
	          // 执行进入等待的方法dispatch_semaphore_wait会返回非0的数
	          // 收到信号的时候此时信号量是1  底层是减减操作,此时刚好等于0 所以直接返回0
	          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)
	              {
	                  //如果一直处于处理source0或者接受mach_port的状态则说明runloop的这次循环还没有完成
	                  if (++self->_timeoutCount < 2){
	                      NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
	                      continue;
	                  }
	                  // 如果超过两秒则说明卡顿了
	                  // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
	                  NSLog(@"检测到超过两次连续卡顿");
	              }
	          }
	          self->_timeoutCount = 0;
	      }
	  });
	}
	@end
③ 微信 matrix
  • 此方案也是借助 runloop 实现的大体流程和方案三相同,不过微信加入了堆栈分析,能够定位到耗时的方法调用堆栈,所以需要准确的分析卡顿原因可以借助微信 matrix 来分析卡顿。当然也可以在方案2中使用 PLCrashReporter 这个开源的第三方库来获取堆栈信息。
  • 微信 matrix 的下载链接:微信 matrix
④ 滴滴 DoraemonKit
  • 实现方案大概就是在子线程中一直 ping 主线程,在主线程卡顿的情况下,会出现断在的无响应的表现,进而检测卡顿。
  • 滴滴 DoraemonKit 的下载链接:滴滴 DoraemonKit

三、 CPU 资源消耗优化

① 预排版
  • 预排版主要是对 CPU 进行减负。
  • 假设现在又个 TableView 其中需要根据每个 cell 的内容来定 cell 的高度。知道 TableView 有重用机制,如果复用池中有数据,即将滑入屏内的 cell 就会使用复用池内的 cell,做到节省资源,但是还是要根据新数据的内容来计算 cell 的高度,重新布局新 cell 中内容的布局,这样反复滑动 TableView 相同的 cell 就会反复计算其 frame,这样也给 CPU 带来了负担。如果在得到数据创建模型的时候就把 cell frame 算出,TableView 返回模型中的 frame 这样的话同样的一条 cell 就算来回反复滑动 TableView,计算 frame 这个操作也就仅仅只会执行一次,所以也就做到了减负的功能,如下图:一个 cell 的组成需要 modal 找到数据,也需要 layout 找到这个 cell 如何布局:

在这里插入图片描述

② 预解码 & 预渲染
  • 图片的渲染流程,在 CPU 阶段拿到图片的顶点数据和纹理之后会进行解码生产位图,然后传递到 GPU 进行渲染,主要流程图如下:

在这里插入图片描述

  • 如果图片很多很大的情况下解码工作就会占用主线程 RunLoop 导致其他工作无法执行比如滑动,这样就会造成卡顿现象,所以这里就可以将解码的工作放到异步线程中不占用主线程,可能有人会想只要将图片加载放到异步线程中在异步线程中生成一个 UIImage 或者是 CGImage,然后再主线程中设置给 UIImageView,此时可以写段代码使用 instruments 的 Time Profiler,查看一下堆栈信息:

在这里插入图片描述

  • 发现图片的编解码还是在主线程,针对这种问题常见的做法是在子线程中先将图片绘制到 CGBitmapContext,然后从 Bitmap 直接创建图片,例如 SDWebImage 三方框架中对图片编解码的处理,这就是 Image 的预解码,代码如下:
	dispatch_async(queue, ^{
	 CGImageRef cgImage = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:self]]].CGImage;
	 CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
	
	 BOOL hasAlpha = NO;
	 if (alphaInfo == kCGImageAlphaPremultipliedLast ||
	     alphaInfo == kCGImageAlphaPremultipliedFirst ||
	     alphaInfo == kCGImageAlphaLast ||
	     alphaInfo == kCGImageAlphaFirst) {
	     hasAlpha = YES;
	 }
	
	 CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
	 bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
	
	 size_t width = CGImageGetWidth(cgImage);
	 size_t height = CGImageGetHeight(cgImage);
	
	 CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
	 CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
	 cgImage = CGBitmapContextCreateImage(context);
	
	 UIImage * image = [[UIImage imageWithCGImage:cgImage] cornerRadius:width * 0.5];
	 CGContextRelease(context);
	 CGImageRelease(cgImage);
	 completion(image);
	});
③ 按需加载
  • 顾名思义需要显示的加载出来,不需要显示的加载,例如 TableView 中的图片滑动的时候不加载,在滑动停止的时候加载(可以使用 Runloop,图片绘制设置 defaultModal 就行)。
④ 异步渲染
  • UIView 和 CALayer 的关系:
    • UIView 是基于 UIKit 框架的,能够接受点击事件,处理用户的触摸事件,并管理子视图;
    • CALayer 是基于 CoreAnimation,而 CoreAnimation 是基于 QuartzCode 的,所以 CALayer 只负责显示,不能处理用户的触摸事件;
    • UIView 是直接继承 UIResponder 的,CALayer 是继承 NSObject 的;
    • UIView 的主要职责是负责接收并响应事件;而 CALayer 的主要职责是负责显示 UI,UIView 依赖于 CALayer 得以显示。
  • UIView 主要负责时间处理,CALayer 主要是视图显示,异步渲染的原理其实也就是在子线程将所有的视图绘制成一张位图,然后回到主线程赋值给 layer 的 contents。例如 Graver 框架的异步渲染流程如下:

在这里插入图片描述

  • 核心源码如下:
	if (drawingFinished && targetDrawingCount == layer.drawingCount)
	{
	  CGImageRef CGImage = context ? CGBitmapContextCreateImage(context) : NULL;
	  {
	      // 让 UIImage 进行内存管理
	      // 最终生成的位图  
	      UIImage *image = CGImage ? [UIImage imageWithCGImage:CGImage] : nil;
	      void (^finishBlock)(void) = ^{
	          // 由于block可能在下一runloop执行,再进行一次检查
	          if (targetDrawingCount != layer.drawingCount)
	          {
	              failedBlock();
	              return;
	          }
	          //主线程中赋值完成显示
	          layer.contents = (id)image.CGImage;
	          // ...
	      }
	      if (drawInBackground) dispatch_async(dispatch_get_main_queue(), finishBlock);
	      else finishBlock();
	  }
	
	  // 一些清理工作: release CGImageRef, Image context ending
	}
  • 当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图。
⑤ 对象创建