iOS-保持界面流畅技巧

原文链接iOS 保持界面流畅的技巧。下面是笔记和总结。

屏幕显示原理

CRT显示器的电子枪,是从左到右,再从上到下扫描,扫到右下角,算一帧,完成后又回到左上角开始新的一帧。

iOS-UI-ScreenScan

为了让显示器和系统的视频控制器(以下简称VC)同步,显示器会用硬件时钟产生一系列的定时信号。电子枪换行时,会发出一个水平同步信号(Hsync:Horizonal synchronization),完成一帧后,会发出垂直同步信号(VSync:Vertical synchronization)

显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。尽管现在的设备大都是液晶显示屏了,但原理仍然没有变。

GPU

首先,CPU计算好显示内容提交给GPU,GPU渲染完成后将渲染结果放入帧缓冲区,随后VC会按照VSync信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。

如果帧缓冲区只有一个,那么读写会出现效率问题,因此引入了双缓冲机制(即两个缓冲区)。GPU会在渲染一帧后,放在缓冲区A中,让VC读取,然后下一帧渲染后,放在缓冲区B中,切换VC的读取指针到B。轮流使用两个缓冲区。

iOS-UI-ScreenDisplay

但双缓冲也会出现问题,如果VC读取到一半,即画面实现一半,此时,切换缓冲区,VC会接着一行行扫描新的缓冲区,导致一半上一帧,一半下一帧,造成画面撕裂。

iOS-UI-ScreenError

因此,GPU通常支持垂直同步(V-Sync),即当显示器发出VSync信号后,才进行下一帧的渲染和切换缓冲区。iOS设备是双缓冲,且支持垂直同步。

卡顿原因

在VSync信号到来后,iOS系统图形服务会通过CADisplayLink等机制通知App,App主线程开始在CPU中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。完成后,CPU会将计算好的内容提交到GPU去,由GPU进行变换、合成、渲染,并提交到缓冲区。

如果在一个VSync时间内,CPU或者GPU没有完成内容提交,那这一帧就会被丢弃,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

iOS-UI-FrameDrop

CPU和GPU不论哪个阻碍了显示流程,都会造成掉帧现象,开发中需要分别对其进行评估和优化。

卡顿解决

CPU

对象创建

对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。

优化策略:

  • 避免使用Storyboard,Storyboard更耗资源;
  • 对于无需响应触摸事件的控件,直接用更轻量的CALayer显示,而不创建UIView;
  • 不涉及UI操作的对象,放到后台线程去创建,但包含CALayer的控件,都只能在主线程创建和操作;
  • 可以复用的对象,放到缓存池中;
  • 尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。

对象调整

对象的调整也经常是消耗CPU资源的地方。

CALayer内部并没有属性,当调用属性方法时,它内部是通过Runtime的resolveInstanceMethod为对象临时添加一个方法,并把对应的属性值保存到内部一个Dictionary中,同时通知Delegate,创建动画等,非常消耗资源。

UIView的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是CALayer属性映射来的,所以对 UIView的这些属性进行调整时,消耗的资源要远大于一般的属性。另外,当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,也会消耗资源。

优化策略:

  • 尽量避免调整视图层次、添加和移除视图;
  • 避免修改UIView的属性,特别是显示属性。

对象销毁

对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。

优化策略:

  • 将对象放到后台线程中进行释放。

例如:

1
2
3
4
5
NSArray *tmp = self.array;
self.array = nil;
dispatch_async(queue, ^{
[tmp class];
});

布局计算

视图布局的计算是App中最为常见的消耗CPU资源的地方。

优化策略:

  • 在后台线程提前计算好视图布局、并且对视图布局进行缓存;
  • 在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整布局属性。

AutoLayout

AutoLayout对于复杂视图会造成严重的性能问题。随着视图数量的增长,Autolayout 带来的CPU消耗会呈指数级上升。

iOS-AutoLayout-Performance

优化策略:

  • 尽量避免使用AutoLayout;
  • 可以用left/right/top/bottom/width/height等快捷属性。

文本计算

如果一个界面中包含大量文本,文本的宽高计算会占用很大一部分资源,并且不可避免。

优化策略:

  • 在后台线程中用[NSAttributedString boundingRectWithSize:options:context:]来计算宽高;
  • 在后台线程中用[NSAttributedString drawWithRect:options:context:]来绘制文本;
  • 用CoreText绘制文本,先生成CoreText排版对象,然后自己计算,并且保留CoreText对象以供稍后绘制使用。

文本渲染

屏幕上能看到的所有文本内容控件(UILabel、UITextView、UIWebView),在底层都是通过CoreText排版、绘制为Bitmap显示的。由于都在主线程中进行,因此CPU压力很大。

优化策略:

  • 自定义文本控件,用TextKit或最底层的CoreText对文本进行异步绘制;
  • 缓存CoreText对象,以便多次渲染。

图片解码

当用UIImage或CGImageSource的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到UIImageView或者CALayer.contents中去,并且CALayer被提交到GPU前,CGImage中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。

优化策略:

  • 在后台线程先把图片绘制到CGBitmapContext中,然后从 Bitmap直接创建图片。

图像绘制

图像的绘制通常是指用那些以CG开头的方法把图像绘制到画布中,然后从画布创建图片并显示的过程。

这个最常见的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。一个简单异步绘制的过程大致如下:

1
2
3
4
5
6
7
8
9
10
11
- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}

GPU

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

纹理渲染

所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。当在较短时间显示大量图片时,CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。

优化策略:

  • 尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示;
  • 尽量不要让图片和视图的大小超过GPU 的最大纹理尺寸,否则图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。iOS为4096x4096。

视图混合

当多个CALayer重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。

优化策略:

  • 尽量减少视图数量和层次;
  • 在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成;
  • 把多个视图预先渲染为一张图片来显示。

图形生成

CALayer 的 border、圆角、阴影、遮罩,CASharpLayer 的矢量图形显示,通常会触发GPU的离屏渲染,这时界面仍然能正常滑动,但平均帧数会降到很低。

优化策略:

  • 在后台线程中直接生成一张圆角图片,避免使用圆角、阴影、遮罩等属性。

评测流畅度

GPU Driver

Instuments->OpenGL ES Analysis->GPU Driver,能够实时查看到 CPU 和 GPU 的资源消耗, 并且能查看到几乎所有与显示有关的数据,比如 Texture 数量、CA 提交的频率、GPU 消耗等。

代码监控

CPU和GPU的流畅度可以通过CADisplayLink计算FPS来检测。

总结

预排版

将Cell按照内容分类,并将TableView中每条Cell需要的数据,在后台线程中计算并封装为一个布局对象CellLayout,并将其缓存,以便复用。

不使用AutoLayout,少用UILabel等控件。

对于无需触摸事件的视图,用CALayer替换UIView,将其统一绘制成一张图片。

预渲染

将需要圆角、阴影等图片在后台预先渲染,然后保存到ImageCache中。避免离屏渲染

异步绘制

尽量将绘制工作放到后台线程中,并在绘制每一行文本时,检测任务是否已被取消。这种情况出现在TableView快速划动时,有些Cell还没完成绘制,就已经滑出屏幕了,此时应该立刻取消绘制。

更简单粗暴点的方案就是,在滑动松开手指后,立刻计算划动停止时Cell得位置,并预先绘制附近的几个Cell,忽略滑动中的Cell。这对性能提升也很明显,但是会出现大量空白,属于可以取舍的技巧。

全局并发控制

如果使用Concurrent Queue来执行大量的绘制任务,当遇到锁或者其他原因,线程被阻塞时,会创建新的线程,这会导致过多的线程被创建,运行,销毁等。这里,使用多个优先级不同的Serail Queue来执行不同的绘制任务,避免上述问题。