概述:
UIView
是我们在做iOS开发时每天都会接触到的类,几乎所有跟页面显示相关的控件也都继承自它。但是关于UIView
的布局、显示、以及绘制原理等方面笔者一直一知半解,只有真正了解了它的原理才能更好的服务我们的开发。并且在市场对iOS开发者要求越来越高的大环境下,对App页面流畅度的优化也是对高级及以上开发者必问的面试题,这就需要我们要对UIView
有更深的认知。
一.UIView 与 CALayer
UIView
:一个视图(UIView)就是在屏幕上显示的一个矩形块(比如图片,文字或者视频),它能够拦截类似于鼠标点击或者触摸手势等用户输入。视图在层级关系中可以互相嵌套,一个视图可以管理它的所有子视图的位置,在iOS当中,所有的视图都从一个叫做UIView的基类派生而来,UIView可以处理触摸事件,可以支持基于Core Graphics绘图,可以做仿射变换(例如旋转或者缩放),或者简单的类似于滑动或者渐变的动画。
CALayer
:CALayer
类在概念上和UIView
类似,同样也是一些被层级关系树管理的矩形块,同样也可以包含一些内容(像图片,文本或者背景色),管理子图层的位置。它们有一些方法和属性用来做动画和变换。和UIView最大的不同是CALayer不处理用户的交互。
CALayer
并不清楚具体的响应链(iOS通过视图层级关系用来传送触摸事件的机制),于是它并不能够响应事件,即使它提供了一些方法来判断一个触点是否在图层的范围之内。
1. UIView 与 CALayer的关系
每一个UIView
都有一个CALayer
实例的图层属性,也就是所谓的backing layer
,视图的职责就是创建并管理这个图层,以确保当子视图在层级关系中添加或者被移除的时候,他们关联的图层也同样对应在层级关系树当中有相同的操作.
两者的关系:实际上这些背后关联的图层(Layer)才是真正用来在屏幕上显示和做动画,UIView仅仅是对它的一个封装,提供了一些iOS类似于处理触摸的具体功能,以及Core Animation底层方法的高级接口。
这里引申出面试常问的一个问题:为什么iOS要基于UIView和CALayer提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?
原因在于要做职责分离(单一职责原则),这样也能避免很多重复代码。在iOS和Mac OS两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘有着本质的区别,这就是为什么iOS有UIKit
和UIView
,但是Mac OS有AppKit
和NSView
的原因。他们功能上很相似,但是在实现上有着显著的区别。把这种功能的逻辑分开并封装成独立的Core Animation框架,苹果就能够在iOS和Mac OS之间共享代码,使得对苹果自己的OS开发团队和第三方开发者去开发两个平台的应用更加便捷。
2. CALayer的一些常用属性
contents
属性
CALayer
的contents属性可以让我们为layer图层设置一张图片,我们看下它的定义
/* An object providing the contents of the layer, typically a CGImageRef,
* but may be something else. (For example, NSImage objects are
* supported on Mac OS X 10.6 and later.) Default value is nil.
* Animatable. */
@property(nullable, strong) id contents;
这个属性的类型被定义为id,意味着它可以是任何类型的对象。在这种情况下,你可以给contents属性赋任何值,你的app都能够编译通过。但是,如果你给contents赋的不是CGImage,那么你得到的图层将是空白的。事实上,你真正要赋值的类型应该是CGImageRef,它是一个指向CGImage结构的指针,UIImage有一个CGImage属性,它返回一个CGImageRef,但是要使用它还需要进行强转:
layer.contents = (__bridge id _Nullable)(image.CGImage);
contentGravity
属性
/* A string defining how the contents of the layer is mapped into its
* bounds rect. Options are `center', `top', `bottom', `left',
* `right', `topLeft', `topRight', `bottomLeft', `bottomRight',
* `resize', `resizeAspect', `resizeAspectFill'. The default value is
* `resize'. Note that "bottom" always means "Minimum Y" and "top"
* always means "Maximum Y". */
@property(copy) CALayerContentsGravity contentsGravity;
如果我们为图层layer
设置contents为一张图片,那么可以使用这个属性来让图片自适应layer的大小,它类似于UIView的contentMode
属性,但是它是一个NSString类型,而不是像对应的UIKit部分,那里面的值是枚举。contentsGravity可选的常量值有以下一些:
kCAGravityCenter
kCAGravityTop
kCAGravityBottom
kCAGravityLeft
kCAGravityRight
kCAGravityTopLeft
kCAGravityTopRight
kCAGravityBottomLeft
kCAGravityBottomRight
kCAGravityResize
kCAGravityResizeAspect
kCAGravityResizeAspectFill
例如,如果要让图片等比例拉伸去自适应layer的大小可以直接这样设置
layer.contentsGravity = kCAGravityResizeAspect;
contentsScale
属性
/* Defines the scale factor applied to the contents of the layer. If
* the physical size of the contents is '(w, h)' then the logical size
* (i.e. for contentsGravity calculations) is defined as '(w /
* contentsScale, h / contentsScale)'. Applies to both images provided
* explicitly and content provided via -drawInContext: (i.e. if
* contentsScale is two -drawInContext: will draw into a buffer twice
* as large as the layer bounds). Defaults to one. Animatable. */
@property CGFloat contentsScale
contentsScale
属性定义了contents
设置图片的像素尺寸和视图大小的比例,默认情况下它是一个值为1.0的浮点数。这个属性其实属于支持Retina屏幕机制的一部分,它的值等于当前设备的物理尺寸与逻辑尺寸的比值。如果contentsScale设置为1.0,将会以每个点1个像素绘制图片,如果设置为2.0,则会以每个点2个像素绘制图片。当用代码的方式来处理contents
设置图片的时候,一定要手动的设置图层的contentsScale属性,否则图片在Retina设备上就显示得不正确啦。代码如下:
layer.contentsScale = [UIScreen mainScreen].scale;
maskToBounds
属性
maskToBounds
属性的功能类似于UIView的clipsToBounds
属性,如果设置为YES,则会将超出layer范围的图片进行裁剪.
contentsRect
属性
contentsRect
属性在我们的日常开发中用的不多,它的主要作用是可以让我们显示contents
所设置图片的一个子区域。它是单位坐标取值在0到1之间。默认值是{0, 0, 1, 1},这意味着整个图片默认都是可见的,如果我们指定一个小一点的矩形,比如{0,0,0.5,0.5},那么layer显示的只有图片的左上角,也就是1/4的区域。
实际上给layer的contents赋CGImage的值不是唯一的设置其寄宿图的方法。我们也可以直接用Core Graphics直接绘制。通过继承UIView并实现-drawRect:方法来自定义绘制,如果单独使用
CALayer
那么可以实现其代理(CALayerDelegate)方法- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
在这里面进行自主绘制。实际的方法绘制流程我们在下面进行探讨。
二.View的布局与显示
1.图像显示原理
在开始介绍图像的布局与显示之前,我们有必要先了解下图像的显示原理,也就是我们创建一个显示控件是怎么通过CPU与GPU的运算显示在屏幕上的。这个过程大体分为刘六个阶段:
- 布局 :首先一个视图由CPU进行Frame布局,准备视图(view)和图层(layer)的层级关系,以及设置图层属性(位置,背景色,边框)等等。
- 显示:view的显示图层(layer),它的寄宿图片被绘制的阶段。所谓的寄宿图,就是上面我们提到过的layer所显示的内容。它有两种设置形式:一种是直接设置
layer.contents
,赋值一个CGImageRef
;第二种是重写UIView的drawRect:
或CALayerDelegate
的drawLayer:inContext:
方法,实现自定义绘制。注意:如果实现了这两个方法,会额外的消耗CPU的性能。 - 准备:这是Core Animation准备发送数据到渲染服务的阶段。这个阶段主要对视图所用的图片进行解码以及图片的格式转换。PNG或者JPEG压缩之后的图片文件会比同质量的位图小得多。但是在图片绘制到屏幕上之前,必须把它扩展成完整的未解压的尺寸(通常等同于图片宽 x 长 x 4个字节)。为了节省内存,iOS通常直到真正绘制的时候才去解码图片。
- 提交:CPU会将处理视图和图层的层级关系打包,通过IPC(内部处理通信)通道提交给渲染服务,渲染服务由OpenGL ES和GPU组成。
- 生成帧缓存:渲染服务首先将图层数据交给OpenGL ES进行纹理生成和着色,生成前后帧缓存。再根据显示硬件的刷新频率,一般以设备的VSync信号和CADisplayLink为标准,进行前后帧缓存的切换。
- 渲染 :将最终要显示在画面上的后帧缓存交给GPU,进行采集图片和形状,运行变换,应用纹理和混合,最终显示在屏幕上。
注意:当图层被成功打包,发送到渲染服务器之后,CPU仍然要做如下工作:为了显示屏幕上的图层,Core Animation必须对渲染树种的每个可见图层通过OpenGL循环转换成纹理三角板。由于GPU并不知晓Core Animation图层的任何结构,所以必须要由CPU做这些事情。
前四个阶段都在软件层面处理(通过CPU),第五阶段也有CPU参与,只有最后一个完全由GPU执行。而且,你真正能控制只有前两个阶段:布局和显示,Core Animation框架在内部处理剩下的事务,你也控制不了它。所以接下来我们来重点分析布局与显示阶段。
2.布局
布局
:布局就是一个视图在屏幕上的位置与大小。UIView有三个比较重要的布局属性:frame
,bounds
和center
.UIView提供了用来通知系统某个view布局发生变化的方法,也提供了在view布局重新计算后调用的可重写的方法。
layoutSubviews()
方法
layoutSubviews()
:当一个视图“认为”应该重新布局自己的子控件时,它便会自动调用自己的layoutSubviews方法,在该方法中“刷新”子控件的布局.这个方法并没有系统实现,需要我们重新这个方法,在里面实现子控件的重新布局。这个方法很开销很大,因为它会在每个子视图上起作用并且调用它们相应的layoutSubviews
方法.系统会根据当前run loop
的不同状态来触发layoutSubviews
调用的机制,并不需要我们手动调用。以下是他的触发时机:
- 直接修改 view 的大小时会触发
- 调用
addSubview
会触发子视图的layoutSubviews
- 用户在 UIScrollView 上滚动(layoutSubviews 会在
UIScrollView
和它的父view
上被调用) - 用户旋转设备
- 更新视图的 constraints
这些方式都会告知系统view
的位置需要被重新计算,继而会调用layoutSubviews
.当然也可以直接触发layoutSubviews
的方法。
setNeedsLayout()
方法
setNeedsLayout()
方法的调用可以触发layoutSubviews
,调用这个方法代表向系统表示视图的布局需要重新计算。不过调用这个方法只是为当前的视图打了一个脏标记
,告知系统需要在下一次run loop
中重新布局这个视图。也就是调用setNeedsLayout()
后会有一段时间间隔,然后触发layoutSubviews
.当然这个间隔不会对用户造成影响,因为永远不会长到对界面造成卡顿。
layoutIfNeeded()
方法
layoutIfNeeded()
方法的作用是告知系统,当前打了脏标记
的视图需要立即更新,不要等到下一次run loop
到来时在更新,此时该方法会立即触发layoutSubviews
方法。当然但如果你调用了layoutIfNeeded
之后,并且没有任何操作向系统表明需要刷新视图,那么就不会调用layoutsubview
.这个方法在你需要依赖新布局,无法等到下一次 run loop
的时候会比setNeedsLayout
有用。
3.显示
和布局的方法类似,显示也有触发更新的方法,它们由系统在检测到更新时被自动调用,或者我们可以手动调用直接刷新。
drawRect:
方法
在上面我们提到过,如果要设置视图的寄宿图,除了直接设置view.layer.contents
属性,还可以自主进行绘制。绘制的方法就是实现view的drawRect:
方法。这个方法类似于布局的layoutSubviews
方法,它会对当前View的显示进行刷新,不同的是它不会触发后续对视图的子视图方法的调用。跟layoutSubviews
一样,我们不能直接手动调用drawRect:
方法,应该调用间接的触发方法,让系统在 run loop
中的不同结点自动调用。具体的绘制流程我们在本文第三节进行介绍。
setNeedsDisplay()
方法
这个方法类似于布局中的setNeedsLayout
。它会给有内容更新的视图设置一个内部的标记,但在视图重绘之前就会返回。然后在下一个run loop
中,系统会遍历所有已标标记的视图,并调用它们的drawRect:
方法。大部分时候,在视图中更新任何 UI 组件都会把相应的视图标记为“dirty”,通过设置视图“内部更新标记”,在下一次run loop
中就会重绘,而不需要显式的调用setNeedsDisplay
.
三.UIView的系统绘制与异步绘制流程
UIView的绘制流程
接下来我们看下UIView
的绘制流程
- UIView调用setNeedsDisplay,这个方法我们已经介绍过了,它并不会立即开始绘制。
- UIView 调用
setNeedsDisplay
,实际会调用其layer属性的同名方法,此时相当于给layer打上绘制标记。 - 在当前
run loop
将要结束的时候,才会调用CALayer的display方法进入到真正的绘制当中 - 在CALayer的display方法中,会判断
layer
的代理方法displayLayer:
是否被实现,如果代理没有实现这个方法,则进入系统绘制流程,否则进入异步绘制入口。
系统绘制
在系统绘制开始时,在CALayer内部会创建一个绘制上下文,这个上下文可以理解为
CGContextRef
,我们在drawRect:
方法中获取到的currentRef
就是它。-
然后layer会判断是否有delegate,没有delegate就调用
CALayer
的drawInContext
方法,如果有代理,并且你实现了CALayerDelegate协议中的-drawLayer:inContext:
方法或者UIView中的-drawRect:
方法(其实就是前者的包装方法),那么系统就会调用你实现的这两个方法中的一个。关于这里的代理我的理解是:如果你直接使用的UIView,那么layer的代理就是当前view,你直接实现
-drawRect:
,然后在这个方法里面进行自主绘制; 如果你用的是单独创建的CALayer
,那么你需要设置layer.delegate = self;
当然这里的self就是持有layer的视图或是控制器了,这时你需要实现-drawLayer:inContext:
方法,然后在这个方法里面进行绘制。 最后CALayer把位图传给GPU去渲染,也就是将生成的 bitmap 位图赋值给 layer.content 属性。
注意:使用CPU进行绘图的代价昂贵,除非绝对必要,否则你应该避免重绘你的视图。提高绘制性能的秘诀就在于尽量避免去绘制。
异步绘制
什么是异步绘制?
通过上面的介绍我们熟悉了系统绘制流程,系统绘制就是在主线程中进行上下文的创建,控件的自主绘制等,这就导致了主线程频繁的处理UI绘制的工作,如果要绘制的元素过多,过于频繁,就会造成卡顿。而异步绘制就是把复杂的绘制过程放到后台线程中执行,从而减轻主线程负担,来提升UI流畅度。
异步绘制流程
上面很明显的展示了异步绘制过程:
- 从上图看,异步绘制的入口在
layer
的代理方法displayLayer:
,如果要进行异步绘制,我们必须在自定义view中实现这个方法 - 在
displayLayer:
方法中我们开辟子线程 - 在子线程中我们创建绘制上下文,并借助
Core Graphics
相关API完成自主绘制 - 完成绘制后生成Image图片
- 最后回到主线程,把Image图片赋值给layer的contents属性。
当然我们在日常开发中还要考虑线程的管理与绘制时机等问题,使用第三方库YYAsyncLayer
可以让我们把注意力放在具体的绘制上,具体的使用流程可以点这里去查看.
四.总结
我们知道,当我们实现了CALayerDelegate
协议中的-drawLayer:inContext:
方法或者UIView中的-drawRect:
方法,图层就创建了一个绘制上下文,这个上下文需要的大小的内存可从这个算式得出:图层宽X图层高X4字节,宽高的单位均为像素。对于一个在Retina iPad上的全屏图层来说,这个内存量就是 2048X15264字节,相当于12MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。可见使用Core Graphics
利用CPU进行绘制代价是很高的,那么如何进行高效的绘图呢?iOS-Core-Animation-Advanced-Techniques给出了答案,我们在日常开发中完全可以使用Core Animation
的CAShapeLayer
代替Core Graphics
进行图形的绘制,具体的方法这里就不介绍了,感兴趣的可以自行去查看。
参考引用:
iOS-Core-Animation-Advanced-Techniques
YYAsyncLayer
https://juejin.cn/post/6844903567610871816