【重读iOS】认识CALayer

第一次认识到CALayer是在某次面试时被问到“layer跟view是什么关系”,对layer的一些东西也在开发重逐渐了解,但是对于它缺少一个全面的认识,所以对它进行一下全面的挖掘。

layer和view的关系

开始开发都是从view开始,而且很长一段时间可能都只认识到view,而只会在某些角落看见layer,比如圆角,比如coreAnimation动画,还有绘制内容时也使用CALayer,所以对于layer的首要疑问肯定是:这货跟view到底什么关系?

【重读iOS】认识CALayer_第1张图片
出自 WWDC 2012- iOS App Performance- Graphics and Animations.png

来段文档:

Layers provide infrastructure for your views. Specifically, layers make it easier and more efficient to draw and animate the contents of views and maintain high frame rates while doing so. However, there are many things that layers do not do. Layers do not handle events, draw content, participate in the responder chain, or do many other things

  • layer给view提供了基础设施,使得绘制内容和呈现更高效动画更容易、更低耗
  • layer不参与view的事件处理、不参与响应链

思考一下一个view在系统里起了什么作用:就是接受用户点击和呈现内容。上面这段的意思就是layer负责了内容呈现部分的工作,而不参与用户点击事件处理的工作。

很简单很好记,对view的理解也加深了。

内容呈现

知道了layer的工作之后,接下来的疑问就是:内容如何提供?支持哪些内容?怎么呈现的?

翻开CALayer的api,跟内容呈现最相关的几个就是:

  • displaysetNeedsDisplay``displayIfNeeded
  • drawInContext:和delegate里面的drawLayer:inContext:
  • 属性contents
更新机制

第一组3个方法跟view里面的那一组类似,它们是相似的逻辑。首先一个内容在layer上发生改变,比如颜色变了,要让用户立马看到,就需要图形系统重新渲染。再试想一下,有可能同时多个layer在很短的时间内同时要刷新,比如打开一个新的结构复杂viewController,比如快速滑动tableView时,这种场景并不特殊。如果每个layer更新都要系统刷新一遍,那么会导致紊乱的帧率,有时特别卡有时又很闲。

所以机制是反过来的,系统有基本稳定的刷新频率,然后在layer内容改变的时候,把这个layer做个需要刷新的标记,这就是setNeedsDisplay,每次刷新时,把上次刷新之后被标记的layer一次性全部提交给图形系统,所以这里还有一个东西,就是事务(CATransaction)

layer刷新就是被调用display,但这个我们不主动调用,让系统调用,它可以把我更好的时机。我们只需要setNeedsDisplay做标记。如果你真的非常急需,就用displayIfNeeded,对于已被标记为Needed的layer就立马刷新。

既提供了稳定和谐的通用机制,又照顾到了偶然的特殊需求,很好。

内容提供方法

上面只是说了绘制时机的机制,真正的内容绘制在第二组方法里,根据测试,内容提供的机制是这样的:

  • display
  • delegate的displayLayer:
  • drawInContext:
  • delegate的drawLayer:inContext:;

这四个方法,但凡有一个方法实现了,就不会继续往下进行了,就认为你已经提供了内容。delegate的方法要检查delegate是否存在且是否实现了对应的方法。

第1和第2个方法是对应的,第3和第4个方法也是对应的,前面两个没有构建内容缓冲区(Backing Store),需要直接提供contents,一种方法就是直接赋值一个CAImageRef:

layer.contents = [UIImage imageNamed:@"xxx"].CGImage;

后两种方法,会给layer开辟一块内存用来存储绘制的内容,在这两个方法里,可以使用CoreGraphics的那套api来绘制需要的内容。

delegate的作用

从上面还可以搞清楚一个问题,就是layer的delegate的作用:delegate控制layer的内容,这也是为什么UIView自带的layer的delegate是默认指定到view自身的,而也因为这样,绝大多数时候我们直接修改view的属性(颜色位置透明度等等),layer的呈现就自动发生变化了。

layer和动画的关系

在使用CoreAnimation的动画的时候,是把创建的动画放到layer上,而简单的使用动画,很多时候是使用[UIView animation...],那么后者其实本质是内部建了一个动画放到了layer上吗?是的,动画的载体就是layer,这就是它们的基本关系。但为了更高效的动画,还有更多的细节。

如果你做过位移的动画,并且试着在动画的过程里去输出view的位置,你会惊讶的发现:在动画开始后,view的frame就已经是结束位置的值了!

按照常识理解,view的位置应该是随着时间不断变化的,而这个理解上的错差正是理解动画内核的一个好的窗口。

从上面的现象至少可以得出一点:就是你眼睛看到的,跟系统里的数据不是一致的,动画可能是一个欺骗把戏。

看段文档:

Instead, a layer captures the content your app provides and caches it in a bitmap, which is sometimes referred to as the backing store. ... When a change triggers an animation, Core Animation passes the layer’s bitmap and state information to the graphics hardware, which does the work of rendering the bitmap using the new information. Manipulating the bitmap in hardware yields much faster animations than could be done in software.

这段话的含义是:layer的内容生成一个位图(bitmap),触发动画的时候,是把这个动画和状态信息传递给图形硬件,图形硬件使用这两个数据就可以构造动画了。处理位图对于图形硬件更快。

模拟一下动画处理过程就是:一个很复杂的view的动画,是把它的layer的内容合成一张图片,然后要旋转,就是把这张图旋转一下显示出来。实际上图形系统在渲染的过程里,对于旋转、缩放、位移等,只需要加一个矩阵就可以了(对应就是transform),对于图形系统而言这些工作就是最基本的操作,非常高效。

所以动画的呈现和view本身的的数据时分离的,也就出现了动画时看到的都是结束时的数据。

如果按照常识理解去实现动画,是怎么做?

view移动,在界面刷新的方法里,不断的更新view的位置,每次更新完,把数据提供给图形系统,重新绘制。对于有复杂子视图的view,要把整个子视图树都全部重绘。

对比两者,基于layer的欺骗性的动画节省了什么?

  • 不用不断的更新view的数据
  • 不用不断的和图形硬件交互数据
  • 对于复杂的view,不用重绘整个图层树
  • 处理这些对图形硬件更擅长

能这么做的本质原因我觉得还是因为我们需要的动画是程式化的,有模板、有套路的。哪怕是稍微复杂的动画,也可以用关键帧动画来简化,最后还是变成一个个离散独立的数据,按照既定的路线去呈现。如果动画是即时计算出来的,就没法这么干了,比如一个球扔到地上后怎么弹,是根据球的材料重量大小地面坡度等来计算的。

图层树

上面的动画系统,也就催生了layer3种不同的图层树:

  • 模型树(model layer tree),存储了动画的结束值
  • 表现树(presentation tree),包含了动画正在进行中的值
  • 渲染层(render tree),用来表现实际动画的数据,文档无更多说明,应该是跟图形系统相关的数据,比如提供给GPU的bitmap等。

如果要拿到动画过程中view的数据,可以通过表现树来获取。

性能问题

基本就是off-screen离屏渲染的各种问题

1. 圆角

iOS9之后系统已优化,不考虑。解决方案我认为使用layer覆盖层最好,圆角问题本质是mask,看下面mask部分。

2. 阴影,解决方案:加上shadowPath,替换shadowOffset

为什么使用shadowPath可以解决这个问题,我没有找到其他文章说这个,系统文档也只有蛛丝马迹,但根据各方面资料,我做了一个合理的推测。

label的阴影你会发现是跟随文字变化的,而如果label有背景色,阴影就是根据外边框来的。一个imageView,背景色为空,然后使用一个有镂空效果的图片,就会发现阴影是跟着图片那些不透明的那部分来的。

【重读iOS】认识CALayer_第2张图片
文字阴影
【重读iOS】认识CALayer_第3张图片
镂空图片阴影

所以我推断:阴影是根据layer的alpha值来生成的。模拟一下生成的过程:分配一块同样大小的shadowlayer,在原layer的alpha不为0的地方,shadowlayer填上shadowColor,就跟现实里的影子生成原理一样,不透明的部分才生成阴影。然后把这个shadowlayer做一个偏移(shadowOffset)加到原layer下面。

而且这个alpha不是指当前layer的内容,而是当前layer和它所有的子layer合成后的alpha,也就是如果layer上面还是多个子layer,会把这些视图合成到一起,再查看alpha值。用多个imageView错开叠加到一起就可测试出来。

也就是阴影层是根据内容即时计算出来的,而且会触发离屏渲染,所以消耗巨大。

使用shadowPath之后,那么阴影层的形状就固定了,就类似于加了一个subLayer,不会触发离屏渲染。

shadowPath的注释:

If you specify a value for this property, the layer creates its shadow using the specified path instead of the layer’s composited alpha channel

这里的composited就是指当前layer和所有子layer混合后的结果。有了上面的解释,这句话应该就明白了。

注:在iPhone6上还会卡顿,在8和X上已经很流畅了

3. mask

直接使用CALayermask属性会导致离屏渲染,查看注释

A layer whose alpha channel is used as a mask to select between the layer's background and the result of compositing the layer's contents with its filtered background

mask作用的也不只是当前layer的内容,而是layer和它所有子layer的合成内容。这个也是可以测试的,设置viewA的layer的mask,然后不管在viewA上加多少个视图都是会被mask作用到。

解决方案是,添加一层layer在最上层来实现蒙版。mask的效果是,alpha>0的部分,内容可以透出来,而为0的部分,内容完全遮蔽。

可以添加一个alpha正好相反的maskLayer2在最上层,根据混合效果,maskLayer2的alpha为0的地方内容可以透出来,对应就是原maskalpha>0的地方,也是内容可以透过来的地方。

唯一的麻烦就是对于内容变化的视图,添加一个新视图后,新视图的内容会跑到maskLayer2的上面,对这个新视图就没有蒙版效果了。

圆角的解决方案之一就是这个,之前圆角的本质也是添加了mask,从而导致的离屏渲染。

4. shouldRasterize光栅化

这个也是比说的,从前面的几个性能问题里可以看出,性能问题主要因为两点:1.离屏渲染 2.对复杂layer图层每次都要重新计算合成内容

光栅化的优化是针对后一个问题的,比如有10个视图,互相叠加在一起,每次都要计算叠加都得内容,开启这个效果后,就把计算后的内容生成一张位图(bitmap),之后渲染引擎会缓存和重用这个位图,而避免重新计算。

举个例子:前者就类似你要告诉一个人手机长什么样子,然后你造了一台手机给他看,每介绍给一个人你就要造一个手机;后者类似你把手机造好了之后拍了一张照,然后每次要介绍给别人,就给它看这个照片就好了。

缺点就是,如果样式是不断变化的,重用效果就会降低,而且存储位图会增加内存消耗。

实际测试:在tableView的cell上面添加文字的阴影,然后文字是随机变化的。阴影会导致离屏渲染,而文字的阴影又无法使用shadowPath来指定,所以会卡顿明显。

  • 开启shouldRasterize之后效果显著。
  • 文字是不是变化并没有区别,可能shouldRasterize的重用和变化的概念和内容上的变化并不是一个意思。对于tableView而言,新的cell都是没得到重用的,在测试工具里显示都是红色
  • 如果view开启maskToBounds,效果很差。虽然仍然只是新的cell得不到重用。只能说mask带来的性能消耗太大
关于离屏渲染的猜测

经过上面几个触发离屏渲染的属性的认知,发现一个共性,就是它们都需要layer和它的子图层树合成后的结果。mask是这样,阴影也是这样,开启shouldRasterize之后也是这样。

假设正常的内容是A,然后渲染出图形GA,然后你要加一个B内容,那么就是把内容A和B的结果做一个混合(blend)就好了。

但是如果B的内容是基于A呢?你必须先把A渲染出来,才能去生成B,那么在生成B的时候A存放在哪里?这就需要开辟一块新的缓冲区(frame buffer),把A的结果输出到这个地方,而不能够直接输出到屏幕。然后在那个新的环境(context),把A和B合成结束在切回到原来的context,在输出到屏幕。

这就是我对离屏渲染流程和原因的猜测。

更新1:

这里有个动画是基于CAShapeLayer的,动画调整的属性是strokeStartstrokeEnd,就是一个路径只绘制指定的一部分,不断修改这一部分形成动画。这个跟之前的layer形成bitmap传给图形系统再构建动画有冲突,因为形成bitmap后路径数据就丢失了,不可能通过图片+额外的简单数据形成这个动画。最可能的是CAShapeLayer根据自身路径和strokeStart``strokeEnd两个属性计算顶点(vertex)数据,然后传给图形系统绘制。不断修改属性,不断绘制。这可能是CAShapeLayer针对自身做的特殊处理,所以会跟CALayer的说法不一致。

你可能感兴趣的:(【重读iOS】认识CALayer)