iOS - 渲染原理

Head

在性能优化中,有一个重要的知识点就是卡顿优化,咱们以FPS(每秒传输帧数(Frames Per Second))来衡量它的流畅度,苹果的iPhone推荐的刷新率是60Hz,也就是说GPU每秒钟刷新屏幕60次,这每刷新一次就是一帧frame,每一帧大概在1/60 = 16.67ms画面最佳,静止不变的页面FPS值是0,这个值是没有参考意义的,只有当页面在执行动画或者滑动的时候,FPS值才具备参考价值,FPS值的大小体现了页面的流畅程度高低,当低于45的时候卡顿会比较明显前端

屏幕呈像原理

咱们所看到的动态的屏幕的成像其实和视频同样也是一帧一帧组成的。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其余硬件)会用硬件时钟产生一系列的定时信号。当电子枪换行进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器一般以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。 数组

屏幕呈像原理

卡顿的产生

接下来介绍完成显示信息的过程是:CPU 计算数据 -> GPU 进行渲染 -> 渲染结果存入帧缓冲区 -> 视频控制器会按照 VSync 信号逐帧读取帧缓冲区的数据 -> 成像,假如屏幕已经发出了 VSync 但 GPU 尚未渲染完成,则只能将上一次的数据显示出来,以至于当前计算的帧数据丢失,这样就产生了卡顿,当前的帧数据计算好后只能等待下一个周期去渲染。 缓存

总体流程

卡顿缘由

卡顿的优化

那么,解决卡顿的方案就非常要在下一次VSync到来以前,尽量减小这一帧 CPU 和 GPU 资源的消耗,要减小的话咱们就得先了解这二者在渲染中的具体分工是什么,和iOS中视图的产生过程性能优化

UIView 和 CALayer

咱们都知道,视图的职责是 建立并管理 图层,以确保当子视图在层级关系中 添加或被移除 时,其关联的图层在图层树中也有相同的操做,即保证视图树和图层树在结构上的一致性,那么为何 iOS 要基于 UIViewCALayer 提供两个平行的层级关系呢?其缘由在于要作 职责分离,这样也能避免不少重复代码。在 iOS 和 Mac OS X 两个平台上,事件和用户交互有不少地方的不一样,基于多点触控的用户界面和基于鼠标键盘的交互有着本质的区别,这就是为何 iOS 有 UIKitUIView,对应 Mac OS X 有 AppKitNSView 的缘由。它们在功能上很类似,可是在实现上有着显著的区别。bash

CALayer

那么为何 CALayer 能够呈现可视化内容呢?由于 CALayer 基本等同于一个 纹理。纹理是 GPU 进行图像渲染的重要依据,纹理本质上就是一张图片,所以 CALayer 也包含一个 contents 属性指向一块缓存区,称为 backing store,能够存放位图(Bitmap)。iOS 中将该缓存区保存的图片称为 寄宿图 架构

CALayer
在实际开发中,绘制界面有两种方式:一种是 手动绘制;另外一种是 使用图片。 对此,iOS 中也有两种相应的实现方式:

  • 使用图片:contents image
  • 手动绘制:custom drawing

Contents Image

Contents Image 是指经过 CALayer 的 contents 属性来配置图片。然而,contents 属性的类型为 id。在这种状况下,能够给 contents 属性赋予任何值,app 仍能够编译经过。可是在实践中,若是 content 的值不是 CGImage ,获得的图层将是空白的并发

// Contents Image
    UIImage *image = [UIImage imageNamed:@"cat.JPG"];
    UIView *v = [UIView new];
    v.layer.contents = (__bridge id _Nullable)(image.CGImage);
    v.frame = CGRectMake(100, 100, 100, 100);
    [self.view addSubview:v];
复制代码

Contents Image
咱们能够看到,这样就可使用图片绘制到view上面去

Custom Drawing

Custom Drawing 是指使用 Core Graphics 直接绘制寄宿图。实际开发中,通常经过继承 UIView 并实现 -drawRect: 方法来自定义绘制。app

  • UIView 有一个关联图层,即 CALayer
  • CALayer 有一个可选的 delegate 属性,实现了 CALayerDelegate 协议。UIView 做为 CALayer 的代理实现了CALayerDelegae 协议。
  • 当须要重绘时,即调用 -drawRect:,CALayer 请求其代理给予一个寄宿图来显示。
  • CALayer 首先会尝试调用 -displayLayer: 方法,此时代理能够直接设置 contents 属性。
- (void)displayLayer:(CALayer *)layer;
复制代码
  • 若是代理没有实现 -displayLayer: 方法,CALayer 则会尝试调用 -drawLayer:inContext:方法。在调用该方法前,CALayer 会建立一个空的寄宿图(尺寸由 bounds 和 contentScale 决定)和一个 Core Graphics 的绘制上下文,为绘制寄宿图作准备,做为 ctx 参数传入。
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
复制代码
  • 最后,由 Core Graphics 绘制生成的寄宿图会存入 backing store框架

    Custom Drawing
    若UIView的子类重写了drawRect,则UIView执行完drawRect后,系统会为器layer的content开辟一块缓存,用来存放drawRect绘制的内容。 即便重写的drawRect啥也没作,也会开辟缓存,消耗内存,因此尽可能不要随便重写drawRect却啥也不作

  • 其实,当在操做 UI 时,好比改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,在此过程当中 app 可能须要更新 视图树,相应地,图层树 也会被更新oop

  • 其次,CPU计算要显示的内容,包括布局计算(Layout)视图绘制(Display)图片解码(Prepare)runloopBeforeWaiting(即将进入休眠)Exit (即将退出Loop) 时,会通知注册的监听,而后对图层打包(Commit),打包完后,将打包的数据(backing store)发送给一个独立负责渲染的进程 Render Server

  • 数据到达Render Server 后会被反序列化,获得图层树,按照图层树中图层顺序、RBGA值、图层frame过滤图中被遮挡的部分,过滤后将图层树转成渲染树,渲染树的信息会转给 OpenGL ES/Metal

至此,前面CPU 所处理的这些事情统称为 Commit Transaction

Render Server

Render Server 会调用 GPU,GPU 开始进行顶点着色器形状装配几何着色器光栅化片断着色器测试与混合六个阶段。完成这六个阶段的工做后,再将 CPU 和 GPU 计算后的数据显示在屏幕的每一个像素点上

  • 顶点着色器(Vertex Shader)
  • 形状装配(Shape Assembly),又称 图元装配
  • 几何着色器(Geometry Shader)
  • 光栅化(Rasterization)
  • 片断着色器(Fragment Shader)
  • 测试与混合(Tests and Blending)

GPU
第一阶段,顶点着色器。该阶段的输入是 顶点数据(Vertex Data) 数据,好比以数组的形式传递 3 个 3D 坐标用来表示一个三角形。顶点数据是一系列顶点的集合。顶点着色器主要的目的是把 3D 坐标转为另外一种 3D 坐标,同时顶点着色器能够对顶点属性进行一些基本处理。

第二阶段,形状(图元)装配。该阶段将顶点着色器输出的全部顶点做为输入,并将全部的点装配成指定图元的形状。图中则是一个三角形。图元(Primitive) 用于表示如何渲染顶点数据,如:点、线、三角形。

第三阶段,几何着色器。该阶段把图元形式的一系列顶点的集合做为输入,它能够经过产生新顶点构造出新的(或是其它的)图元来生成其余形状。例子中,它生成了另外一个三角形。

第四阶段,光栅化。该阶段会把图元映射为最终屏幕上相应的像素,生成片断。片断(Fragment) 是渲染一个像素所须要的全部数据。

第五阶段,片断着色器。该阶段首先会对输入的片断进行 裁切(Clipping)。裁切会丢弃超出视图之外的全部像素,用来提高执行效率。

第六阶段,测试与混合。该阶段会检测片断的对应的深度值(z 坐标),判断这个像素位于其它物体的前面仍是后面,决定是否应该丢弃。此外,该阶段还会检查 alpha 值( alpha 值定义了一个物体的透明度),从而对物体进行混合。所以,即便在片断着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能彻底不一样。 公式为:

R = S + D * (1 - Sa)
复制代码

假设有两个像素 S(source) 和 D(destination),S 在 z 轴方向相对靠前(在上面),D 在 z 轴方向相对靠后(在下面),那么最终的颜色值就是 S(上面像素) 的颜色 + D(下面像素) 的颜色 * (1 - S(上面像素) 颜色的透明度)

因此,才须要咱们在作页面的时候,尽可能控制少的图层数、还有尽可能不要使用alpha

  • 最终,GPU经过Frame Buffer(帧缓冲区、 双缓冲机制)视频控制器等相关部件,将图像显示在屏幕上。

至此,原生的渲染流程到此结束。

原生渲染卡顿优化方案

因此解决卡顿现象的主要思路就是:尽量减小 CPUGPU 资源的消耗。 ######CPU

  • 尽可能用轻量级的对象 如:不用处理事件的 UI 控件能够考虑使用 CALayer;
  • 不要频繁地调用 UIView 的相关属性 如:frame、bounds、transform 等;
  • 尽可能提早计算好布局,在有须要的时候一次性调整对应属性,不要屡次修改;
  • Autolayout 会比直接设置 frame 消耗更多的 CPU 资源;
  • 图片的 size 和 UIImageView 的 size 保持一致;
  • 控制线程的最大并发数量;
  • 耗时操做放入子线程;如文本的尺寸计算、绘制,图片的解码、绘制等; ######GPU
  • 尽可能避免短期内大量图片显示;
  • GPU 能处理的最大纹理尺寸是 4096 * 4096,超过这个尺寸就会占用 CPU 资源,因此纹理不能超过这个尺寸;
  • 尽可能减小透视图的数量和层次;
  • 减小透明的视图(alpha < 1),不透明的就设置 opaque 为 YES;
  • 尽可能避免离屏渲染;

大前端渲染

大前端的开发框架主要分为两类:第一类是基于 WebView 的,第二类是相似 React Native 的。

对于第一类 WebView 的大前端渲染,主要工做在 WebKit 中完成。WebKit 的渲染层来自之前 macOS 的 Layer Rendering 架构,而 iOS 也是基于这一套架构。因此,从本质上来看,WebKit 和 iOS 原生渲染差异不大。

第二类的类 React Native 更简单,渲染直接走的是 iOS 原生的渲染。那么,咱们为何会感受 WebView 和类 React Native 比原生渲染得慢呢?

从第一次内容加载来看,即便是本地加载,大前端也要比原生多出脚本代码解析的工做。

WebView 须要额外解析 HTML + CSS + JavaScript 代码,而类 React Native 方案则须要解析 JSON + JavaScriptHTML + CSS 的复杂度要高于 JSON,因此解析起来会比 JSON 慢。也就是说,首次内容加载时,WebView会比类 React Native 慢。

从语言自己的解释执行性能来看,大前端加载后的界面更新会经过 JavaScript解释执行,而 JavaScript 解释执行性能要比原生差,特别是解释执行复杂逻辑或大量计算时。因此,大前端的运算速度,要比原生慢很多。

说完了大前端的渲染,你会发现,相对于原生渲染,不管是 WebView 仍是类 React Native 都会由于脚本语言自己的性能问题而在存在性能差距。那么,对于 Flutter 这种没有使用脚本语言,而且渲染引擎也是全新的框架,其渲染方式有什么不一样,性能又怎样呢?

Flutter 渲染

Flutter 界面是由 Widget 组成的,全部 Widget 组成 Widget Tree,界面更新时会更新 Widget Tree,而后再更新 Element Tree,最后更新 RenderObject Tree。

接下来的渲染流程,Flutter 渲染在 Framework 层会有 BuildWiget TreeElement TreeRenderObject TreeLayoutPaintComposited Layer 等几个阶段。将 Layer 进行组合,生成纹理,使用 OpenGL 的接口向 GPU 提交渲染内容进行光栅化与合成,是在 Flutter 的 C++ 层,使用的是 Skia 库。包括提交到 GPU 进程后,合成计算,显示屏幕的过程和 iOS 原生基本是相似的,所以性能也差很少。