优化APP的显示性能
APP 的显示性能问题一直以来都是一个经久不衰的话题,特别是滚动视图、表视图的滚动流畅性等等问题。那么从我们 new
一个 UIKit
开始到它在屏幕上显示的过程,CPU 和 GPU 以及显示设备是如何协同工作的呢?APP 的显示性能、帧率到底应该从哪些方面入手去优化?还是说忽略理论分析,而强行优化性能?下面我们就来讨论一下这个问题。
一、几个概念
1、像素和分辨率
像素,通俗的来讲就是构成图像的最小单位,APP 的显示界面就是由像素组成,每个像素都会携带一个由 RGB 三原色组成的颜色信息,一个界面所有的像素和其携带的颜色信息组成我们肉眼看到的多彩的界面。分辨率就是一个界面可以显示的像素数,比如 iPhone 6 的分辨率为 750x1334,即单个 iPhone 6 界面可以显示 750x1334 个像素。分辨率的大小决定了画面的精细程度。比如:
左侧的图片甚至可以看到一个一个的小格子(像素)。
2、位图
位图,在计算机术语中,它是一种数据结构,我们也可以理解为,一张由二进制表示的图像,它也是由若干个点组成,每个点会携带一个表示 RGB 三原色的数值,比如 0xffffff
等。
3、FPS(Frame per second,帧率)
在计算机图形学、视频、动画、游戏等领域都有帧率的影子,这个视频可以帮助我们很好的理解这个概念,当我们在 APP 上滚动视图时,滚动动画其实就是一帧一帧的图像组成的,只不过 CPU 让它滚动的频率过快,才形成了动画,其实视频、动画、游戏等都是相同的原理。iOS 设备的帧率是 60,也就是 CPU 会每秒刷新 60 次界面,如果不够这个数,就会丢帧,丢帧的直观感受就是小时候看 CD 机时,我们叫丢帧为卡碟,这也是界面不流畅或者卡顿的根源。
4、CALayer 和 UIView
UIView
和 CALayer
都是一些被层级关系管理的矩形块,每一个视图都有一个 layer,当我们对视图做仿射变换(比如旋转、缩放),滑动、渐变等动画时,其实是操作的它的 layer,我们也可以直接对每个视图的 layer 做这些事。
UIView
是对 CALayer
的高级封装,他除了具有和 layer 相同的功能以外,还能处理手势事件。但是为什么 iOS 要基于 UIView
和 CALayer
提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?原因在于要做职责分离,这样也能避免很多重复代码。在 iOS 和 Mac OS 两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘有着本质的区别,这就是为什么 iOS 有 UIKit 和 UIView
,但是 Mac OS 有 AppKit 和 NSView
的原因。他们功能上很相似,但是在实现上有着显著的区别。
UIView
和 CALayer
之间有一个 has a 的关系,每一个视图都 has a layer,其实既然视图是对图层能力的扩充,那么为什么不是继承关系?这是因为 has a 比 is a 具有更好的扩展性和可维护性。
二者有相同的层级关系,父视图(父图层)、子视图(子图层),这个应该没人不知道,不再赘述。
5、寄宿图
上面说,每个我们在手机屏幕上看到的内容都是由像素组成的图像,动画也是有一帧一帧的图像组成的,每个图像在经过 GPU 处理之前都是以位图的形式存储在内存中,它也被成为 CALayer
的寄宿图:
1 | /** Layer content properties and methods. **/ |
6、Core Graphics
CG 是一个轻量级的 2D 绘图 API,CALayer
工作在它的上层,当我们创建一个 UIView
时,CALayer
会使用 CG 生成一个位图,所以,它的作用是生成位图,我们也可以不通过 CALayer
而直接使用它:
1 | CGContextRef ctx = UIGraphicsGetCurrentContext(); |
用 CG 绘制一个简单的矩形。
7、Core Animation
Core Animation 的作用不仅仅是动画,我们可能对它有点误解,动画的能力只是它所有能力中的冰山一角。
当我双击 home 键或者长按应用图标的时候,iPhone 都会有动画交互,其实动画不止存在于应用内,也存在于应用外。动画和图层的显示是一个独立的渲染服务进程,当我们在应用内准备好动画的执行时间、位图等内容时,CA 通过 IPC(进程通信) 将这些信息传递给渲染进程,它会调用 OpenGL-ES API 完成对动画的渲染工作。OpenGL 是一个开放式的图形库,它负责使用 GPU 和图形管线等硬件将位图输送到等屏幕上。
(我没有深入研究过 OpenGL 和硬件渲染,仅仅知道一些皮毛,所以不做过多的讨论。)
二、一个视图从创建到显示在屏幕上
CPU 和 GPU 的工作原理请自行维基百科,从上面的几个概念,我们可以得出一个结论:
整个图像的绘制和显示过程如上图,其实这里 CALayer
并没有承担图层的工作,它更像是保存图像属性的数据模型,如上文我们用 CG 绘制一个矩形时,会给它的上下文设置一些信息,比如填充色、大小、位置等信息,而这些内容都是我们平常工作中设置给 layer 或者 view 的,因此,可以认为 CALayer
是 CG 上下文的对象描述。
从创建一个 UIView
到它显示在屏幕上,经历了如下几个步:
- 布局,准备视图/图层的层级关系,以及视图/图层的属性(背景色、位置、填充内容、阴影等)。
- 绘制,由 CG 生成图层的寄宿图(位图),调用
-drawRect:
方法。 - 准备,准备将位图和一些动画信息打包发送给渲染服务进程,同时 CA 也会解码动画的执行时间、缓冲函数(渐入渐出)等一些信息。
- 提交,CA 打包所有位图和动画属性,通过 IPC 发送到渲染服务进程。
- 纹理化,渲染服务进程按照图层之间的层级结构将所有的位图信息计算后,设置 OpenGL 几何形状。
- 渲染,OpenGL 指令操作 GPU、图形总线等将图像渲染到屏幕上。
现在回想一下,好像工作中,也就经历了第一步,可能有部分人会经历第二步,看起来简单的过程,其实幕后工作很复杂,当然我们可能永远不会绕过 CA 直接编写 OpenGL 程序,但是知道这些幕后工作对优化 APP 来说有很大的意义。
CPU 和 GPU 协同工作,上面的六个步骤前五步都是 CPU 的工作,第六步才交付给 GPU,它用来采集图片和形状(三角形),运行变换,应用纹理和混合然后把它们输送到屏幕上。也就是说渲染之前的计算、绘制等仍然会占用 CPU 和 RAM 资源。
考虑一下上面几步哪些会是 CPU 和 GPU 的性能瓶颈?
- 布局计算,如果视图层级过于复杂,当视图呈现或者修改的时候,计算图层布局就会消耗一部分时间。Autolayout 会增加 CPU 的计算时间,但是它又有更好的可维护性,建议平衡取舍后使用它。
-drawRect:
,重写这个方法会产生一定的性能损耗,我们被灌输过这样的观点,下文会解释原因所在。- 特别复杂的视图层级,当图层绘制完成后,CA 会将所有的位图打包,通过 IPC 提交给渲染服务,IPC 是 CPU 的工作,假如视图层级特别多的话,也会增加 CPU 的传送时间。
- 重绘(每一帧都需要相同的像素填充多次),主要是半透明的图层引起的,因为半透明的图层在显示时,需要和后面的图层的像素混合才能达到半透明的效果,GPU 需要耗费更多的时间来绘制。
- 离屏渲染,有些图层 GPU 会用离屏渲染的方式绘制它,也就是在 GPU 的缓冲区渲染,然后在需要显示的时候,直接从缓冲区取出来显示,假如 GPU 在渲染其他图层的过程中,发生了离屏渲染 ,就会造成渲染的上下文切换,假如离屏渲染的图层过多的话,会给 GPU 带来很大的负载。
- 图片过大,当加载一个图片到内存中并显示时,CPU 会对它进行预处理,比如压缩和解压缩,当图片过大,就会增加 CPU 的负载。
等等。
- drawRect: 带来的性能问题
drawRect:
方法的默认实现是不做任何事情,只要你给 UIView/CALayer
设置合适的属性,CG 就会正确的绘制它,-drawRect:
方法只有首次绘制某个图层,或者某个图层的可见部分失效的情况下才会被调用。
当我们绘制完一个图层时(调用 -drawRect:
),CPU 会保存一份快照在缓冲区,对于动画来说,图层的每一帧变化总是会有一大块未被改变的像素集合,比如滚动视图,滚动的前后帧的区别仅仅是很小的一部分像素块。当改变发生时,CPU 会将未改变的部分从缓冲区快照中复制到新的图层,放到合适的位置,改变的部分被重新计算和绘制,而此时 -drawRect
也会被调用。假如重写了这个方法,此时 CG 会重新绘制整个图层,也就是当改变发生时,CG 不会复制未发生改变的大块像素,而是重新绘制整个图层,这就是重写它会带来性能损耗的原因。
imageView 的优势 or 劣势?
假如我们直接用静态的 image 填充 imageView
就节省了绘制的过程,直接被 CG 压缩转化为位图后,扔给 CA 处理,然后渲染进程将图片解压缩,纹理化为三角形后,交给 GPU 去渲染。如果图片不是特别大的话,看上去比直接绘制速度可能会快一点,但是,即使 iOS 的闪存比一般的磁盘读写速度快很多,而 RAM 仍然会比它快 200 倍,iOS 视图懒加载的特性又导致了它不会在使用图片之前将它加载进内存,所以,静态图片的优势也就荡然无存,因此,CG 绘制要比静态图片更快一些。
三、APP 显示性能提升的方案
基于上述的理论分析,可以得出几种解决性能问题的方案:
- 首先,使用
UIKit
、CALayer
或者Core Graphics
绘制寄宿图,都不能给性能带来显著的提升,UIKit
相对于 CG 并不会产生 CPU 的性能损耗,反而能让我们更好的使用面向对象机制。如果要使用异步绘制的话,请使用 CG,但同时也要注意 UI 作为临界区时带来的线程安全问题。 - Autolayout,自动布局从诞生开始就是一个争议性的存在,现在我们可以确定它对性能有一定的损耗,因为 CPU 要重新计算布局,但是它又带来了更好的可维护性,随着硬件的提升,这点损耗在我看来是无伤大雅的,但是究竟如何选择,还要衡量一下利弊,最好不要在性能要求很高的地方使用它。
- 不要重写
-drawRect:
。 - 尽量用代码代替本地图片。
- 同一个界面,不要有太复杂的视图层级,最好让所有的子视图都在同一层级。
- 说服你们的 UI,减少不必要的圆角、阴影等可能产生离屏渲染的图层。
- 尽量将所有的图层都设置为不透明。尽量!!
- 从磁盘和网络加载的图片不宜过大,网络图片库,类似于 SDWebImage 会对这方面优化。
等等。
这些方法可能都是些老生常谈的内容,因为我们可能在很多地方看到前辈们对我们的忠告,但是从来没有人告诉过我为什么要这样,前面的理论分析都是这段结果的铺垫。
更流畅的 tableView
上面提到了,滚动视图的滚动动画中 iOS 所做的优化,即复用不变的部分,重绘可变的部分。相对于显示能力,大家更关注 tableView
的滚动流畅性,滚动动画其实是一帧一帧的图像衔接在一起产生的,因此上述几点对每一帧图像的优化当然也对滚动视图至关重要。比如将所有 cell
的子视图都放在同一层级,同样也会提高滚动的流畅性。
tableView
和普通视图不同的是,它有好几个数据源代理方法,而它的代理方法又是绘制它的关键,因此无论更新数据源还是滚动都会多次调用它的代理方法,现在你应该知道为什么滚动的时候也多次调用代理方法了。假如我们滚动的过程中,绘制帧率达不到 60 次/s,就会造成卡顿的视觉效果(和卡碟差不多)。针对这些,我们也可以提几个优化的点:
- 代理方法中不要做太多的耗时工作,它会造成滚动视图的时候占用 CPU,阻塞主线程,从而导致帧率下降。缓存
cell
高度,避免滚动时计算是个不错的好方法。 - 尽量不要改变
cell
的显示内容,比如太多的事件导致的 UI 变化,cell
的定位是一个轻量级的展示控件。因为,cell
变化过多的话,就会导致滚动的时候,可变的区域增大。 - RunLoop 单独使用一个 mode 来处理滚动的原因就是为了保持它的帧率,我们可以将它加入
commonModes
,让 RunLoop 在响应其他事件的同时滚动视图,但是其他事件可能会阻塞主线程,导致帧率下降,因此,尽可能的处理轻量级的事件,或者使用异步子线程来做。
等等,后面想到再补充。
四、总结
以前总是盲目的看别人的博客,然后优化性能,缺少真正的理论依据,因此自己整理一下,其中不乏一些自己的见解,当然也都是建立在理论上的结果,仅供参考。
五、参考
1、The relationship between CoreGraphics, UIViews and CALayers
3、What is different between CoreGraphics and CoreAnimation
4、https://github.com/AttackOnDobby/iOS-Core-Animation-Advanced-Techniques
5、https://developer.apple.com/documentation/uikit/uiview/1622529-drawrect