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 的原因。它们在功能上很相似,但是在实现上有着显著的区别。

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: 方法来自定义绘制。

  • 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 可能需要更新 视图树,相应地,图层树 也会被更新

  • 其次,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 原生基本是类似的,因此性能也差不多。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容