本文整理一下有关计算机图像渲染流程,以及 iOS 渲染相关知识,最后介绍一下在 iOS 开发过程中保持 APP 流畅的注意事项。
简介
在显示器上显示的图像是由一帧一帧的画面组成的,当一帧画面绘制完成后,准备画下一帧,显示器会发出一个垂直同步信号 VSync(vertical synchronization)刷新画面。显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率。
计算机通过 CPU、GPU、显示器协同工作显示图像。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。
计算机将存储在内存中的形状转换成实际绘制在屏幕上的图像的过程称为渲染( Render )。下面就来看一下渲染的过程。
计算机图像渲染
图像渲染流程,大概的步骤:
Application 应用处理阶段:得到图元
这个阶段图像在应用中被处理,可能会对图像进行一系列的操作或者改变,此时还处于 CPU 负责的时期。最终将新的图像信息传给下一阶段。这部分信息被叫做图元(primitives)用于表示渲染的顶点数据,如:点、线、三角形。
Geometry 几何处理阶段:处理图元
进入这个阶段之后,就主要由 GPU 负责了。GPU 拿到上一个阶段传递下来的图元信息,对这部分图元进行处理,之后输出新的图元。这一系列阶段包括:
- 顶点着色器(Vertex Shader):将图元中的顶点信息进行处理,主要的目的是把 3D 坐标转为另一种 3D 坐标,同时也可以对顶点属性进行一些基本处理。
- 形状装配(Shape Assembly):将图元中的三角形、线段、点分别对应的顶点 Vertex 装配成指定图元的形状。
- 几何着色器(Geometry Shader):产生额外的顶点 Vertex,将原始图元转换成新图元,以构建其他形状的模型。简单来说就是基于通过三角形、线段和点构建更复杂的几何图形。
Rasterization 光栅化阶段:图元转换为像素
光栅化的主要目的是将几何渲染之后的图元信息数据,转换为一系列的像素,以便后续显示在屏幕上。根据图元信息,计算出每个图元所覆盖的像素信息,生成片段。片段(Fragment) 是渲染一个像素所需要的所有数据。
Pixel 像素处理阶段:处理像素,得到位图
经过上述光栅化阶段,我们得到了图元所对应的像素,此时,我们需要给这些像素填充颜色和效果,只要有足够多的不同色彩的像素,就可以制作出色彩丰富的图象。所以最后这个阶段就是给像素填充正确的内容,最终显示在屏幕上。这些经过处理、蕴含大量信息的像素点集合,被称作位图(bitmap)。也就是说,Pixel 阶段最终输出的结果就是位图,过程具体包含:
- 片段着色器(Fragment Shader):也叫做 Pixel Shader,这个阶段的目的是给每一个像素 Pixel 赋予正确的颜色。颜色的来源就是之前得到的顶点、纹理、光照等信息。由于需要处理纹理、光照等复杂信息,所以这通常是整个系统的性能瓶颈。
- 测试与混合(Tests and Blending):也叫做 Merging 阶段,这个阶段主要处理片段的前后位置以及透明度。会检测各个着色片段的深度值 z 坐标,从而判断片段的前后位置,以及是否应该被舍弃。同时也会计算相应的透明度 alpha 值,从而进行片段的混合,得到最终的颜色。
图像渲染流程结束之后,接下来就需要将得到的像素信息显示在物理屏幕上了。GPU 最后一步渲染结束之后像素信息,被存在帧缓冲器(Framebuffer)中,之后视频控制器(Video Controller)会读取帧缓冲器中的信息,经过数模转换传递给显示器进行显示。
iOS 中的渲染
iOS 的渲染框架依然符合渲染流水线的基本架构。在硬件基础之上,iOS 中有 Core Graphics、Core Animation、Core Image、OpenGL 等多种软件框架来绘制内容,在 CPU 与 GPU 之间进行了更高层地封装。
UIKit 是 iOS 开发者最常用的框架,可以通过设置 UIKit 组件的布局以及相关属性来绘制界面。显示、动画都通过 CoreAnimation,依赖于 OpenGL ES、Metal 做 GPU 渲染,CoreGraphics 做 CPU 渲染,最底层的 GraphicsHardWare 是图形硬件。
显示在屏幕上的 UIView 继承自 UIResponder 自身并不具备在屏幕成像的能力,其主要负责对用户操作事件的响应。我们看到的屏幕上的内容都由 CALayer 进行管理,CALayer 中有个属性 contents 提供了 layer 的内容。contents 属性保存了由设备渲染流水线渲染好的位图 bitmap(通常也被称为 backing store),而当设备屏幕进行刷新时,会从 CALayer 中读取生成好的 bitmap,进而呈现到屏幕上。每次渲染 需要重绘时,Core Animation 会触发调用 drawRect: 方法,使用存储好的 bitmap 进行新一轮的展示。
Core Animation 主要职责包含:渲染、构建和实现动画。
通常我们会使用 Core Animation 来高效、方便地实现动画,动画实现只是它功能中的一部分。除此之外其职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的 layer,这些图层会被存储在一个叫做图层树的体系之中。从本质上而言,CALayer 是用户所能在屏幕上看见的一切的基础。Core Graphics 是一个强大的二维图像绘制引擎,是 iOS 的核心图形库,常用的比如 CGRect 就定义在这个框架下。
Core Image 是一个高性能的图像处理分析的框架,支持CPU、GPU两种处理模式。它拥有一系列现成的图像滤镜,能对已存在的图像进行高效的处理。
OpenGL ES(OpenGL for Embedded Systems,简称 GLES),是 OpenGL 的子集。是一个提供了 2D 和 3D 图形渲染的 API,它能和 GPU 密切的配合,最高效地利用 GPU 的能力,实现硬件加速渲染。 OpenGL 是一套第三方标准,函数的内部实现由对应的 GPU 厂商开发实现。
Metal 类似于 OpenGL ES,也是一套第三方标准,具体实现由苹果实现。Core Animation、Core Image、SceneKit、SpriteKit 等等渲染框架都是构建于 Metal 之上的。
渲染过程
iOS 中 APP 的渲染是由一个独立的进程 Render Server 负责。APP 将渲染任务及相关数据提交给 Render Server。Render Server 处理完数据后,再传递至 GPU。最后由 GPU 调用 iOS 的图像设备进行显示。
1、CoreAnimation 提交会话,包括自己和子树(view hierarchy)的 layout 状态等;
2、RenderServer 解析提交的子树状态,生成绘制指令;
3、GPU执行绘制指令;
4、显示渲染后的数据;
上面的 Commit Transaction 其实可以细分为 4 个步骤: Layout、Display、Prepare、Commit
- Layout 阶段主要进行视图构建,包括:layoutSubviews 方法的重载,addSubview: 方法添加子视图等。
- Display 阶段主要进行视图绘制,这里仅仅是设置最要成像的图元数据。重载视图的 drawRect: 方法可以自定义 UIView 的显示,其原理是在 drawRect: 方法内部绘制寄宿图,该过程使用 CPU 和内存。
- Prepare 阶段属于附加步骤,一般处理图像的解码和转换等操作。
- Commit 阶段主要将图层进行打包,并将它们发送至 Render Server。该过程会递归执行,因为图层和视图都是以树形结构存在,如果子树太复杂,会消耗很大,对性能造成影响。
Tile-Based 渲染
Tiled-Based 渲染是移动设备的主流。整个屏幕会分解成N*Npixels组成的瓦片(Tiles),tiles存储于SoC 缓存中。对于每一块 tile,把必须的几何体提交到 OpenGL ES,然后进行渲染(光栅化)。完毕后,将 tile 的数据发送回 CPU。
普通的Tile-Based渲染流程
1、CommandBuffer,接受 OpenGL ES 处理完毕的渲染指令;
2、Tiler,调用顶点着色器,把顶点数据进行分块(Tiling);
3、ParameterBuffer,接受分块完毕的tile和对应的渲染参数;
4、Renderer,调用片元着色器,进行像素渲染,处理得到 bitmap,之后存入 Render Buffer;
5、RenderBuffer,存储渲染完毕的像素,供之后的 Display 操作使用;
离屏渲染
普通情况下 GPU 直接将渲染好的内容放入 Framebuffer 中,而离屏渲染需要先额外创建离屏渲染缓冲区 Offscreen Buffer,将提前渲染好的内容放入其中,等到合适的时机再将 Offscreen Buffer 中的内容进一步叠加、渲染,完成后将结果切换到 Framebuffer 中。
离屏渲染时由于 APP 需要提前对部分内容进行额外的渲染并保存到 Offscreen Buffer,以及需要在必要时刻对 Offscreen Buffer 和 Framebuffer 进行内容切换,所以会需要更长的处理时间。并且 Offscreen Buffer 本身就需要额外的空间,大量的离屏渲染可能早能内存的过大压力。
可见离屏渲染的开销非常大,一旦需要离屏渲染的内容过多,很容易造成掉帧的问题。所以尽量避免离屏渲染。
使用离屏渲染原因:
1、一些特殊效果需要使用额外的 Offscreen Buffer 来保存渲染的中间状态,所以不得不使用离屏渲染。比如阴影、圆角等等。
2、处于效率目的,可以将内容提前渲染保存在 Offscreen Buffer 中,达到复用的目的。
触发离屏渲染的情况:
1、使用了 layer.mask
遮罩显示的内容是由两层渲染结果叠加,所以必须要利用额外的内存空间对中间的渲染结果进行保存,因此系统会默认触发离屏渲染。
2、模糊特效 UIBlurEffectView
模糊过程分为多步:先渲染需要模糊的内容本身,然后对内容进行缩放,然后分别对内容进行横纵方向的模糊操作,最后一步用模糊后的结果叠加合成,最终实现完整的模糊特效。
使用 UIBlurEffectView ,应该是尽可能小的 view,因为性能消耗巨大。
3、光栅化的 layer.shouldRasterize
把视图的内容渲染成纹理并缓存,可以通过CALayer的shouldRasterize属性开启光栅化。
注意,光栅化的元素,总大小限制为2.5倍的屏幕。
更新内容时,会启用离屏渲染,所以更新代价较大,只能用于静态内容;而且如果光栅化的元素100ms 没有被使用将被移除,故而不常用元素的光栅化并不会优化显示。
圆角、阴影、组透明度等会由系统自动触发离屏渲染,那么打开光栅化可以节约第二次及以后的渲染时间。而多层 subLayer 的情况由于不会自动触发离屏渲染,所以相比之下会多花费第一次离屏渲染的时间,但是可以节约后续的重复渲染的开销。
4、组透明度 layer.allowsGroupOpacity / layer.opacity
CALayer 的 allowsGroupOpacity 属性,UIView 的 alpha 属性等,同于 CALayer opacity 属性。
allowsGroupOpacity = YES,子 layer 在视觉上的透明度的上限是其父 layer 的 opacity。当父视图的layer.opacity != 1.0时,会开启离屏渲染。layer.opacity == 1.0时,父视图不用管子视图,只需显示当前视图即可。
5、需要进行裁剪的 layer ,layer.masksToBounds / view.clipsToBounds
设置 cornerRadius 剪裁圆角时,没有设置 masksToBounds = YES,由于不需要叠加裁剪,此时是并不会触发离屏渲染的。而当设置了裁剪属性的时候,由于 masksToBounds 会对 layer 以及所有 subLayer 的 content 都进行裁剪,这时会触发离屏渲染。
6、添加了投影的 layer ,layer.shadow*
7、绘制了文字的 layer ,UILabel, CATextLayer, Core Text 等
设置圆角+裁剪(cornerRadius+masksToBounds)、透明度+组透明(allowsGroupOpacity+opacity)、阴影等,都是类似的效果,设置后会应用到所有的 subLayer 上,所以 subLayer 处理后,不能立刻丢弃,等待所有 subLayer 处理完成,然后叠加合成,其中就要被保存在 Offscreen buffer 中,这也就触发了离屏渲染。
需要注意的是,重写 drawRect: 方法并不会触发离屏渲染。重写 drawRect: GPU 会等待 CPU 数据计算完成,然后进行 GPU 中的渲染操作,并且需要额外开辟内存空间。这和标准意义上的离屏渲染并不一样,在 Instrument 中开启 Color offscreen rendered yellow 调试时也会发现这并不会被判断为离屏渲染。
性能优化
通过上面的内容,大致了解了图像显示的过程,屏幕上显示的内容,CUP 计算好内容后交给 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次刷新显示到屏幕上。屏幕 60Hz 的刷新率,每秒显示 60 帧画面,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变,这就导致每秒没有显示 60 帧画面,产生了卡顿。
在开发中保持 APP 的流畅使我们追求的目标,可以通过 Instuments 工具,查看显示相关的数据,从而定位问题,优化性能,提升流畅度。
CUP 资源性能优化
对象创建
对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。
尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用。布局计算
视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。尽量在后台线程提前计算好视图布局、并且对视图布局进行缓存。
UIView 的 frame、bounds、transform 等属性,实际上都是 CALayer 属性映射来的,CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性,应该尽量减少不必要的属性修改。
当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以应该尽量避免调整视图层次、添加和移除视图。文本计算
如果一个界面中包含大量文本,计算文本的宽高,不可避免的会占用很大一部分资源。可以使用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程。
屏幕上能看到的所有文本内容控件,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。常见的文本控件 UILabel、UITextView 等,其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。可自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制,可参考第三方文本框架 YYText。CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算,CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。
图片的解码
尽量使用 PNG 格式图片,Xcode有对PNG图片进行特殊的算法优化。避免使用奇怪的图片格式, 避免格式转换和调整图片大小。一个图片如果不被GPU支持,那么需要CPU来转换。
当你用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。图形的绘制
重写了drawRect 绘制图像会导致CPU渲染;在CPU进行渲染时,GPU大多数情况是处于等待状态。
由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以放到后台线程进行。
GUP 资源性能优化
相对于 CPU 来说,GPU 能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合并渲染,然后输出到屏幕上。
- 纹理的渲染
所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。当在较短时间显示大量图片时,CPU 占用率很低,GPU 占用非常高,界面可能会掉帧。避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。
当图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。
视图的混合 (Composing)
当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成。当然,这也可以用上面的方法,把多个视图预先渲染为一张图片来显示。图形的生成
CALayer 的 border、圆角、阴影、遮罩(mask),CASharpLayer 的矢量图形显示,通常会触发离屏渲染(offscreen rendering),而离屏渲染通常发生在 GPU 中。当一个列表视图中出现大量圆角的 CALayer,并且快速滑动时,可以观察到 GPU 资源已经占满,而 CPU 资源消耗很少。这时界面仍然能正常滑动,但平均帧数会降到很低。为了避免这种情况,可以尝试开启 CALayer.shouldRasterize 光栅化属性,但这会把原本离屏渲染的操作转嫁到 CPU 上去。
对于只需要圆角的情况,也可以用一张已经绘制好的圆角图片覆盖到原本视图上面来模拟相同的视觉效果。最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性。
References
iOS开发-视图渲染与性能优化
iOS Rendering 渲染全解析
iOS 图像渲染原理
iOS 保持界面流畅的技巧
iOS 浅谈GPU及App渲染流程