1. 渲染框架

iOS下的渲染框架:
image.png

对于一个App来说,它会使用CoreGraphicsCoreAnimationCoreImage进行绘制可视化内容。而这些框架都需要通过OpenGL ES / Metal来调用GPU进行绘制,才能最终将内容显示到屏幕上

iOS下的UIKit框架,就是继承自CoreGraphicsCoreAnimation框架,方便开发者的应用。我们可以设置UIKit的布局和属性,对界面进行绘制。但是显示和动画,就会用到CoreAnimation框架

CoreAnimation框架也被称为核心动画编程框架,依赖于OpenGL ES / Metal进行GPU的渲染,例如抖音上的一些酷炫特性,都是基于OpenGL ES / Metal开发出来的

CoreGraphics是一个高级的绘图引擎,在CPU上执行。它的作用是在运行时绘制图像,开发者可以使用这个框架完成绘制路径、阴影、图像创建,图像遮罩等功能

CoreImage框架用来处理运行前创建的图像,例如工程里的图片。该框架拥有一系列的线程的图片处理器,能够对已经存在的图片进行高效的处理。既能在CPU上执行,也能在GPU上执行

2. 渲染流程

图像/图像的渲染流程:
image.png

首先,在Application阶段是由CPU进行处理,CPU会负责创建视图、计算视图的Frame、图片解码、绘制纹理等操作,完成后交给GPU处理

GPU在第一个阶段,通过顶点着色器确定图形在硬件上的显示位置,通过片元着色器计算每一个像素点的颜色值

之后在第二个阶段进行光栅化,找到图形像素点的范围,然后把像素点的颜色显示上去

显示后来到第三阶段,将数据放入帧缓冲区,由显示系统将帧缓冲区里的数据显示到屏幕上

整体流程如下:
image.png

  • 经过CPUGPU把数据处理好,将其放入FrameBuffer帧缓冲区中,由视频控制度读取帧缓冲区里的数据,通过显示器显示出来

3. 屏幕扫描

视频控制度读取帧缓冲区里的数据,就是通过屏幕扫描的方式完成的。如图:
image.png

在进行图像显示的时候,CRK电子枪会按照图中的方式,从上到下进行逐行的扫描,扫描的过程就是在读取帧缓冲里面的数据。当扫描完成后,显示器就会显示出一帧的画面。随后电子枪又会回到初始位置,准备下一帧的扫描

当电子枪扫描完第一行,换到下一行的时候,显示器就会发出一个水平同步信号(HSync)。当一帧画面全部显示完成后,电子枪回到初始位置准备下一帧的扫描之前,显示器又会发出一个垂直同步信号(VSync

显示器通常都是固定频率来进行刷新的,这个频率就是垂直同步信号产生的频率。在苹果手机上,刷新频率为每秒60次(60fps),所以在进行屏幕卡顿优化时,我们通常会以fps作为衡量指标,它的值越接近60,说明屏幕的流畅度越高

4. 掉帧

屏幕卡顿的根本原因:掉帧
image.png

当收到一个垂直同步信号时,如果CPUGPU没有完成内容的提交,也就是没有把渲染结果放入帧缓冲区,此时当前这一帧的画面就会被丢弃

显示器会等收到下一次垂直同步信号时,再进行显示。但这时屏幕上显示的还是上一帧的画面,这个过程就是我们所说的掉帧

当出现掉帧的情况,fps就会出现相对的减少,这也是屏幕卡顿的一个根本原因

5. 离屏渲染的定义

如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的FrameBuffer(帧缓冲区),作为像素数据存储区域,然后由显示控制器把帧缓冲区的数据显示到屏幕上。如果有时因为面临一些限制,一些原因,例如:阴影、遮罩Mask等,GPU无法把渲染结果直接写入FrameBuffer,而是先暂时把中间的一个临时状态存储在另外的内存区域,之后再写入FrameBuffer,那么这个过程被称之为离屏渲染

详细说明:

正常情况,GPU都是直接将数据写入到帧缓冲区

但有时因为面临一些限制,GPU无法一次性的将最终显示效果完成,而是需要将中间状态存储到另外开辟的一块内存中

在内存中会对中间状态进行合成,直到合成为最终显示效果后才会存储到帧缓冲区

此时相比正常情况,多出开辟内存空间存储中间状态的步骤,这个过程我们称之为离屏渲染

6. 图层合并

在日常开发中,当需要做图层合并时,就会触发离屏渲染

开发中我们使用的UIView,它是基于CALayer的一个封装。UIView中每一个属性,都会对应CALayer中的某个属性。CALayer负责内容显示,而UIView负责事件响应。我们在设备上看到的内容,其实都是由CALayer呈现的

CALayer分为以下三个部分:
image.png

  • backgroundColor
  • contents
  • borderWidth / borderColor

GPU进行渲染时,它会遵循画家算法:
image.png

例如:当屏幕上要显示最后一张图片的内容时,GPU作为一个画家,它会先画完一座山。但它不能像我们一样,在画完山之后,翻过头来再去画草和树。它只能开辟一块内存空间,将画好山的画布存储到内存中。之后创建新的画布,再去画草。当画完草之后,再创建画布开始画树。当山、草、数都画完,会在内存空间中进行合并,形成最终的图片内容。等合成后的图片内容需要显示时,GPU才会将其存储在帧缓冲区

上述过程,就是所谓的离屏渲染。这种复杂操作,很有可能因此出现掉帧,导致屏幕卡顿

7. 代码中的离屏渲染

检测离屏渲染的方式:

  • 模拟器:DebugColor Off-screen Renderd,当离屏渲染的图层高亮为黄色,可能存在性能问题

  • 真机:DebugView DebuggingRenderingColor Offscreen-Renderd Yellow

7.1 光栅化

  1. - (void)lgShouldRasterize {
  2. self.lgImageView.layer.shouldRasterize = YES;
  3. }
  • 光栅化会触发离屏渲染

光栅化的作用:将图片保存成bitmap位图形式,进行缓存。再次显示这张图片时,CPU会直接从缓存中读取位图,然后传递给GPU。此时GPU不会再次渲染这部分的图层,减少GPU的计算量,从而提高性能

但是,光栅化的运用场景很少。因为光栅化要求是一张静态图片,而且它会触发离屏渲染,它的缓存只能保存100毫秒,慎用

7.2 遮罩Mask

  1. - (void)lgMask {
  2. CALayer *layer = [CALayer layer];
  3. layer.frame = CGRectMake(30, 30, self.lgImageView.bounds.size.width, self.lgImageView.bounds.size.height);
  4. layer.backgroundColor = [UIColor redColor].CGColor;
  5. self.lgImageView.layer.mask = layer;
  6. }
  • 遮罩Mask会触发离屏渲染

遮罩Mask属于CALayer类型,是layer的一个属性。它是一个遮罩层,覆盖在视图layer的上层,默认是不存在的

在屏幕上的每一个像素点,它都是由当前像素点上多层layer通过GPU混合颜色而来的。当我们设置了遮罩layer,就需要进行图层合并。因为它会增加GPU的计算复杂度,GPU无法一次性渲染到位,这时会通过离屏渲染进行处理

7.3 阴影

  1. - (void)lgShadows {
  2. self.lgImageView.layer.shadowColor = [UIColor redColor].CGColor;
  3. self.lgImageView.layer.shadowOffset = CGSizeMake(20, 20);
  4. self.lgImageView.layer.shadowOpacity = 0.2;
  5. self.lgImageView.layer.shadowRadius = 5;
  6. self.lgImageView.layer.masksToBounds = NO;
  7. }
  • 阴影会触发离屏渲染

设置阴影后,它会处于视图layer的下层。GPU先进行阴影的绘制,然后开辟内存空间存储阴影。再进行本体的绘制,最后进行阴影和本体的合成,从而达到最终的显示效果

阴影的优化:

  1. - (void)lgShadows2 {
  2. self.lgImageView.layer.shadowColor = [UIColor redColor].CGColor;
  3. self.lgImageView.layer.shadowOpacity = 0.2;
  4. self.lgImageView.layer.shadowRadius = 5;
  5. self.lgImageView.layer.masksToBounds = NO;
  6. [self.lgImageView.layer setShadowPath:[UIBezierPath bezierPathWithRect:CGRectMake(0, 0, self.lgImageView.bounds.size.width + 20, self.lgImageView.bounds.size.height + 20)].CGPath];
  7. }
  • 使用ShadowPath指定layer阴影效果路径,不会触发离屏渲染

通过ShadowPath属性,可以预先告知CoreAnimation阴影的几何形状。它会根据指定路径构建图形阴影,使得阴影可以独立渲染,不需要依赖于layer的本体,所以不会触发离屏渲染

7.4 抗锯齿

  1. - (void) lgEdgeAnntialiasing {
  2. CGFloat angle = M_PI / 60.0;
  3. [self.lgImageView.layer setTransform:CATransform3DRotate(self.lgImageView.layer.transform, angle, 0.0, 0.0, 1.0)];
  4. self.lgImageView.layer.allowsEdgeAntialiasing = YES;
  5. }

图片开启抗锯齿,不一定会触发离屏渲染,这个和图片的填充模式(contentMode)有关:

  • ScaleToFill:缩放图片,使图片充满容器,会导致图片变形,但不会触发离屏渲染

  • ScaleAspectFit / ScaleAspectFill:二者都会保证图片比例不变。Fit使图片全部显示在容器中,比例不符留白。而Fill会填充整个容器,可能只有部分图片能显示出来。但它们都会触发离屏渲染

在开发中,如果能保证图片和控件大小成比例,将填充模式设置为ScaleToFill,在性能方面有一定程度的帮助

7.5 不透明

  1. - (void)lgAllowsGroupOpacity {
  2. UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 20, 20)];
  3. view.backgroundColor = [UIColor greenColor];
  4. [self.lgImageView addSubview:view];
  5. self.lgImageView.alpha = 0.8;
  6. self.lgImageView.layer.allowsGroupOpacity = YES;
  7. }
  • allowsGroupOpacity:允许组不透明,默认为Yes

设置allowsGroupOpacity属性,不一定会触发离屏渲染:

  • 如果allowsGroupOpacity属性为NO,此时设置父视图透明度,子视图也会跟随透明,这种情况不会触发离屏渲染

  • 如果allowsGroupOpacity属性为Yes,但不设置视图透明度(alpha = 1.0),也不会触发离屏渲染

  • 如果allowsGroupOpacity属性为Yes,此时设置父视图透明度,子视图不会跟随透明,这种情况会触发离屏渲染

  • 如果父视图中没有子视图,即使允许组不透明被启用,并设置父视图透明度,也不会触发离屏渲染

当允许组不透明被启用,并设置父视图透明度时,GPU除了渲染子视图之外,还要渲染子视图下面的父视图部分,然后将二者进行合成,所以会触发离屏渲染

7.6 圆角

7.6.1 UIImageView

仅设置圆角:

  1. - (void)lgRadius {
  2. self.lgImageView.layer.cornerRadius = 40;
  3. }
  • 仅设置圆角,不会触发离屏渲染

layer包含backgroundColorcontentsborder三个部分。当仅设置圆角时,设置的是contents层的圆角。系统在iOS9之后进行了优化,不会触发离屏渲染

设置圆角和边框:

  1. - (void)lgRadius {
  2. self.lgImageView.layer.borderWidth = 5;
  3. self.lgImageView.layer.cornerRadius = 40;
  4. }
  • 设置圆角和边框,会触发离屏渲染

同时设置圆角和边框,圆角是对contents层进行设置,而边框是对border层进行设置,最终要进行图层合并,所以会触发离屏渲染

设置圆角和背景色:

  1. - (void)lgRadius {
  2. // self.lgImageView.backgroundColor = [UIColor redColor];
  3. self.lgImageView.layer.backgroundColor = [UIColor greenColor].CGColor;
  4. self.lgImageView.layer.cornerRadius = 40;
  5. }
  • 设置圆角和背景色,会触发离屏渲染

同时设置圆角和背景色,圆角是对contents层进行设置,而背景色是对backgroundColor层进行设置,最终要进行图层合并,所以会触发离屏渲染

UIImageView中,使用backgroundColorlayer.backgroundColor,都是对backgroundColor层设置背景色

7.6.2 UILabel

设置圆角,同时使用backgroundColor设置背景色:

  1. - (void)lgRadius {
  2. self.lgLabel.backgroundColor = [UIColor redColor];
  3. self.lgLabel.layer.cornerRadius = 10;
  4. }
  • 设置圆角,同时使用backgroundColor设置背景色,不会触发离屏渲染

设置圆角,同时使用layer.backgroundColor设置背景色:

  1. - (void)lgRadius {
  2. self.lgLabel.layer.backgroundColor = [UIColor greenColor].CGColor;
  3. self.lgLabel.layer.cornerRadius = 10;
  4. }
  • 设置圆角,同时使用layer.backgroundColor设置背景色,会触发离屏渲染

这里就体现了UILabel控件的特殊性:

  • UILabel的圆角,同样是对contents层进行设置

  • 使用backgroundColor设置背景色,也是对contents层进行设置

  • 但使用layer.backgroundColor,是对backgroundColor层进行设置

  • 因此后者要进行图层合并,所以会触发离屏渲染

UIImageViewUILabel设置背景色的区别:

  1. - (void)lgRadius {
  2. self.lgImageView.backgroundColor = [UIColor redColor];
  3. self.lgImageView.layer.backgroundColor = [UIColor greenColor].CGColor;
  4. self.lgImageView.layer.cornerRadius = 40;
  5. self.lgLabel.backgroundColor = [UIColor redColor];
  6. self.lgLabel.layer.backgroundColor = [UIColor greenColor].CGColor;
  7. self.lgLabel.layer.cornerRadius = 10;
  8. }
  • 运行结果,UIImageView为绿色,UILabel为红色

因为UIImageView都是对backgroundColor层进行设置,绿色是最后设置的,所以显示为绿色

UILabel先对contents层设置为红色,然后对backgroundColor层设置为绿色,由于contents层在backgroundColor层之上,所以显示为红色

7.6.3 贝塞尔曲线

通过贝塞尔曲线画圆:

  1. - (void)lgBezier {
  2. //开始上下文
  3. UIGraphicsBeginImageContextWithOptions(self.lgImageView.bounds.size, NO, 0.0);
  4. [[UIBezierPath bezierPathWithRoundedRect:self.lgImageView.bounds cornerRadius:100] addClip];
  5. [self.lgImageView drawRect:self.lgImageView.bounds];
  6. //当前上下文
  7. self.lgImageView.image = UIGraphicsGetImageFromCurrentImageContext();
  8. // 结束上下文
  9. UIGraphicsEndImageContext();
  10. }
  • 通过贝塞尔曲线画圆,不会触发离屏渲染

使用贝塞尔曲线也是消耗性能的,它虽然不会触发离屏渲染,但使用过程中会进行上下文切换,这样的操作同样是耗时的

7.6.4 优化方案

圆角需求的优化方案,如果列表中头像区域周围背景没有图案,可以采用一张静态图片。中间圆形区域透明,四周预留和背景颜色一致的遮罩
image.png

  • 例如:列表的背景是白色,图片四周同样留白,中间透明,然后将其覆盖在头像图片至上

7.7 特殊的离屏渲染

根据离屏渲染的定义,如果将GPU没有把渲染结果直接存储到帧缓冲区的渲染方式都称为离屏渲染,那么还有另一种特殊的离屏渲染方式

  1. -(void)drawRect:(CGRect)rect {
  2. ...
  3. }

如果我们重写drawRect方法,系统就会对当前View生成一块内存区域,用来等待绘制。这种情况下会开辟一块CGContext画布,渲染数据会临时存储在CGContext画布中,也没有直接存储到帧缓冲区

使用任何Core Graphics技术进行绘制操作,例如图片解码,都会涉及到CPU渲染。所有CPU渲染都无法直接绘制到GPU的帧缓冲区中,需要用到中转的内存区域,通过CPU渲染得到的bitmap最后再交由GPU用于显示

但根据苹果官方的说法,重写drawRect方法并不是真正意义上的离屏渲染,这种方式使用Xcode也检测不到离屏渲染。但根据离屏渲染的定义,它属于一种特殊的离屏渲染

8. 离屏渲染的影响与优化

影响:

  • 离屏渲染会开辟内存用来存储中间状态,它会增加内存的消耗

  • GPU需要在离屏和当前屏进行反复的上下文切换,这种耗时操作可能会引起掉帧,导致屏幕卡顿

优化:

  • 阴影使用ShadowPath指定layer阴影效果路径

  • 圆角使用贝塞尔曲线。如果可以,使用图片遮罩的方案性能最佳

总结

渲染框架:

  • App使用CoreGraphicsCoreAnimationCoreImage进行绘制可视化内容

  • CoreGraphics是一个高级的绘图引擎,在CPU上执行。它的作用是在运行时绘制图像,开发者可以使用这个框架完成绘制路径、阴影、图像创建,图像遮罩等功能

  • CoreAnimation框架也被称为核心动画编程框架,依赖于OpenGL ES / Metal进行GPU的渲染

  • CoreImage框架用来处理运行前创建的图像,例如工程里的图片。该框架拥有一系列的线程的图片处理器,能够对已经存在的图片进行高效的处理。既能在CPU上执行,也能在GPU上执行

渲染流程:

  • Application阶段是由CPU进行处理,CPU会负责创建视图、计算视图的Frame、图片解码、绘制纹理等操作,完成后交给GPU处理

  • GPU在第一个阶段,通过顶点着色器确定图形在硬件上的显示位置,通过片元着色器计算每一个像素点的颜色值

  • 之后在第二个阶段进行光栅化,找到图形像素点的范围,然后把像素点的颜色显示上去

  • 显示后来到第三阶段,将数据放入帧缓冲区,由显示系统将帧缓冲区里的数据显示到屏幕上

屏幕扫描:

  • 在进行图像显示的时候,CRK电子枪会按照图中的方式,从上到下进行逐行的扫描

    • 当电子枪扫描完第一行,换到下一行的时候,显示器就会发出一个水平同步信号(HSync

    • 当一帧画面全部显示完成后,电子枪回到初始位置准备下一帧的扫描之前,显示器又会发出一个垂直同步信号(VSync

  • 显示器通常都是固定频率来进行刷新的,这个频率就是垂直同步信号产生的频率

  • 在苹果手机上,刷新频率为每秒60次(60fps),所以在进行屏幕卡顿优化时,我们通常会以fps作为衡量指标,它的值越接近60,说明屏幕的流畅度越高

掉帧:

  • 当收到一个垂直同步信号时,如果CPUGPU没有完成内容的提交,也就是没有把渲染结果放入帧缓冲区,此时当前这一帧的画面就会被丢弃

  • 显示器会等收到下一次垂直同步信号时,再进行显示。但这时屏幕上显示的还是上一帧的画面,这个过程就是我们所说的掉帧

  • 当出现掉帧的情况,fps就会出现相对的减少,这也是屏幕卡顿的一个根本原因

离屏渲染的定义:

  • 正常情况,GPU都是直接将数据写入到帧缓冲区

  • 但有时因为面临一些限制,GPU无法一次性的将最终显示效果完成,而是需要将中间状态存储到另外开辟的一块内存中

  • 在内存中会对中间状态进行合成,直到合成为最终显示效果后才会存储到帧缓冲区

  • 此时相比正常情况,多出开辟内存空间存储中间状态的步骤,这个过程我们称之为离屏渲染

图层合并:

  • 开发中我们使用的UIView,它是基于CALayer的一个封装。UIView中每一个属性,都会对应CALayer中的某个属性

    • CALayer负责内容显示

    • UIView负责事件响应

  • CALayer分为以下三个部分:

    • backgroundColor

    • contents

    • borderWidth / borderColor

  • GPU进行渲染时,它会遵循画家算法。将临时状态存储在开辟的内存区域,在内存空间中进行合并,形成最终的图片内容。等合成后的图片内容需要显示时,GPU才会将其存储在帧缓冲区

代码中的离屏渲染:

  • 光栅化

    • 将图片保存成bitmap位图形式,进行缓存

    • 缺点:会触发离屏渲染,它的缓存只能保存100毫秒

  • 遮罩Mask

    • 遮罩Mask会触发离屏渲染

    • 遮罩Mask属于CALayer类型,是layer的一个属性。它是一个遮罩层,覆盖在视图layer的上层,默认是不存在的

  • 阴影

    • 直接设置shadow等属性,会触发离屏渲染

    • 使用ShadowPath指定layer阴影效果路径,不会触发离屏渲染

  • 抗锯齿

    • 和图片的填充模式(contentMode)有关:

      • ScaleToFill:缩放图片,使图片充满容器,会导致图片变形,但不会触发离屏渲染

      • ScaleAspectFit / ScaleAspectFill:二者都会保证图片比例不变,但它们都会触发离屏渲染

  • 不透明

    • 如果allowsGroupOpacity属性为Yes,此时设置父视图透明度,子视图不会跟随透明,这种情况会触发离屏渲染
  • 圆角

    • 仅设置圆角,只对contents层进行设置,不会触发离屏渲染

    • 设置圆角的同时,设置边框或背景色,会触发离屏渲染

    • 设置圆角的同时,使用backgroundColor设置背景色:

      • UIImageView中,是对backgroundColor层设置,会触发离屏渲染

      • UILabel中,是对contents层进行设置,不会触发离屏渲染

  • 贝塞尔曲线

    • 通过贝塞尔曲线画圆,不会触发离屏渲染

    • 但它同样消耗性能,使用过程中会进行上下文切换,这样的操作同样是耗时的

  • 圆角的优化方案

    • 采用一张静态图片作为遮罩
  • 特殊的离屏渲染

    • 重写drawRect方法,会开辟一块CGContext画布,渲染数据会临时存储在CGContext画布中,也没有直接存储到帧缓冲区

    • 根据苹果官方的说法,重写drawRect方法并不是真正意义上的离屏渲染,这种方式使用Xcode也检测不到离屏渲染。但根据离屏渲染的定义,它属于一种特殊的离屏渲染

离屏渲染的影响与优化:

  • 影响:

    • 离屏渲染会开辟内存用来存储中间状态,它会增加内存的消耗

    • GPU需要在离屏和当前屏进行反复的上下文切换,这种耗时操作可能会引起掉帧,导致屏幕卡顿

  • 优化:

    • 阴影使用ShadowPath指定layer阴影效果路径

    • 圆角使用贝塞尔曲线。如果可以,使用图片遮罩的方案性能最佳