一、屏幕显像原理
上图显示的是CRT电子枪扫描路径,涉及到两个比较重要的概念:水平同步信号(HSync),垂直同步信号(VSync)。
当要显示图像时,电子枪会发送一个VSync信号,将电子枪移到左上角,然后发送一个HSync信号,电子枪从屏幕最左侧扫描到屏幕最右侧,完成一行的扫描,然后再发送一个HSync信号,开始第二行的扫描,重复此过程直到最后一行完成扫描,此时当前帧已经扫描完毕,屏幕呈现完整画面。当要展示下一帧图像时会再次发送VSync信号,将电子枪移到左上角重复扫描过程。
二、图像显示流程
上图是图像显示流程,图像的显示需要CPU和GPU共同协作,CPU负责计算显示内容并提交到GPU,GPU负责图像渲染并将结果存放到帧缓存区,最后由视频控制器通过VSync读取帧缓存区中的内容,通过一些列转换显示到屏幕上。
为了使用图像显示更流畅iOS采用了双缓冲机制。系统会准备A、B两个缓冲区,假设A缓冲区有内容,B缓冲区无内容,视频控制器发送VSync信号会读取A缓冲区内容,这时GPU可以继续像B缓冲区写入内容。当A缓冲区读取完毕,发送VSync信号读取下一帧时,视频控制器会立刻读取B缓冲区的内容(B中的内容是在A在被读取的时候写入的),此时GPU会像A缓冲区继续写入内容。A、B两个缓冲区交替读写实现了双缓冲机制,提高了图像显示的流畅度。
三、iOS图像显示流程
UIKit是常用的框架,显示、动画都通过CoreAnimation。
CoreAnimation是核心动画,依赖于OpenGL ES做GPU渲染,CoreGraphics做CPU渲染;
最底层的GraphicsHardWare是图形硬件。
上图显示了在iOS中数据先通过CPU部分如CoreGraphics、CoreAnimation和CoreImage预处理,然后通过OpenGl ES将数据传送到GPU,最终显示到屏幕上。
注:CoreImage支持CPU和GPU两种处理模式。
1).CoreAnimation提交会话,包括自己和子树(view hierarchy)的layout状态等;
2).RenderServer解析提交的子树状态,生成绘制指令;
3).GPU执行绘制指令
4).显示渲染后的数据
CPU渲染职能
1).Layout:在这个阶段,程序设置 View/Layer 的层级信息,设置 layer 的属性,如 frame,background color 等
会造成CPU和I/O瓶颈。
2).Display:在这个阶段程序会创建 layer 的 backing image,无论是通过 setContents 将一个 image 传給 layer,还是通过 drawRect:或 drawLayer:inContext:来画出来的。所以 drawRect:等函数是在这个阶段被调用的
会造成CPU和内存瓶颈。
3).Prepare:在这个阶段,Core Animation 框架准备要渲染的 layer 的各种属性数据,以及要做的动画的参数,准备传递給 render server。同时在这个阶段也会解压要渲染的 image。(除了用 imageNamed:方法从 bundle 加载的 image 会立刻解压之外,其他的比如直接从硬盘读入,或者从网络上下载的 image 不会立刻解压,只有在真正要渲染的时候才会解压)
GPU不支持的某些图片格式,尽量使用GPU能支持的图片格式。
4).Commit:在这个阶段,Core Animation 打包 layer 的信息以及需要做的动画的参数,通过 IPC(inter-Process Communication)传递給 render server。
尽可能简化视图层级,层级太多会对性能造成影响。
OpenGL ES渲染职能
纹理的概念:纹理是一个用来保存图像的颜色元值的 OpenGL ES 缓存,可以简单理解为一个单位。
OpenGL ES是对图层进行取色,采样,生成纹理,绑定数据,生成前后帧缓存。
最后,会将生成的前后帧缓存提交到GPU。
OpenGL ES具体的可以去看《OpenGL ES应用开发实践指南:iOS卷》。
GPU渲染职能
Tiled-Based 渲染是移动设备的主流。整个屏幕会分解成N*Npixels组成的瓦片(Tiles),tiles存储于SoC 缓存(SoC=system on chip,片上系统,是在整块芯片上实现一个复杂系统功能,如intel cpu,整合了集显,内存控制器,cpu运核心,缓存,队列、非核心和I/O控制器)。几何形状会分解成若干个tiles,对于每一块tile,把必须的几何体提交到OpenGL ES,然后进行渲染(光栅化)。完毕后,将tile的数据发送回cpu。
1、普通的Tile-Based渲染流程
∙CommandBuffer:接受OpenGL ES处理完毕的渲染指令
∙Tiler:调用顶点着色器,把顶点数据进行分块(Tiling)
∙ParameterBuffer:接受分块完毕的tile和对应的渲染参数
∙Renderer:调用片元着色器,进行像素渲染
∙RenderBuffer:存储渲染完毕的像素
2.离屏渲染
3.渲染迟滞
由于一帧内的顶点和像素的处理是发生在相对独立的阶段的,应用程序会将CPU处理, 顶点处理,像素处理安排在相邻的三帧中。如下图所示。当一个渲染命令提交后,要在当帧之后的第三帧,渲染结果才会显示出来。
四、卡顿原因
在 VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。
从上面的图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧引起卡顿。
五、优化方案
CPU方面优化
1.对象创建
对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。
尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择。
尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。尽管这实现起来比较麻烦,并且带来的优势并不多,但如果有能力做,还是要尽量尝试一下。如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用。
2.对象调整
对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer:CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。
当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图。
3.对象销毁
对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。这里有个小 Tip:把对象捕获到 block 中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了。
4.布局计算
视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这个地方基本就不会产生性能问题了。
不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center 等属性的调整上。上面也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。
5.文本计算
如果一个界面中包含大量文本(比如微博微信朋友圈等),文本的宽高计算会占用很大一部分资源,并且不可避免。如果你对文本显示没有特殊要求,可以参考下 UILabel 内部的实现方式:用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程。
如果你用 CoreText 绘制文本,那就可以先生成 CoreText 排版对象,然后自己计算了,并且 CoreText 对象还能保留以供稍后绘制使用。
6.文本渲染
屏幕上能看到的所有文本内容控件,包括 UIWebView,在底层都是通过 CoreText 排版、绘 制为 Bitmap 显示的。常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。
7.图片的解码
当你用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。
8.图像的绘制
图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以放到后台线程进行。
GPU方面优化
相对于 CPU 来说,GPU 能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。
1.纹理的渲染
所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。
当图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。目前来说,iPhone 4S 以上机型,纹理尺寸上限都是 4096×4096,更详细的资料可以看这里:iosres.com。所以,尽量不要让图片和视图的大小超过这个值。
2.视图的混合 (Composing)
当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成。当然,这也可以用上面的方法,把多个视图预先渲染为一张图片来显示。
3.图形的生成。
CALayer 的 border、圆角、阴影、遮罩(mask),CASharpLayer 的矢量图形显示,通常会触发离屏渲染(offscreen rendering),而离屏渲染通常发生在 GPU 中。当一个列表视图中出现大量圆角的 CALayer,并且快速滑动时,可以观察到 GPU 资源已经占满,而 CPU 资源消耗很少。这时界面仍然能正常滑动,但平均帧数会降到很低。为了避免这种情况,可以尝试开启 CALayer.shouldRasterize 属性,但这会把原本离屏渲染的操作转嫁到 CPU 上去。对于只需要圆角的某些场合,也可以用一张已经绘制好的圆角图片覆盖到原本视图上面来模拟相同的视觉效果。最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性。
六、Core Animation工具
对于离屏渲染的检测,苹果为我们提供了一个测试工具Core Animation。可以在Xcode->Open Develeper Tools->Instruments中找到,如下图:
Core Animation工具用来监测Core Animation性能,提供可见的FPS值,并且提供几个选项来测量渲染性能。如下图:
下面我们来说明每个选项的功能:
Color Blended Layers:这个选项如果勾选,你能看到哪个layer是透明的,GPU正在做混合计算。显示红色的就是透明的,绿色就是不透明的。
Color Hits Green and Misses Red:如果勾选这个选项,且当我们代码中有设置shouldRasterize为YES,那么红色代表没有复用离屏渲染的缓存,绿色则表示复用了缓存。我们当然希望能够复用。
Color Copied Images:按照官方的说法,当图片的颜色格式GPU不支持的时候,Core Animation会
Color Immediately:默认情况下Core Animation工具以每毫秒10次的频率更新图层调试颜色,如果勾选这个选项则移除10ms的延迟。对某些情况需要这样,但是有可能影响正常帧数的测试。
Color Misaligned Images:勾选此项,如果图片需要缩放则标记为黄色,如果没有像素对齐则标记为紫色。像素对齐我们已经在上面有所介绍。
Color Offscreen-Rendered Yellow:用来检测离屏渲染的,如果显示黄色,表示有离屏渲染。当然还要结合Color Hits Green and Misses Red来看,是否复用了缓存。
Color OpenGL Fast Path Blue:这个选项对那些使用OpenGL的图层才有用,像是GLKView或者 CAEAGLLayer,如果不显示蓝色则表示使用了CPU渲染,绘制在了屏幕外,显示蓝色表示正常。
Flash Updated Regions:当对图层重绘的时候回显示黄色,如果频繁发生则会影响性能。可以用增加缓存来增强性能。
七、其它
Core Animation 在 RunLoop 中注册了一个 Observer,监听了 BeforeWaiting 和 Exit 事件。当一个触摸事件到来时,RunLoop 被唤醒,App 中的代码会执行一些操作,比如创建和调整视图层级、设置 UIView 的 frame、修改 CALayer 的透明度、为视图添加一个动画;这些操作最终都会被 CALayer 标记,并通过 CATransaction 提交到一个中间状态去。当上面所有操作结束后,RunLoop 即将进入休眠(或者退出)时,关注该事件的 Observer 都会得到通知。这时 Core Animation 注册的那个 Observer 就会在回调中,把所有的中间状态合并提交到 GPU 去显示;如果此处有动画,通过 DisplayLink 稳定的刷新机制会不断的唤醒runloop,使得不断的有机会触发observer回调,从而根据时间来不断更新这个动画的属性值并绘制出来。