iOS 异步绘制

4,570 阅读8分钟

一、UIView如何显示内容

当我们操作UI时,例如改变frame、更新UIView/CALayer,或者自己去调用setNeedsLayout/setNeedsDisplay方法,UIView会调用-[CALayer setNeedsLayout]/-[CALayer setNeedsDisplay]方法,给layer上打上一个脏标记,意味着需要重绘。但是只有在下一次runloop即将结束的时候才会调用[CALayer display],而这个方法会判断是否实现了displayLayer这个方法,如果没有实现,那么走系统调用,如果实现了就为我们提供了异步绘制的入口。具体可以参看下面的流程图

绘制流程

系统绘制:

系统绘制
我们首先看一下系统绘制,当[CALayer dispaly]方法调用的时候,他会检查-dispalyerLayer方法是否被实现了,若没有实现则我们调用系统的绘制方法。首先 CALayer会生成一个backing store(CGContextRef),每个layer都有一个content,这个content指向的一块缓存称为backing store。如果layer有delegate,则调用delegate的- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx方法,否则调用-[CALayer drawInContext:]方法,进而调用[UIView drawRect:]方法。 UIKit会将这个conext推到系统的context堆栈中,如果在draw rect中通过UIGraphicsGetCurrentContext() 取得的CGContextRef就是CALayer生成的这个实例。所有的绘制操作也会在这块Context上生效。 CPU 执行完draw rect之后,通过context将数据写入backing store。当backing store写完之后,通过rendserver交给GPU去渲染,将backing store中的bitmap数据显示在屏幕上。

二、UIKit遇到的问题

iOS的mainLoop是一个60fps的回掉,即16.7毫秒绘制一次屏幕。这个时间内要完成view的缓冲区创建,view内容的绘制,这些是cpu的工作,cpu完成之后交给GPU去渲染,这个过程又包含了多个view的拼接,纹理的渲染,最终渲染在屏幕上。如果这个时候,cpu做了很多工作,view层次过于复杂,图片过大,导致gpu压力也很大,那么就会出现迪奥帧的现象,也就是表现在我们的眼里的“卡”。 如果我们所有的绘制任务都交给UIKit去做,因为UIKit不是线程安全的,所以官方也建议我们只在主线程操作。那么就无法利用cpu多核的优势,无法异步的进行绘制,但是通过对UIView绘制原理的了解我们知道,在异步绘制是有他的理论基础的。

三、异步绘制的原理

好,我们现在说一下异步绘制的原理。 我们不能在非主线程将内容绘制到layer的context上,但是我们可以将需要绘制的内容绘制在一个自己创建的跑private_context上。通过CGBitmapContextCreate()可以创建一个CGCentextRef,在异步线程使用这个context进行绘制,最后通过CGBitmapContextCreateImage()创建一个CGImageRef,并在主线程设置给layer的contents,完成异步绘制。

- (void)display {
    dispatch_async(backgroundQueue, ^{
        CGContextRef ctx = CGBitmapContextCreate(...);
        // draw in context...
        CGImageRef img = CGBitmapContextCreateImage(ctx);
        CFRelease(ctx);
        dispatch_async(mainQueue, ^{
            layer.contents = img;
        });
    });
}

四、CPU相关优化

view的绘制与CPU和GPU都有很强的关系,但是具体是哪些呢?了解了之后我们才能为卡顿问题的优化提成更好的解决方案。

4.1 创建对象

对象的创建会分配内存、调整属性、甚至会可能有I/O操作,比较消耗CPU资源。所以要尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量,如果不需要响应触摸事件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但如果是包含了 CALayer 的控件,都只能在主线程创建和操作。尽量不使用storyboard创建视图对象。使用懒加载,将不重要对象的创建时机延后。

4.2 调整对象视图层级

对象的视图层级变化也会增加cpu的运算,应尽量减少addsubview,removesubview等操作。减少视图的层级,避免过多的调整。

4.3 调整对象布局

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

不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center 等属性的调整上。上面也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。

4.4文本计算、文本渲染

如果一个界面中包含大量文本(比如微博微信朋友圈等),文本的宽高计算会占用很大一部分资源,并且不可避免。如果你对文本显示没有特殊要求,可以参考下 UILabel 内部的实现方式:用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程。

如果你用 CoreText 绘制文本,那就可以先生成 CoreText 排版对象,然后自己计算了,并且 CoreText 对象还能保留以供稍后绘制使用。 屏幕上能看到的所有文本内容控件,包括 UIWebView,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。

4.5 图像绘制

图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个过程我们可以用异步绘制的思想解决这个问题,发挥cpu多核的优势。

五、GPU相关优化

相对于 CPU 来说,GPU 能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合并渲染,然后输出到屏幕上。 GPU处理的单位是Texture,基本上我们控制GPU都是通过OpenGL来完成的,但是从bitmap到Texture之间需要一座桥梁,Core Animation正好充当了这个角色: Core Animation对OpenGL的api有一层封装,当我们的要渲染的layer已经有了bitmap content的时候,这个content一般来说是一个CGImageRef,CoreAnimation会创建一个OpenGL的Texture并将CGImageRef(bitmap)和这个Texture绑定,通过TextureID来标识。 这个对应关系建立起来之后,剩下的任务就是GPU如何将Texture渲染到屏幕上了。

5.1 视图混合(Composing)

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

5.2 图形的生成

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

5.3 纹理的渲染

所有的 Bitmap,包括图片、文本、栅格化的内容,最终都要由内存提交到显存,绑定为 GPU Texture。不论是提交到显存的过程,还是 GPU 调整和渲染 Texture 的过程,都要消耗不少 GPU 资源。当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。