iOS UI 显示的原理及优化策略

iOS 提供了非常丰富且性能优越的 UI 工具栈,加上自己底层已经做了足够好的优化,直接使用 UIKit 库的基本 APICoreAnimation 库的基本 API 已经可以绝满足大部分的工作需要了。

但是有时我们仍然会遇到一些显示上的性能瓶颈问题,这时怎么去优化呢?我觉得我们应该从基础入手,先搞懂 iOS 中 UI 图像是怎样显示到屏幕上的,经过了哪些 UI 栈,他们各自有什么特点又存在哪些缺点,从而找到优化的方向,再结合一系列优化工具与方法,尝试将问题解决。

在这一章的学习中,我们主要了解下 UI 显示的这些基础。看看从显示的基础中是否可以总结出可以优化的点,最后我们对这些可优化的点做些实验与总结。

一个像素是如何显示在屏幕上的

每一个像素均由三个颜色组件构成:红,绿,蓝。三个独立的颜色单元会根据给定的颜色来显示到一个像素点之上。我们可以这样计算一下,比如我写这篇文章时最新的 iPhoneX 的分辨率是 (24361125*), 那么其显示器就需要有 2436*1125*3=8,221,500,8 百多万个颜色单元。

那我们是怎样控制这些颜色单元显示些啥呢?通常我们使用一个字节来表示一个颜色单位色值,也就是说,一个颜色单位显示的亮度用一个字节来控制 (也就是他的取值范围在 0~255)。这样,我们就理解我们常用的颜色值代码什么的,比如颜色值 0xFFFFFF 代表白色,因为当红,绿,蓝三个单元一齐到色值最大值时,合成的就是白色。再比如颜色值 0x000000 代表黑色,也就是三个单元都不显示时就一片漆黑了。

我们可以代码中得到证实。我们先使用 UIImage 从磁盘上读取一张图片,将之解压成 data 数据,然后观察之:

UIImage *tempImage = [UIImage imageNamed:@"earth"];
CFDataRef imageData = CGDataProviderCopyData(CGImageGetDataProvider(tempImage.CGImage));

输出上面的 imageData,可以得到如下日志:

" <0c71a5ff 0c70a5ff 0d71a5ff 0c71a5ff 0c71a5ff 0c71a5ff 0c71a5ff 0c71a5ff 0c71a5ff 0c71a5ff。。。>"

我们发现,这是一个由 4 字节 16 进制数组成的一个数组。其中,每个数据如 0c71a5ff 就代表一个像素的显示,我们再细分之,其中每 8 位也就是一个字节代表了一个颜色组件的当前值。如下:

A R G B A R G B A R G B
pixel 0 pixel 1 pixel 2
0 1 2 3 0 1 2 3 0 1 2 3

这就是我们熟知的 ARGB 值了,其中新增出来的 A 代表 Alpha 也就是当前像素的透明度,在做像素合成的时候将会用上。

2 UI 显示过程中的硬件栈

上面我们说过,显示器显示是靠那么多个颜色单元,而颜色单元的显示由传给显示器的色值数组所控制。那么这个色值数组是怎样传输给显示器的呢?

我们来看看 iPhone 中像素显示的硬件栈 (图片出自 Objective-C 中国):

image.png

上图中除了我们之前介绍过的 Display 显示器之外,又多了两个重量级角色 – GPU & CPU。

其中,我们可以直接使用程序操作的主要是 CPU,如第一节中我们举的加载图片的例子。CPU 从磁盘上加载图片,将之读入内存 (当然,这一步说起来轻松但其实并不简单,因为我们读取的图片通常不是简单的使用 ARGB Bitmap 表示的,他们往往是如 PNG 或 JPEG 这样的经过压缩处理的图片。这时 CPU 需要先将之解压)。

2.1 图像文件与纹理

图像文件格式与纹理格式的定义与区别:

常用的 图像文件格式 有 BMP,TGA,JPG,GIF,PNG 等;
常用的纹理格式有 R5G6B5,A4R4G4B4,A1R5G5B5,R8G8B8, A8R8G8B8 等。

文件格式 是图像为了存储信息而使用的对信息的特殊编码方式,它存储在磁盘中,或者内存中,但是并不能被 GPU 所识别,因为以向量计算见长的 GPU 对于这些 复杂的计算无能为力。这些文件格式当被游戏读入后,还是需要经过 CPU 解压成 R5G6B5,A4R4G4B4,A1R5G5B5,R8G8B8, A8R8G8B8 等像素格式,再传送到 GPU 端进行使用。

纹理格式 是能被 GPU 所识别的像素格式,能被快速寻址并采样。

然后 CPU 会通过 GPU 总线 将要显示的数组传给 GPU。这里要注意的是,我们屏幕上往往并不只有一张图片,需要有时看起来好像是当前只在浏览一张图片一样,但其实每个盖在下面的 View 或 layer 也是一张图片 (我们在 CALyaer 章节中介绍过了)。而 CPU 交给 GPU 要处理的,就是这样由一张张图片组成的 图层树。GPU 将这一张张图片称之为 纹理 (texture),每个纹理在物理上指的是 GPU 显存中一段连续的空间 (也就是存储在上面所画的 VRAM 中),存储的大概就是我们之前所说的 ARGB 数组 (当然其具体结构为了适应 GPU 并行计算的特点会设计得更复杂些,但这里我们可以简单地将之认为是一段段 ARGB 数据源)。

之后 GPU 就要做他最牛逼也是最擅长的事了 – GPU 会将众多的纹理进行合成,其中就利用到了从 CPU 处传来的透明值等纹理显示的信息(我们后面具体说到合成时会细说)。将众多纹理合成后,ARGB 中的 A 值就不需要了,最终合成成显示器显示需要的 RGB 数据源,刷新显示器的显示。

2.2 iOS 卡顿的原因

  • UI 刷新时间有多快呢?

我们知道,一个动画要显示得连贯,根据人眼的视觉残影等原理,需要每秒刷新 60 帧,人就会感觉到显示的流畅。而每秒 60 帧也就是每 16.7ms 要完成上面所说的一次流程。这就是我们平常所说为什么不要在做动画时做复杂计算等操作的原因了,因为在 cpu->gpu->display 这个环节中,任一个环节进行了超时操作,都会造成丢帧。

VSync 信号(对于 VSync 信号的理解请看文章后附录章节中的 framebuffer 小节)到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于 垂直同步(对于垂直同步的理解请看文章后附录章节中的 framebuffer 小节)的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

在硬件流水线上有哪些程序工具协助显示工作呢?我们现在要看看显示过程中的软件栈:

3 UI 显示过程中的软件栈

image.png

我们这次从底往上挨个看看这些软件栈是如何互相协作完成系统 UI 的展示的。

首先 Display 显示器的软件栈不就多说了,主要还是显示 RGB 数据源进行刷新。大部分显示器自己都有调整自身显示偏移、亮度饱合度等的功能,其实就是对传入的 RGB 数据源进行加工。

GPU 处理纹理的合成,我们说过了,他负责处理多张像素图并将它们合成成一张显示在显示器上。为什么要使用 GPU 做这个合成工作呢?因为他有专门为高并发计算而量身定做的处理单元,能非常高效地地进纹理的合成。我们下面来粗略地看看 GPU 的合成算法。

3.1 合成算法

我们说,一个纹理就是一个包含 RGBA 值的长方形存储空间,比如,每一个像素里面都包含红、绿、蓝和透明度的值。在 Core Animation 世界中这就相当于一个 CALayer。我们现在把每个 layer 都想像成纹理,所有纹理组成一棵图层树。那么所谓的纹理合成,就是两张纹理存储对齐后一个像素一个像素合成得到一张纹理, 所以我们只要看一个像素的合成便可以推出整个纹理的合成了,这里两个像素 S 与 D 合成像素 R(其中 S 在顶端),(S+D)->R 的合成算法为:

R = S + D * (1 – Sa)    // 其中 Sa 为 S 像素中的透明度,我们用文字解释如下:
合成结果 = 源色彩 (顶端纹理) + 目标颜色 (低一层的纹理) * (1 - 源颜色的透明度)

上面便是两个像素点的合成算法。我们从中会发现两点:

如果顶层像素点为不透明,则可以直接使用 R=S,也就是合成的结果直接使用顶层像素值就行了!也就是合成后的像素点直接使用上层纹理的像素点就行,
我们会发现上面的公式与 UIView 或 CALayer 的 alpha 不同,为什么呢?因为透明度为 1 也就是不透明时 R=S 没问题,但是当透明度等于 0 时,你会发现 R=S+D, 比如 s 为红色,D 为绿色,则合成的 R 为黄色。但是,很明显,在 UIView 体系中,当 alpha 为 0 时,上层的 View 是会看不见的。所以,这里说的透明度是经过计算传给 GPU 的透明度,而非 UI 编程逻辑上常说的透明度。

3.2 不透明 VS 透明

当源纹理是完全不透明的时候,目标像素就等于源纹理。这可以省下 GPU 很大的工作量,这样只需简单的拷贝源纹理而不需要合成所有的像素值。那有没有方法能告诉 GPU 纹理上的像素是透明还是不透明的呢?答案是有!这就是我们在介绍 UIKit 及 CALayer 时所说的 opaque 属性!当我们将之置为 YES 时,GPU 将不会做任何合成,而是简单从这个层拷贝,不需要考虑它下方的任何东西 (因为都被它遮挡住了),这大大节省了 GPU 相当大的工作量!!

所以,现在你能理解为什么很多 View 的默认 opaque 值是 YES 了吧,这看起来是一个非常有用的优化。我们可以使用 Instruments 中 color blended layers 功能来查看当前布局中哪些地方没有使用不透明像素。

因此我们也就得出了一个优化结论是,当你知道你的 layer 是不透明的,最好确定设置它的 opaque 为 YES,还要注意的是,此时 alpha 值应该为 1,不然你的逻辑就有问题了。如果你加载一个没有 alpha 通道的图片,并且将它显示在 UIImageView 上,这将会自动发生。但是要记住一个没有 alpha 通道的图片和一个 imageView 的 aplha 值为 1 是不同的。 此时我们又得到一个结论就是使用没有 alpha 通道的图片性能会高一些,因为省掉了 GPU 一部分合成计算的工作量

3.3 OpenGL

我们基本上不可能直接对 GPU 进行编程,因为不同的 GPU 使用了不同的计算方法。但是中间一层 GPU Driver 层,这层驱动使 GPU 对上有统一的接口,而这统一的接口,多是对 OpenGL 的支持。

OpenGL(全写 Open Graphics Library)是指定义了一个跨编程语言、跨平台的编程接口规格的专业的图形程序接口。它用于三维图像(二维的亦可),是一个功能强大,调用方便的底层图形库 。OpenGL™ 是行业领域中最为广泛接纳的 2D/3D 图形 API,其自诞生至今已催生了各种计算机平台及设备上的数千优秀应用程序。OpenGL 使用简便,效率高。它具有七大功能:

  • 建模:OpenGL 图形库除了提供基本的点、线、多边形的绘制函数外,还提供了复杂的三维物体(球、锥、多面体、茶壶等)以及复杂曲线和曲面绘制函数。
  • 变换:OpenGL 图形库的变换包括基本变换和投影变换。基本变换有平移、旋转、缩放、镜像四种变换,投影变换有平行投影(又称正射投影)和透视投 影两种变换。其变换方法有利于减少算法的运行时间,提高三维图形的显示速度。
  • 颜色模式设置:OpenGL 颜色模式有两种,即 RGBA 模式和颜色索引(Color Index)。
  • 光照和材质设置:OpenGL 光有自发光(Emitted Light)、环境光(Ambient Light)、漫反射光(Diffuse Light)和高光(Specular Light)。材质是用光反射率来表示。场景(Scene)中物体最终反映到人眼的颜色是光的红绿蓝分量与材质红绿蓝分量的反射率相乘后形成的颜色。
  • 纹理映射(Texture Mapping)。利用 OpenGL 纹理映射功能可以十分逼真地表达物体表面细节。
    位图显示和图象增强图象功能除了基本的拷贝和像素读写外,还提供融合(Blending)、抗锯齿(反走样)(Antialiasing)和雾(fog)的特殊图象效果处理。以上三条可使被仿真物更具真实感,增强图形显示的效果。
  • 双缓存动画(Double Buffering)双缓存即前台缓存和后台缓存,简言之,后台缓存计算场景、生成画面,前台缓存显示后台缓存已画好的画面。
    在移动操作系统中,一般是内嵌了 OpenGL ES,OpenGL ES (OpenGL for Embedded Systems) 是 OpenGL 三维图形 API 的子集,针对手机、PDA 和游戏主机等嵌入式设备而设计 。

也就是说使用 OpenGL/OpenGL ES API 进行编程,就可以直接操作 GPU,实现最高的渲染效率。但是 OpenGL 编程难度还是比较高,对此,iOS 提供了几个框架对底层 OpenGL 进行了封装,给予我们都方便的接口与更易用的框架。当然,在 iOS 中你是可以直接使用 OpenGL/OpenGL ES API 进行编程的 (通常是游戏应用)。

3.4 CoreGraphics、CoreAnimation 和 CoreImage

这几个框架我们之前已经有过介绍了,大家可以看看 iOS 开发大全 中的 UI 章节。他们提供了应用中从读取图片、解压、到加载显示,以及界面上 UI 控件封装与管理,还有绘图、动画,基本涵盖了一个 App 有关 UI 显示的方方面面了。

我们下面主要学习下它们的工作原理,看看从中是否可以得到一些可以优化的点

4 深入 Core Animation

Core Animation 允许你做非常高效的渲染。这也是为什么当你使用 Core Animation 时可以实现每秒 60 帧的动画。 它的核心是对 OpenGL ES 进行封装 ,让你可以跳过 OpenGL 复杂的使用方式而使用到其高效的性能,这也是我们之前说可以将 layer 和 texture 当做等价的原因。

Core Animation 的 layer 可以有 子 layer,所以最终你得到的是一个 图层树 。Core Animation 所需要做的最繁重的任务便是判断出哪些图层需要被 (重新) 绘制,而 OpenGL ES 需要做的便是将图层合并、显示到屏幕上。

举个例子,当你设置一个 layer 的内容为 CGImageRef 时,Core Animation 会创建一个 OpenGL 纹理,并确保在这个图层中的位图被上传到对应的纹理中。以及当你重写 -drawInContext 方法时,Core Animation 会请求分配一个纹理,同时确保 Core Graphics 会将你所做的 (即你在 drawInContext 中绘制的东西) 放入到纹理的位图数据中。一个图层的性质和 CALayer 的子类会影响到 OpenGL 的渲染结果,许多低等级的 OpenGL ES 行为被简单易懂地封装到 CALayer 概念中。

Core Animation 通过 Core Graphics 的一端和 OpenGL ES 的另一端,精心策划基于 CPU 的位图绘制。因为 Core Animation 处在渲染过程中的重要位置上,所以你如何使用 Core Animation 将会对性能产生极大的影响。

4.1 后备存储 (layer backing store)

image.png

在介绍 CALayer 时,我们说过他的的 后备存储,但是因为当时没有学习到这么底层,所以对他的理解不深。

我们说每一个在 UIKit 中的 view 都有它自己的 CALayer (每个 view 至少都有一个 layer)。而这些图层都有一个叫像素位图的后备存储(也是我们之前说的 layer 的 cache), 有点像一个图像。这个后备存储会被映射成我们上面所说的 GPU 的一个纹理以显示到显示器上。

4.1.1 CALayer 的 - drawRect: 与 display 方法

如果你的视图类实现了 -drawRect:,他们将像这样工作:

当你调用 -setNeedsDisplay,UIKit 将会在这个视图的图层上调用 -setNeedsDisplay。这为图层设置了一个标识,标记为 dirty,但还显示原来的内容。它实际上没做任何工作,所以多次调用 -setNeedsDisplay 并不会造成性能损失。

下面,当渲染系统准备好,它会调用视图图层的 -display 方法。此时,图层会装配它的后备存储。然后建立一个 Core Graphics 上下文 (CGContextRef),将后备存储对应内存中的数据恢复出来,绘图会进入对应的内存区域,并使用 CGContextRef 绘制。

从现在开始,图层的后备存储将会被不断的渲染到屏幕上。直到下次再次调用视图的 -setNeedsDisplay ,将会依次将图层的后备存储更新到视图上。

也就是说,当你使用 -drawRect: 或 -display 方法准备在 layer 上绘图时,layer 会自动创建一块与 layer 大小相同的内存区域 ,后面的绘图结果就会保存中这块区域中,这块区域就是所谓的 后备存储 了。而如果我们使用 layer 的 contents 赋值一个 UIImage 呢,这将不会创建一个 后备存储 空间,取而代之的是使用一个 CGImageRef 作为他的内容,并且渲染服务将会把图片的数据绘制到帧的缓冲区,比如,绘制到显示屏。最简单的道理是多个图层使用要同的 contents,他们会共用这个图片的同一块内存。

这说明了:

为什么尽量不要使用 -drawRect: 或 -display 方法进行绘图,尽量使用 CALayer 的工具类如 CAShapeLayer 去绘图,这样可以节省 后备存储 空间;
要绘制一幅图片时,使用 UIGraphicsBeginImageContextWithOptions () 或者 CGBitmapContextCeate () 创建位置并共用之是更好的做法;
多个 Layer 使用同一张图片会节省内存,因为他们可以共用同一块存储空间。

4.2 Layer 的显示流程

layer 在显示之前,会经过 4 个阶段的处理:

  • 布局 ,计算 view/layer 的层级关系以及图层的属性 (位置、背景色、边框、阴影等);
  • 绘制 ,就是准备上小节所说的 后备存储,调用 drawRectordisplay 绘制要显示的内容;
  • 准备 ,Core Animatoin 在这个阶段对事务中数据做准备,比如解码要显示的图片等;
  • 提交 ,这是最后的阶段,Core Animation 打包所有的图层和动画属性,通过 IPC 发送到渲染服务进行显示。

5 深入 Core Graphics / Quartz 2D

Core Graphics 框架,就是我们常说的 Quartz 2D, 它的 API 是纯 C 语言的,它是一个二维绘图引擎,同时支持 iOS 和 Mac 系统,其中数据类型和函数基本都以 CG 作为前缀。

当你的程序进行位图绘制时,不管使用哪种方式,都是基于 Quartz 2D 的。也就是说,CPU 部分实现的绘制是通过 Quartz 2D 实现的 。

在 iOS 中使用 Quartz 2D 绘图,一般有两种方式,一种是使用 UIKit api,一种是使用 Core Graphics api。但是不管使用哪种方式,其实都使用了 CGContext 这个图形上下文来进行绘制。

当我们使用 Core Graphics api 时,需要显示地操作 CGContext。这个 context 可能是通过 -drawInContext: 传入的,此时的绘制都会被绘制到上节所说的 layer 的后备存储中去。我们也可以创建自己的上下文,比如 CGBitmapContextCreate (). 这个方法返回一个我们可以传给 CGContext 方法来绘制的上下文,绘制的内容可以生成我们自己可以使用的 image 位图。

使用 UIKit api 时无需显示地操作 context,因为 UIkit 维护着一个上下文堆栈,UIKit 方法总是绘制到最顶层的上下文中。你可以使用 UIGraphicsGetCurrentContext () 来得到最顶层的上下文。你可以使用 UIGraphicsPushContext () 和 UIGraphicsPopContext () 在 UIKit 的堆栈中推进或取出上下文。UIKit 中还可以使用 UIGraphicsBeginImageContextWithOptions () 和 UIGraphicsEndImageContext () 方便的创建类似于 CGBitmapContextCreate () 的位图上下文。

UIKit 方法可以与 CoreGraphics 方法混用:

CGContextRef ctx = CGBitmapContextCreate(NULL, 90, 90, 8, 90 * 4, space, bitmapInfo);
CGContextScaleCTM(ctx, 0.5, 0.5);
UIGraphicsPushContext(ctx);
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
...
[path stroke];
UIGraphicsPopContext(ctx);
CGContextRelease(ctx);

5.1 关于 Core Graphics 的几点优化

  • 尽量不要使用 -drawRect:-display 方法进行绘图,尽量使用 CALayer 的工具如 CAShapeLayer 等实现绘图

上一小节深入 Core Animation 时说了 **尽量不要使用 -drawRect:-display 方法进行绘图,尽量使用 CALayer 的工具类如 CAShapeLayer 去绘图,这样可以节省 后备存储 空间 **;其中还有个原因就是使用 Core Graphics 绘制 API 是在 CPU 上执行的,也就是我们调用 CG 方法绘图是由 CPU 渲染出 bitmap,再赋给 layer 经 GPU 显示,这里的渲染结果不是直接入 Framebuffer,故被称为 离屏渲染 (Off-Screen Rendering)。所以我们说尽量少地使用 CG 方法绘图,因为这会加大 CPU 的工作量 (我们将在 UI 优化下篇 中详细说明离屏渲染)。

  • 尽量只使用 setNeedsDisplayInRect:-drawRect: 重绘需要的部分

为了减少不必要的绘制,Mac OS 和 iOS 设备将会把屏幕区分为需要重绘的区域和不需要重绘的区域。那些需要重绘的部分被称作 脏区域。在实际应用中,鉴于非矩形区域边界裁剪和混合的复杂性,通常会区分出包含指定视图的矩形位置,而这个位置就是 脏矩形

当一个视图被改动过了,TA 可能需要重绘。** 但是很多情况下,只是这个视图的一部分被改变了,所以重绘整个寄宿图就太浪费了 **。当你检测到指定视图或图层的指定部分需要被重绘,你直接调用 setNeedsDisplayInRect: 来标记它,然后将影响到的矩形作为参数传入。这样就会在一次视图刷新时调用视图的 -drawRect: (或图层代理的 - drawLayer:inContext: 方法)

传入 -drawLayer:inContext: 的 CGContext 参数会自动被裁切以适应对应的矩形。为了确定矩形的尺寸大小,你可以用 CGContextGetClipBoundingBox () 方法来从上下文获得大小。调用 drawRect () 会更简单,因为 CGRect 会作为参数直接传入。

你应该将你的绘制工作限制在这个矩形中,任何在此区域之外的绘制都将被自动无视。

6 深入 Core Image

图片消耗很大一部分内存,而且不太可能把需要显示的图片都保留在内存中,所以需要在应用运行的时候周期性地加载和卸载图片。图片文件加载的速度被 CPU 和 IO(输入 / 输出)同时影响。iOS 设备中的闪存已经比传统硬盘快很多了,但仍然比 RAM 慢将近 200 倍左右,这就需要很小心地管理加载,来避免延迟。

你可以使用可变尺寸的图像来降低绘图系统的压力。

7 CPU 与 GPU 优化

经过本单了学习,我们明白了 iOS 的 UI 显示技术主要重任在 CPU 与 GPU 上,我们平时操作的库如 CoreAnimation, Quartz2D 都是在 CPU 层面的计算;而虽然我们对 GPU 直接操作的少,但是我们的某些行为也会影响其性能,这节我们主要探讨下怎么减轻他哥俩的重任。

7.1 GPU 优化重点

7.1.1 太多图层

大多数 CALayer 属性是使用 GPU 绘制的,因此,如果一次要渲染太多的 layer,显示会加重 GPU 的负担,让之效率降低。所以我们在绘制 UI 界面时,尽量不要造成太多的 layer 层级与数量。

7.1.2 重绘

如前面的章节介绍过的,只重绘需要的部分可以减少 GPU 重绘,提高显示的性能。

7.1.3 离屏渲染

有时我们的绘制不是直接绘制在屏幕上,而是绘制到离屏的图片上下文中。离屏渲染会分享额外的内存以及上下文的切换,这些都会降低 GPU 性能。对于特定的图层效果,比如圆角、layer mask、阴影或光栅化都会强制 Core Animation 提前进行离屏绘制。
离屏渲染 这个概念与 CALayer 在之前章节中我们已经一起学习过了,但那时说得不太够细,现在我们来细细看下这个问题。首先,我们补充下当初学习 CALayer 时漏掉的点,关于 CALayer 的显示结构:


image.png

这里我们看到,CALayer 也不是简简单单一张图画,也是由几个部件合成的。默认的空 Layer 的三个视觉元素是这样的:contents 为空,背景颜色为空 (透明色),前景框宽度为 0 的前景框,也就是说这个视图从视觉上是都看不到的。

通常来说,在原理篇我们学习显示的硬件流水与软件流水时已经知道了,一般情况下 layer 的这些东西直接传给 GPU 渲染进 Framebuffer 中显示就可以了,这也就是所谓的 在屏渲染 (On-Screen Rendering)。但是并不是所有事情都这么简单的。

思考一下,当我们使用 CG 方法绘图时,Core Graphics 绘制 API 是在 CPU 上执行的,也就是我们调用 CG 方法绘图是由 CPU 渲染出 bitmap,再赋给 layer 经 GPU 显示,这里的渲染结果不是直接入 Framebuffer,故被称为 离屏渲染 (Off-Screen Rendering)。所以我们说尽量少地使用 CG 方法绘图,因为这会加大 CPU 的工作量。

相较于 CPU 的离屏渲染,GPU 的离屏渲染更为隐蔽。当对图层使用圆角,阴影,遮罩等效果的时候,图层的这些属性导致需要对 layer 的 contents 进行再加工。这时,图层就不是简单地直接由 GPU 渲染至 FB 了,而是要先创建一个缓冲区,将渲染上下文从屏幕切换到这个缓冲区并进行离屏渲染,渲染结束后,再将上下文切回屏幕,这时再利用离屏渲染的内容结合输出显示。我们从这个过程可以看到,这就产生了 上下文切换开销 与 创建缓冲区的开销 。所以我们说,离屏渲染会带来性能问题,如果一屏显示中太多离屏渲染,就会带来卡顿感。

那么除了圆角,还有哪些操作会触发离屏渲染呢?

7.1.4 过大的图片

绘制过大的图片,比如超过了 GPU 一次可以处理的大小时,就需要 CPU 对之进行预处理,也会降低性能。

7.2 CPU 优化重点

7.2.1 布局计算

如果你的视图层级过于复杂,当视图呈现或者修改的时候,计算图层帧率就会消耗一部分时间。特别是使用 iOS6 的自动布局机制尤为明显,它应该是比老版的自动调整逻辑加强了 CPU 的工作。

7.2.2 视图懒加载

iOS 只会当视图控制器的视图显示到屏幕上时才会加载它。这对内存使用和程序启动时间很有好处,但是当呈现到屏幕上之前,按下按钮导致 的许多工作都会不能被及时响应。比如控制器从数据库中获取数据,或者或者涉及 IO 的图片显示,都会比 CPU 正常操作慢得多。所以我们要正确使用视图懒加载,在一些需要及时响应的地方尽量不使用懒加载。

7.2.3 Core Graphics 绘制

如果对视图实现了 -drawRect: 方法,或是 CALayerDelegate 的 -drawLayer:inContext: 方法,那么在绘制任何东西之前会先生成本章前几节介绍的 后备存储。然后一旦绘制结束之后,必须把图片数据通过 IPC 传到渲染服务器。在此基础上,Core Graphics 绘制就会变得十分缓慢,所以在一个对性能十分挑剔的场景下这样做十分不好。

7.2.4 解压图片

PNG 或者 JPEG 压缩之后的图片文件会比同质量的位图小得多。但在图片绘制到屏幕上之前,必须把它扩展成完整的未解压的尺寸(通常等同于图片宽 x 长 x 4 个字节)。为了节省内存,iOS 通常直到真正绘制的时候才去解码图片。根据你加载图片的方式,第一次对 图层内容赋值的时候(直接或者间接使用 UIImageView )或者把它绘制到 Core Graphics 中,都需要对它解压,这样的话,对于一个较大的图片,都会占用一定的时间。

8.1 Framebuffer - 帧缓冲器

之前在做 Android 的截屏 App 时接触过一个叫 Framebuffer 的驱动,所以我知道屏幕上显示的东西其实就是存储在 Framebuffer 上,那么,FramebufferGPU 是什么关系呢?

我们说,Framebuffer 的真正作用是,由 CPU 计算好的显示内容,GPU 渲染完成后将渲染结果放入 Framebuffer,随后视频控制器会按照 HSync 信号 (此处可参考 ibireme 的关于屏幕显示图像原理来理解) 逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示,如下图:

image.png

但 Framebuffer 并不是屏幕内容的直接的像素表示。Framebuffer 实际上包含了几个不同作用的缓存,比如颜色缓存、深度缓存等,具体不详细说明。大家只需要知道,这几个缓存的共同作用下,形成了最终在屏幕上显示的图像。而且 Framebuffer 是一段存储空间,可以位于显存 (也就是文章上面提到的 VRAM),也可以位于内存中。

Framebuffer 是一个逻辑上的概念,并非在显存或者是内存上有一块固定的物理区域叫 Framebuffer。实际上,物理是显存或者内存,只要是在 GPU 能够访问的空间范围内 (GPU 的物理地址空间),任意分配一段内存 (或显存),都可以作为 Framebuffer 使用 ,只需要在分配后将该内存区域信息,设置到显卡相关的寄存器中即可。这个其实跟 DMA 区域的概念是类似的。

引用中的关于 FrameBuffer 文章大家想学习的可以点进去细看之。

9.2 双缓冲机制

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

垂直同步 (V-Sync)
双缓冲虽然能解决效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象。

为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

那么目前主流的移动设备是什么情况呢?iOS 设备会始终使用双缓存,并开启垂直同步。而安卓设备直到 4.1 版本,Google 才开始引入这种机制,目前安卓系统是三缓存 + 垂直同步。

你可能感兴趣的:(iOS UI 显示的原理及优化策略)