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 要基于 UIView
和 CALayer
提供两个平行的层级关系呢?其原因在于要做 职责分离
,这样也能避免很多重复代码。在 iOS 和 Mac OS X 两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘的交互有着本质的区别,这就是为什么 iOS 有 UIKit
和 UIView
,对应 Mac OS X 有 AppKit
和 NSView
的原因。它们在功能上很相似,但是在实现上有着显著的区别。
CALayer
那么为什么 CALayer 可以呈现可视化内容呢?因为 CALayer 基本等同于一个 纹理。纹理是 GPU 进行图像渲染的重要依据,纹理本质上就是一张图片,因此 CALayer 也包含一个 contents
属性指向一块缓存区,称为 backing store
,可以存放位图(Bitmap)
。iOS 中将该缓存区保存的图片称为 寄宿图
在实际开发中,绘制界面有两种方式:一种是
手动绘制
;另一种是 使用图片
。对此,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];
我们可以看到,这样就可以使用图片绘制到view上面去
Custom Drawing
Custom Drawing 是指使用 Core Graphics 直接绘制寄宿图。实际开发中,一般通过继承 UIView 并实现 -drawRect: 方法来自定义绘制。
- 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
。
若UIView的子类重写了drawRect
,则UIView执行完drawRect
后,系统会为器layer的content
开辟一块缓存,用来存放drawRect绘制的内容。
即使重写的drawRect啥也没做,也会开辟缓存,消耗内存,所以尽量不要随便重写drawRect却啥也不做
。 其实,当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的
setNeedsLayout/setNeedsDisplay
方法后,在此过程中 app 可能需要更新视图树
,相应地,图层树
也会被更新其次,CPU计算要显示的内容,包括
布局计算(Layout)
、视图绘制(Display)
、图片解码(Prepare)
当runloop
在BeforeWaiting(即将进入休眠)
和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)
第一阶段,顶点着色器。该阶段的输入是 顶点数据(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(帧缓冲区、 双缓冲机制)
、视频控制器
等相关部件,将图像显示在屏幕上。
至此,原生的渲染流程到此结束。
原生渲染卡顿优化方案
所以解决卡顿现象的主要思路就是:尽可能减少 CPU
和 GPU
资源的消耗。
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 + JavaScript
。HTML + 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 层会有 Build
、Wiget Tree
、Element Tree
、RenderObject Tree
、Layout
、Paint
、Composited Layer
等几个阶段。将 Layer 进行组合,生成纹理,使用 OpenGL 的接口向 GPU 提交渲染内容进行光栅化与合成,是在 Flutter 的 C++ 层,使用的是 Skia 库。包括提交到 GPU 进程后,合成计算,显示屏幕的过程和 iOS 原生基本是类似的,因此性能也差不多。