卡顿一般是由于CPU或者GPU没有完成内容提交,以至于那一帧会被丢掉,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。

CPU相关

对象创建

对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗CPU资源。尽量用轻量的对象代替重量的对象。比如CALayer比UIView要轻量许多,如果不需要响应事件,显然用CALayer更加合适。如果不涉及UI操作则尽量放到后台线程去创建。通过Storyboard创建视图对象时,其资源消耗会比直接通过代码创建对象要大很多。
尽量推迟对象创建时间,并把对象的创建分散到多个任务。如果对象可以复用,尽量这类对象放到缓冲池中。

对象调整

对象的调整也很消耗CPU。比如对UIView的显示相关属性(frame、bounds、transform等)调整、视图层次调整。

对象销毁

对象销毁虽然消耗资源不多,但累计起来也是不容忽视的。

布局计算

视图布局的计算是最为常见的消耗CPU资源的地方。如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这块儿基本就不会产生性能问题。

Autolayout

Autolayout是苹果提倡的技术,在大部分情况下也能很好的提升开发效率,但是对于复杂的视图来说常常会产生严重的性能问题。随着视图数量的增长,Autolayout带来的CPU消耗会呈指数级增长。如果不想手动调整frame属性,可以用一些工具方法替代(left、right、top、button、width、height等)。

文本计算

如果一个界面中包含大量文本,文本的宽高计算会占用很大一部分资源,并且不可避免。可以参考一下UILabel内部的实现方式:

//计算文本高度
[NSAttributeString boundingRectWithSize: options: context: ];
//绘制文本
[NSAttributeString drawWithRect: options: context: ];

尽管这两个方法性能不错,但仍需放在后台线程以避免阻塞主线程。
如果用CoreText绘制文本,就可以先生成CoreText排版对象,然后自己计算。并且CoreText还能够保留供稍后绘制使用。

文本渲染

屏幕上能看到的所有文本内容控件,在底层都是通过CoreText排版、绘制位Bitmap显示的。常见的文本控件其排版和绘制都是在主线程进行的,当显示大量文本时,CPU的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用TextKit或最底层的CoreText对文本异步绘制。CoreText对象创建后可直接获取文本的宽高等信息,避免了多次计算(调整UILabel大小时计算一遍、UILabel绘制时内部再计算一遍)。CoreText对象占用内存较少,可以缓存下来以备稍后多次渲染。

图片的解码

当使用UIImage或CGImageSource的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到UIImageView或者CALayer.contents中去,并且CALayer被提交到GPU前,CGImage中的数据才会得到解码。这一步发生在主线程并且不可避免。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到CGBitmapContext中,然后从Bitmap中直接创建图片。目前常见的网络图片库都自带这个功能。

图像的绘制

图像的绘制一般是指用那些以CG开头的方法把图像绘制到画布中,然后从画布创建图片并显示的一个过程。由于CoreGraphic方法通常都是线程安全的,所以图像绘制很容易放倒后台线程执行。

GPU相关

相对于CPU来说,GPU能干的事情比较单一:接收提交的纹理和顶点描述,应用变换、混合并渲染,然后输出到屏幕。通常所能看到的内容主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。

纹理的渲染

所有的Bitmap,包括图片、文本、栅格化的内容,最终都要有内存提交到缓存,绑定为GPU纹理。当在较短时间显示大量图片时(比如tableview存在很多图片并且快速滑动时),CPU占用率很低,GPU占用非常高,界面仍会掉帧。避免这种情况的方法只能是只能尽量减少在短时间内显示图片数量,尽可能将多张图片合成一张进行显示。

视图的混合

当多个视图(或者说CALayer)重叠在一起显示时,GPU会首先把它们混合在一起。如果视图结果过于复杂,混合的过程也会消耗很多GPU资源。为了减轻这种情况的GPU消耗,应用应当尽量减少视图数量和层次,并在不透明的的视图里标明opaque属性以避免无用的alpha通道合成。

图形的生成

CALayer的border、圆角、阴影、遮罩(mask),CAShapLayer的矢量图形显示,通常会触发离屏渲染(offscreen rendering),而离屏渲染通常发生在GPU中。当一个列表视图中出现大量圆角的CALayer并且快速滑动时,可以观察到GPU资源已占满而CPU资源消耗很少,这时界面帧数会降到很低。为了避免这种情况可以尝试开启CALayer.shouldRasterize属性,但这会把原本离屏渲染的操作转嫁到CPU上去。对于只需要圆角的某些场合,也可以用一张已经绘制好的圆角图片覆盖到原本视图上;最彻底的解决办法就是把需要显示的圆形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性。

AsyncDisplayKit

Facebook发布了其iOS UI框架AsyncDisplayKit(ASDK)的正式版,这个框架被用于Facebook自家的应用Paper,能够提高UI的流畅性并缩短响应时间。主要是把原来在主线程的处理(解码图像、布局、渲染等)放到后台并且可以利用不同的CPU核心。

ASDK 的基本原理

ios ui卡顿很久都不动 ios变得很卡_ios


ASDK认为阻塞主线程的任务主要分为上面三大类。文本和布局的计算、渲染、解码、绘制都可以通过各种方式异步执行,但UIKit和Core Animation相关操作必须在主线程执行。ASDK的目标就是尽量把这些任务从主线程挪走,挪不动的就尽量优化性能。

为了达成这一目标,ASDK 尝试对 UIKit 组件进行封装:

ios ui卡顿很久都不动 ios变得很卡_ios_02


这是常见的UIView和CALayer的关系:View持有Layer用于显示,View中大部分显示属性实际是从Layer映射而来;Layer的代理在这里是View,当其属性改变动画产生时,View能够得到通知。UIView和CALayer不是线程安全的,并且只能在主线程创建、访问和销毁。

ios ui卡顿很久都不动 ios变得很卡_ios ui卡顿很久都不动_03


ASDK 为此创建了 ASDisplayNode 类,包装了常见的视图属性(比如 frame、bounds、alpha、transform、backgroundColor、superNode、subNodes 等),然后它用 UIView—>CALayer 相同的方式,实现了 ASNode—>UIView 这样一个关系。

ios ui卡顿很久都不动 ios变得很卡_ios ui卡顿很久都不动_04


当不需要响应触摸事件时,ASDisplayNode可以被设置为layer backed,即ASDisplayNode充当了原来UIView的功能,节省了更多资源。

与 UIView 和 CALayer 不同,ASDisplayNode 是线程安全的,它可以在后台线程创建和修改。Node 刚创建时,并不会在内部新建 UIView 和 CALayer,直到第一次在主线程访问 view 或 layer 属性时,它才会在内部生成对应的对象。当它的属性(比如frame、transform)改变后,它并不会立刻同步到其持有的 view 或 layer 去,而是把被改变的属性保存到内部的一个中间变量,稍后在需要时,再通过某个机制一次性设置到内部的 view 或 layer。

通过模拟和封装 UIView、CALayer,开发者可以把代码中的 UIView 替换为 ASNode,很大的降低了开发和学习成本,同时能获得 ASDK 底层大量的性能优化。为了方便使用, ASDK 把大量常用控件都封装成了 ASNode 的子类,比如 Button、Control、Cell、Image、ImageView、Text、TableView、CollectionView 等。利用这些控件,开发者可以尽量避免直接使用 UIKit 相关控件,以获得更完整的性能提升。

ASDK 的图层预合成

有时一个 layer 会包含很多 sub-layer,而这些 sub-layer 并不需要响应触摸事件,也不需要进行动画和位置调整。ASDK 为此实现了一个被称为 pre-composing 的技术,可以把这些 sub-layer 合成渲染为一张图片。开发时,ASNode 已经替代了 UIView 和 CALayer;直接使用各种 Node 控件并设置为 layer backed 后,ASNode 甚至可以通过预合成来避免创建内部的 UIView 和 CALayer。
通过这种方式,把一个大的层级,通过一个大的绘制方法绘制到一张图上,性能会获得很大提升。CPU避免了创建UIKit对象的资源消耗,GPU避免了多张纹理合成和渲染的消耗,更少的 Bitmap 也意味着更少的内存占用。

ASDK异步并发操作

自 iPhone 4S 起,iDevice 已经都是双核 CPU 了。充分利用多核的优势、并发执行任务对保持界面流畅有很大作用。ASDK 把布局计算、文本排版、图片/文本/图形渲染等操作都封装成较小的任务,并利用 GCD 异步并发执行。如果开发者使用了 ASNode 相关的控件,那么这些并发操作会自动在后台进行,无需进行过多配置。

RunLoop任务分发

Runloop work distribution 是 ASDK 比较核心的一个技术。

ios ui卡顿很久都不动 ios变得很卡_ios ui卡顿很久都不动_05


iOS 的显示系统是由 VSync 信号驱动的,VSync 信号由硬件时钟生成,每秒钟发出 60 次(这个值取决设备硬件,比如 iPhone 真机上通常是 59.97)。iOS 图形服务接收到 VSync 信号后,会通过 IPC 通知到 App 内。App 的 Runloop 在启动后会注册对应的 CFRunLoopSource 通过 mach_port 接收传过来的时钟信号通知,随后 Source 的回调会驱动整个 App 的动画与显示。

Core Animation 在 RunLoop 中注册了一个 Observer,监听了 BeforeWaiting 和 Exit 事件。这个 Observer 的优先级是 2000000,低于常见的其他 Observer。当一个触摸事件到来时,RunLoop 被唤醒,App 中的代码会执行一些操作,比如创建和调整视图层级、设置 UIView 的 frame、修改 CALayer 的透明度、为视图添加一个动画;这些操作最终都会被 CALayer 捕获,并通过 CATransaction 提交到一个中间状态去(CATransaction 的文档略有提到这些内容,但并不完整)。当上面所有操作结束后,RunLoop 即将进入休眠(或者退出)时,关注该事件的 Observer 都会得到通知。这时 CA 注册的那个 Observer 就会在回调中,把所有的中间状态合并提交到 GPU 去显示;如果此处有动画,CA 会通过 DisplayLink 等机制多次触发相关流程。

ASDK 在此处模拟了 Core Animation 的这个机制:所有针对 ASNode 的修改和提交,总有些任务是必需放入主线程执行的。当出现这种任务时,ASNode 会把任务用 ASAsyncTransaction(Group) 封装并提交到一个全局的容器去。ASDK 也在 RunLoop 中注册了一个 Observer,监视的事件和 CA 一样,但优先级比 CA 要低。当 RunLoop 进入休眠前、CA 处理完事件后,ASDK 就会执行该 loop 内提交的所有任务。具体代码见这个文件:ASAsyncTransactionGroup

通过这种机制,ASDK 可以在合适的机会把异步、并发的操作同步到主线程去,并且能获得不错的性能。

其他

ASDK 中还有封装很多高级的功能,比如滑动列表的预加载、V2.0添加的新的布局模式等。ASDK 是一个很庞大的库,它本身并不推荐你把整个 App 全部都改为 ASDK 驱动,把最需要提升交互性能的地方用 ASDK 进行优化就足够了。