- UIView和CALayer之间的关系
- UIView绘制原理
- 图像显示原理
- UI卡顿、掉帧的原因
- 解决方案
- 离屏渲染
- 何时触发
- 为什么要避免离屏渲染
- 具体举例
UIView和CALayer之间的关系
首先他们二者之间是一个什么关系?
- UIView 是对CALayer的一个封装,它为CALayer 提供内容以及负责处理触摸等事件,参与事件的响应链
-
CALayer 负责显示内容的contents
CALayer由背景色backgroundColor、内容contents、边缘borderWidth&borderColor
构成,图片摘自Adjusting a Layer’s Visual Style and Appearance
UIView绘制原理
上面我们说过,UIView的内容其实是CALayer
显示的,layer中有一个属性contents
,而contents
的内容就是要显示的具体内容,通常情况下,contents
的值就是一个位图。我们常用的无论是 UILabel
还是UIImageView
里面显示的内容,其实都是绘制在一张画布上,绘制完成从画布中导出图片,再把图片赋值给layer.contents
就完成了显示 (推荐看看这篇文章 绘制像素到屏幕上),那么UIView内部是如何具体绘制的呢?
下面我们通过一张图来详细看看:
- 当我们调用
[UIView setNeedsDisplay]
方法时候,实际上系统并没有立即触发UIView的绘制工作,而是调用了对应layer的同名方法[view.layer setNeedsDisplay]
,此处相当于在layer上面打了一个脏标记,然后等到当前RunLoop
将要结束的时候才会去调用[CALayer display]
方法,此时才真正的进入到了视图的绘制工作中。 - 在
[CALayer display]
方法内部会首先去判断layer.delegate
是否响应displayerLayer:
函数,如果响应该函数则进入到了异步绘制工作中;否则进入到系统绘制流程中。
上面就是UIView的绘制原理,接下来我们看一下系统绘制流程是怎样的
-
系统绘制流程:
在
CALayer
内部会首先创建一个backing store
,也就是我们常说的图形上下文CGContextRef
。然后layer会判断是否有代理,如果没有代理,那么就会调用
[CALayer drawInCotext:]
方法;如果有代理,会调用代理的drawLayer:inContext:
方法,然后做当前视图的绘制工作,在一个合适的时机给与我们这个十分熟悉的[UIView drawRect:]
方法的回调,该方法内部默认是什么都不做的,系统给我们开这个口子是为了让我们可以再做一些其他的绘制工作无论走哪个分支,最终都会由
CALayer
上传对应的backing store
给GPU,此时结束系统默认的绘制流程异步绘制流程:
实际上我们只需要调用系统给我们提供的代理方法[layer.delegate displayLayer:]
即可,在该方法中生成对应的bitmap(位图)
,同时设置bitmap
作为layer.contents
属性的值。
具体流程如下:
假如此时调用
[view setNeedsDisplay]
方法,系统会在当前RunLoop
将要结束的时候调用[CALyer display]
方法,如果此时我们实现了displayLayer :
这个方法,我们可以在该方法内部创建一个子线程去做一些位图的绘制工作,主线程可以去做一些其他的操作当位图绘制完成后在回到主线程,设置layer的
contents
属性,这样就完成了一个UI控件的异步绘制过程
图像显示原理
计算机系统中的CPU和GPU这两个硬件是通过总线连接起来的,在CPU中输出的结果一般都是一个位图,在经由总线在一个合适的时机上传给GPU,GPU拿到这个位图之后会做一些图层的渲染,包括一些纹理的合成等,之后将这个结果放入到
FrameBuffer
即帧缓冲区中,随后视频控制器会按照 VSync
信号在指定时间之前提取对应帧缓冲区的数据显示内容,最后显示到我们的手机屏幕上面。
下面我们来看看在这个过程中CPU和GPU分别都做了哪些工作
首先当我们创建一个UIView控件的时候,其中负责显示内容的是
CALayer
CALayer
中有一个contents
属性,就是我们最终要绘制到屏幕上的一个位图,比如说我们创建了一个UILabel
,那么在contents
里面就放了一个关于Hello world
的文字位图然后系统会在一个合适的时机回调给我们一个
drawRect:
的方法,这个方法中我们可以去绘制一些自定义的内容绘制好了之后,最终会由
Core Animation
这个框架提交给GPU部分的OpenGL渲染管线,进行最终的位图的渲染,包括纹理合成等,然后显示在屏幕上
CPU的工作
- Layout阶段: UI布局、文本的计算等
-
Display阶段: 绘制,比如
drawRect:
方法就是发生在这一过程中 -
Prepare阶段 图片的编解码等。比如我们使用到了
UIImageView
对象,对它设置Image对象的时候,默认情况下它是不能直接显示到我们屏幕上面的,需要对图片进行一个解码操作。 -
Commit: 最后这一步经由
Core Animation
框架将最终生成的位图提交给GPU。
GPU渲染管线的工作
UI卡顿、掉帧的原因
我们平时在做性能优化的时候,经常会提到一个指标就是页面帧率达到60fps,至于为什么是60fps,我还真去网上特意查了一下,放到了下面
生成图像的设备(如显卡)与显示图像的设备(如显示器)是分离的。
目前大多数显示器根据其设定按 30Hz、 60Hz、 120Hz 或者 144Hz 的频率进行刷新。 而其中最常见的刷新频率是 60 Hz。 这样做是为了继承以前电视机刷新频率为 60Hz 的设定。
显卡内图片的真正提供者是GPU,由于 GPU 生成图像的频率与显示器刷新的频率是不相关的,那么在显示器刷新时,GPU 没有准备好需要显示的图像怎么办;或者 GPU 的渲染速度过快,显示器来不及刷新,GPU 就已经开始渲染下一帧图像又该如何处理?
如果解决不了这两个问题,就会出现屏幕撕裂(Screen Tearing)现象,即屏幕中一部分显示的是上一帧的内容,另一部分显示的是下一帧的内容。
如何解决屏幕撕裂问题?其中最知名可能也是最古老的解决方案就是 V-Sync 技术
V-Sync 的原理简单而直观:产生屏幕撕裂的原因是显卡在屏幕刷新时进行了渲染,而 V-Sync 通过同步渲染/刷新时间的方式来解决这个问题。显示器的刷新频率为 60 Hz,若此时开启 V-Sync,将控制显卡渲染速度在 60 Hz 以内以匹配显示器刷新频率。这也意味着,在 V-Sync 的限制下,显卡显示性能的极限就限制为 60 Hz 以内
60fps指的是每一秒钟有60帧的画面更新,我们人眼中看到的就是流畅的效果,基于此也就是每间隔1/60
也就是16.7ms
就会产生一帧画面,在这16.7ms
内需要由CPU和GPU协同工作产生这一帧的数据,
卡顿掉帧原因:
如果在这16.7ms
内,也就是在下一帧VSync
信号到来之前,CPU和GPU并没有共同完成下一帧画面的合成,于是那一帧就会被丢弃,也就是掉帧现象,而这时显示屏会保留之前的内容不变,从用户的角度来看就是造成了屏幕卡顿。
基于以上的分析我们可以从CPU和GPU两部分来处理
CPU 资源消耗原因和解决方案
对象创建
对象的创建、设置属性、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择。
如果对象可以复用,并且复用的代价比释放、创建新对象还要小,那么这类对象应当尽量放到一个缓存池里复用。
对象调整
对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer,CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod
为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如frame/bounds/transform
)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。
布局计算
视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这个地方基本就不会产生性能问题了。
不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center
等属性的调整上。上面也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。
当获取到 API JSON 数据后,我会把每条 Cell 需要的数据都在后台线程计算并封装为一个布局对象 CellLayout。CellLayout 包含所有文本的 CoreText 排版结果、Cell 内部每个控件的高度、Cell 的整体高度。每个 CellLayout 的内存占用并不多,所以当生成后,可以全部缓存到内存,以供稍后使用。这样,TableView 在请求各个高度函数时,不会消耗任何多余计算量;当把 CellLayout 设置到 Cell 内部时,Cell 内部也不用再计算布局了。
对于通常的 TableView 来说,提前在后台计算好布局结果是非常重要的一个性能优化点。为了达到最高性能,你可能需要牺牲一些开发速度,不要用 Autolayout 等技术,少用 UILabel 等文本控件。但如果你对性能的要求并不那么高,可以尝试用 TableView 的预估高度的功能,并把每个 Cell 高度缓存下来。这里有个来自百度知道团队的开源项目可以很方便的帮你实现这一点:FDTemplateLayoutCell。
文本渲染
屏幕上能看到的所有文本内容控件,包括 UIWebView,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染。
图像解码
当你用UIImage
或CGImageSource
的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到UIImageView
或者 CALayer.contents
中去,并且 CALayer 被提交到 GPU 前,CGImage
中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext
中,然后从 Bitmap 直接创建图片。目前常见的网络图片库都自带这个功能。
图像的绘制
图像的绘制通常是指用那些以 CG 开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是[UIView drawRect:]
里面了。由于CoreGraphic
方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。一个简单异步绘制的过程大致如下(实际情况会比这个复杂得多,但原理基本一致):
- (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 资源。当在较短时间显示大量图片时(比如 TableView 存在非常多的图片并且快速滑动时),CPU 占用率很低,GPU 占用非常高,界面仍然会掉帧。避免这种情况的方法只能是尽量减少在短时间内大量图片的显示,尽可能将多张图片合成为一张进行显示。
当图片过大,超过 GPU 的最大纹理尺寸时,图片需要先由 CPU 进行预处理,这对 CPU 和 GPU 都会带来额外的资源消耗。目前来说,iPhone 4S 以上机型,纹理尺寸上限都是 4096×4096,更详细的资料可以看这里:iosres.com。所以,尽量不要让图片和视图的大小超过这个值。
视图的混合 (Composing)
当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成。当然,这也可以用上面的方法,把多个视图预先渲染为一张图片来显示
图形的生成
CALayer 的 border、圆角、阴影、遮罩(mask),CASharpLayer
的矢量图形显示,通常会触发离屏渲染(offscreen rendering
),而离屏渲染通常发生在 GPU 中。当一个列表视图中出现大量圆角的 CALayer,并且快速滑动时,可以观察到 GPU 资源已经占满,而 CPU 资源消耗很少。这时界面仍然能正常滑动,但平均帧数会降到很低。为了避免这种情况,可以尝试开启 CALayer.shouldRasterize
属性,但这会把原本离屏渲染的操作转嫁到 CPU 上去。对于只需要圆角的某些场合,也可以用一张已经绘制好的圆角图片覆盖到原本视图上面来模拟相同的视觉效果。最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性。
推荐大家看下面这个博客,写的真是超详细!!!
iOS 保持界面流畅的技巧
离屏渲染
GPU进行屏幕渲染有两种方式
- On-Screen Rendering(在屏渲染):指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区进行
- Off-Screen Rendering (离屏渲染):指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作
何时触发?
当我们指定了UI视图的某些属性,比如圆角、图层蒙版、阴影,光栅化的时候,图层属性的混合体也就是纹理的合成在被指定为在未预合成之前(下一个VSync
信号到来之前)不能直接在屏幕中绘制,所以就需要屏幕外渲染。
为什么要避免离屏渲染?
一般情况下,你需要避免离屏渲染,因为这是很大的消耗。直接将图层合成到帧的缓冲区中(在屏幕上)比先创建屏幕外缓冲区,然后渲染到纹理中,最后将结果渲染到帧的缓冲区中要廉价很多。因为这其中涉及两次昂贵的环境转换(转换环境到屏幕外缓冲区,然后转换环境到帧缓冲区)。
总的来说触发离屏渲染会增加GPU的工作量,从而可能导致CPU和GPU在绘制一帧画面的工作时长总超过了16.7ms,从而导致UI的卡顿和掉帧。
触发场景介绍以及优化
我们可以在真机情况下使用XCode下的 Debug
-> View Debugging
->Rendering
->Color Offscreen-Rendered Yellow
的选项,它会将已经被渲染到屏幕外缓冲区的区域标注为黄色(这个选项在模拟器中也可以用)。
设置圆角
我们看看官方对cornerRadius
的解释:
When positive, the background of the layer will be drawn with rounded corners. Also effects the mask generated by the `masksToBounds' property. Defaults to zero. Animatable.
很明显cornerRadius
只对前景框和背景色起作用,如果Contents
有内容或者内容的背景不是透明的话,只有设置masksToBounds = Yes;
才能起作用,此时两个属性相结合,产生离屏渲染。
- UIView
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(150, 30, 100, 100)];
view.backgroundColor = [UIColor lightGrayColor];
view.layer.cornerRadius = 25.f;
view.layer.borderColor = [UIColor redColor].CGColor;
view.layer.borderWidth = 1;
[self.view addSubview:view];
如上所示,为UIView
单独设置cornerRadius
并没有触发离屏渲染,同时也可以达到圆角的效果。
- UILabel
UIColor *color = [UIColor blueColor];
UILabel *lbl = [[UILabel alloc] initWithFrame:CGRectMake(25, 30, 100, 60)];
lbl.text = @"Developer";
lbl.textColor = color;
lbl.layer.borderColor = color.CGColor;
lbl.layer.borderWidth = 1;
lbl.textAlignment = NSTextAlignmentCenter;
lbl.layer.cornerRadius = 30;
lbl.layer.backgroundColor = [UIColor redColor].CGColor;
[self.view addSubview:lbl];
我们为
UILabel
单独设置cornerRadius
也没有触发离屏渲染,如果我们添加上 lbl.layer.masksToBounds = YES;
之后:很明显此时触发了离屏渲染
- UIImageView
UIImageView *img = [[UIImageView alloc] initWithFrame:CGRectMake(25, 30, 100, 100)];
img.image = [UIImage imageNamed:@"dog"];
img.backgroundColor = [UIColor redColor];//加上背景色会触发离屏渲染
img.layer.cornerRadius = 20;
img.layer.masksToBounds = YES;//必须加上这一句
[self.view addSubview:img];
与UIView
和 UILabel
不同,单独设置img.layer.cornerRadius = 20
是看不出显示圆角效果的,因为图片的image
属性是img.layer
的Contents
部分;只有将layer的masksToBounds
属性也设置为YES,才能绘制出圆角效果。
但是从iOS9
以后苹果对UIImageView
做了一些优化,为图片设置圆角不会在触发离屏渲染了,前提是不要为图片设置背景颜色,相信一般也没有人这么操作。
- UIButton
与UIView
和UILabel
一样,我们可以单独使用layer.cornerRadius
为按钮设置圆角,而不需要设置layer.maskstoBounds
。
但是如果我们为按钮设置了image
或者BackgroundImage
属性的时候就必须结合layer.masksToBounds = YES
圆角才能生效,道理同上,因为image
属性是img.layer
的Contents
部分
如下代码:
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
[btn setTitle:@"确定" forState:UIControlStateNormal];
btn.frame = CGRectMake(25, 30, 100, 40);
btn.layer.cornerRadius = 15;
btn.layer.masksToBounds = YES;
UIImage *img = [self imageWithColor:[UIColor orangeColor] rect:btn.bounds];
[btn setBackgroundImage:img forState:UIControlStateNormal];//此时会触发离屏渲染
[self.view addSubview:btn];
为按钮设置了图片或者是背景图片之后 在进行 layer.masksToBounds = YES
操作就会触发离屏渲染,目前我还没有找到好的解决方法,如果有朋友知道可以告诉我哈~
设置阴影
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(25, 30, 100, 100)];
view.backgroundColor = [UIColor lightGrayColor];
view.layer.shadowColor = [UIColor redColor].CGColor;
view.layer.shadowOffset = CGSizeMake(10, 10);
view.layer.shadowOpacity = 0.3;
[self.view addSubview:view];
但是我们可以为CALayer
的 设置 shadowPath
,添加上下面这句代码后
view.layer.shadowPath = [UIBezierPath bezierPathWithRect:view.bounds].CGPath;
此时就不会产生离屏渲染了