离屏渲染


图像显示原理

enter image description here

图像显示的大概流程:

  1. 程序运行从内存中读取数据
    • 对图片进行解压得到像素数据,若GPU不支持图片的颜色格式,CPU需要进行格式转换
    • CoreText和CoreGraphics跟进文本内容生成位图
  2. 然后解压后的数据或位图通过GPU Bus上传到GPU,GPU需要将每一个frame的纹理(位图)合成在一起(一秒60次)。每一个纹理会占用VRAM(video RAM),所以需要给 GPU 同时保持纹理的数量做一个限制。GPU 在合成方面非常高效,但是某些合成任务却比其他更复杂,并且 GPU在 16.7ms(1/60s)内能做的工作也是有限的。
CPU的工作
  • 对象的创建、调整和销毁
  • 布局计算
  • 文本计算
  • 文本渲染
  • 图片的解码
  • 图像的绘制
GPU的工作
  • 纹理的渲染
  • 视图合成
  • 图形生成

GPU显示图像

enter image description here
  1. CPU 计算好显示内容提交到 GPU
  2. GPU 渲染完成后将渲染结果放入帧缓冲区
  3. 随后视频控制器会按照VSync信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。

    在最简单的情况下,帧缓冲区只有一个,这时帧缓冲区的读取和刷新都都会有比较大的效率问题。为了解决效率问题,显示系统通常会引入两个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。

垂直同步机制
enter image description here

    当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象。
    为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync)
    首先从过去的CRT显示器原理说起。CRT 的电子枪按照上面方式,从上到下一行行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。尽管现在的设备大都是液晶显示屏了,但原理仍然没有变。
    当开启垂直同步后,GPU 会等待显示器的VSync信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

卡顿的产生

enter image description here

    在VSync信号到来后,系统图形服务会通过CADisplayLink等机制通知App,App主线程开始在CPU中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。
    从上面的图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。

离屏渲染

GPU的渲染方式

  • On-Screen Rendering(当前屏幕渲染):指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行。


    enter image description here
  • Off-Screen Rendering (离屏渲染),指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。


    enter image description here

        OffScreen Rendering 则多了一个步骤,GPU 会先创建一个屏外缓冲区(OffScreenBuffer),然后在其中进行渲染,最后将渲染结果提交到帧缓冲区内(FrameBuffer);这其中还涉及到了两次上下文的转换,首先把当前上下文转换到屏外缓冲区(OffScreenBuffer),然后又转换到帧缓冲区(FrameBuffer)。整个过程会造成很大的消耗。例如蒙板操作:
        在前两个渲染通道中,GPU分别得到了纹理(texture,也就是那个相机图标)和layer(蓝色的蒙版)的渲染结果。但这两个渲染结果没有直接放入Render Buffer中,也就表示这是离屏渲染。直到第三个渲染通道,才把两者组合起来放入Render Buffer中。离屏渲染意味着把渲染结果临时保存,等用到时再取出,因此相对于普通渲染更占用资源。

CPU 渲染
    如果我们重写了drawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。由CPU处理的一种特殊渲染方式,在App内同步完成,渲染得到的bitmap最后再交由GPU用于显示,由于CPU自身做渲染的性能也不好,所以这种方式也是需要尽量避免的。

enter image description here

为何需要离屏渲染

    一些复杂的效果,如:圆角,阴影,遮罩,图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制,无法直接渲染出结果,所以就需要屏幕外渲染被唤起。

    屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。

    所以当使用离屏渲染的时候会很容易造成性能消耗,因为在OPENGL里离屏渲染会单独在内存中创建一个屏幕外缓冲区并进行渲染,而屏幕外缓冲区跟当前屏幕缓冲区上下文切换是很耗性能的。

触发离屏渲染的操作
  • shouldRasterize(光栅化)
  • masks(遮罩)
  • shadows(阴影)
  • edge antialiasing(抗锯齿)
  • group opacity(不透明)
  • 复杂形状设置圆角等
光栅化:

概念:将图转化为一个个栅格组成的图象。
特点:每个元素对应帧缓冲区中的一像素。
    shouldRasterize = YES在其他属性触发离屏渲染的同时,会将光栅化后的内容缓存起来,如果对应的layer及其sublayers没有发生改变,在下一帧的时候可以直接复用。shouldRasterize = YES,这将隐式的创建一个位图,各种阴影遮罩等效果也会保存到位图中并缓存起来,从而减少渲染的频度(不是矢量图)。相当于光栅化是把GPU的操作转到CPU上了,生成位图缓存,直接读取复用。
    “Color Hits Green and Misses Red”可以检查当前场景下光栅化操作,绿色表示缓存被复用,红色表示缓存在被重复创建。
    如果光栅化的层变红得太频繁那么光栅化对优化可能没有多少用处。位图缓存从内存中删除又重新创建得太过频繁,红色表明缓存重建得太迟。可以针对性的选择某个较小而较深的层结构进行光栅化,来尝试减少渲染时间。

注意:
对于经常变动的内容,不要开启光栅化,则会造成大量的离屏渲染,降低图形性能。

圆角的设置

  1. 使用cornerRadius

    self.layer.cornerRadius = cornerRadius;    self.layer.masksToBounds = YES;    //防止子view边界超过父view
    //self.clipsToBounds = YES;
    self.layer.shouldRasterize = YES;       //光栅化
    
    • UIView的clipsToBounds与CALayer的maskToBounds的作用一致,防止子view边界超过父view
    • cornerRadius默认情况下只对背景色和border起作用
    • 如果最后设置了 shouldRasterize 为 YES,那也要记住设置 rasterizationScale 为 contentsScale
  1. 使用贝塞尔曲线 + maskLayer

    - (void)setRoundRect:(CGRect)frame cornerRadius:(CGFloat)cornerRadius {
        CGRect maskFrame = CGRectMake(0, 0, CGRectGetWidth(frame), CGRectGetHeight(frame));
        UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:maskFrame  cornerRadius:cornerRadius];
        CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
        maskLayer.frame = maskFrame;
        maskLayer.path = path.CGPath;
        self.layer.mask = maskLayer;
    }
    

    UIBezierPath对CoreGraphics进行了一层封装

  2. 使用CoreGraphics绘制圆角图片做背景(CPU渲染)

    - (void)drawRoundCornerWithCornerRadius:(CGFloat)cornerRadius {
    
        CGFloat width = CGRectGetWidth(self.frame);
        CGFloat height = CGRectGetHeight(self.frame);
    
        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
        [self addSubview:imageView];
        dispatch_async(dispatch_queue_create("backgroundQueue", DISPATCH_QUEUE_CONCURRENT), ^{
        UIGraphicsBeginImageContextWithOptions(self.frame.size, NO, [UIScreen mainScreen].scale);
            CGContextRef context = UIGraphicsGetCurrentContext();
    
            CGContextMoveToPoint(context, 0, 0);
        
            CGContextAddArcToPoint(context, width, 0, width, height, cornerRadius);
            CGContextAddArcToPoint(context, width, height, 0, height, cornerRadius);
            CGContextAddArcToPoint(context, 0, height, 0, 0, cornerRadius);
            CGContextAddArcToPoint(context, 0, 0, width, 0, cornerRadius);
        
            CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
            CGContextDrawPath(context, kCGPathStroke);
        
            UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
            UIGraphicsEndImageContext();
            dispatch_async(dispatch_get_main_queue(), ^{
            imageView.image = image;
            });
        });
    }
    

    CoreGraphic通常是线程安全的,所以可以进行异步绘制,显示的时候再放回主线程

  3. 将图片剪切为圆角(针对图片)

    - (UIImage *)setRoundCornerRadius:(CGFloat)cornerRadius {
        UIImage *image = nil;
        CGRect imageFrame = CGRectMake(0, 0, self.size.width, self.size.height);
        UIGraphicsBeginImageContextWithOptions(self.size, NO, [UIScreen mainScreen].scale);
        UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:imageFrame cornerRadius:cornerRadius];
        [path addClip];
        [self drawInRect:imageFrame];
        image = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        return image;
    }
    
  4. 使用圆角图片作为蒙板

如何避免离屏渲染

  1. 圆角视图较少,使用使用cornerRadius
  2. UIImageView 的圆角通过直接截取图片实现,其它视图的圆角可以通过 Core Graphics 画出圆角矩形实现。
  3. 对于图形采用异步绘制
  4. 直接使用圆角素材作为背景

参考资料

iOS离屏渲染优化
绘制像素到屏幕上
关于性能的一些问题(iOS)
解决常见的masksToBounds离屏渲染带来的性能损耗
iOS 离屏渲染的研究
小心别让圆角成了你列表的帧数杀手
iOS 高效添加圆角效果实战讲解
UIKit性能调优实战讲解
iOS 保持界面流畅的技巧
iOS开发:关于图形渲染以及界面优化的的一些想法
iOS图形渲染分析

推荐阅读更多精彩内容