图层树
UIView和CALayer的关系
CALayer
类在概念上和UIView
类似,同样也是一些被层级关系树管理的矩形块,同样也可以包含一些内容(像图片,文本或者背景色),管理子图层的位置。
它们有一些方法和属性用来做动画和变换。和UIView
最大的不同是CALayer
不处理用户的交互。但是它提供了一些方法来判断是否一个触点在图层的范围之内。
每一个UIView
都有一个CALayer
实例的图层属性,也就是所谓的backing layer
,视图的职责就是创建并管理这个图层。
这些背后关联的图层才是真正用来在屏幕上显示和做动画,UIView
仅仅是对它的一个封装,提供了一些iOS类似于处理触摸的具体功能,以及Core Animation
底层方法的高级接口。
CALayer的能力
有一些UIView
没有暴露出来的CALayer
的功能:
- 阴影,圆角,带颜色的边框
- 3D变换
- 非矩形范围
- 透明遮罩
- 多级非线性动画
寄宿图
contents
CALayer
有一个属性叫做contents
,虽然是id类型,但是如果赋值不止CGImage
,图层就会是空白的。
所以被定义为id类型,是因为在Mac OS系统上,这个属性对
CGImage
和NSImage
类型的值都起作用。
事实上,真正要赋值的类型应该是CGImageRef
,它是一个指向CGImage
结构的指针。UIImage
有一个CGImage
属性,它返回一个”CGImageRef
“,如果你想把这个值直接赋值给CALayer
的contents
,那你将会得到一个编译错误。因为CGImageRef
并不是一个真正的Cocoa对象,而是一个Core Foundation类型。
1 | layer.contents = (__bridge id)image.CGImage; |
其他属性:
contentGravity
:设置拉伸方式,对应UIView
的contentMode
contentsScale
:定义寄宿图的像素尺寸和试图大小的比例,默认为1.0
,一般设置为[UIScreen mainScreen].scale;
1 | layerView.layer.contentsGravity = .center |
maskToBounds
:是否绘制超过边界的内容,对应UIView
的clipsToBounds
;contentsRect
:在图层边框里面显示寄宿图的子区域,使用单位坐标,默认为{0, 0, 1, 1}
该属性可用于图片拼合,可以打包真核一张大图一次性载入,相比多次载入不同的图片,在内存使用、载入时间、渲染性能能会有更好表现
1 | func addSpriteImage(img:UIImage, rect:CGRect, superLayer:CALayer) { |
contentsCenter
:定义了一个固定的边框和一个在图层上可拉伸的区域,默认为{0, 0, 1, 1}
,类似resizableImageWithCapInsets
Custom Drawing
可以直接用Core Graphics
直接绘制寄宿图。能够通过继承UIView并实现-drawRect:
方法来自定义绘制。
-drawRect:
方法没有默认的实现,因为对UIView
来说,寄宿图并不是必须的,。如果UIView
检测到-drawRect:
方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以 contentsScale
的值。
如果你不需要寄宿图,那就不要创建这个方法了,这会造成CPU资源和内存的浪费
当视图在屏幕上出现的时候 -drawRect:
方法就会被自动调用。-drawRect:
方法里面的代码利用Core Graphics
去绘制一个寄宿图,然后内容就会被缓存起来直到它需要被更新(通常是因为开发者调用了-setNeedsDisplay
方法,尽管影响到表现效果的属性值被更改时,一些视图类型会被自动重绘,如bounds
属性)。虽然-drawRect:
方法是一个UIView
方法,事实上都是底层的CALayer安排了重绘工作和保存了因此产生的图片。
CALayer
有一个可选的delegate
属性,实现了CALayerDelegate
协议,当CALayer
需要一个内容特定的信息时,就会从协议中请求。
当需要被重绘时,CALayer会请求它的代理给他一个寄宿图来显示。它通过调用下面这个方法做到的:
1 | open func display() |
代理想直接设置contents
属性的话,它就可以这么做,不然没有别的方法可以调用了。如果代理不实现-displayLayer:
方法,CALayer
就会转而尝试调用下面这个方法:
1 | func draw(_ layer: CALayer, in ctx: CGContext); |
CALayer
创建了一个合适尺寸的空寄宿图(尺寸由bounds
和contentsScale
决定)和一个Core Graphics
的绘制上下文环境,为绘制寄宿图做准备,他作为ctx
参数传入。
1 | class CustomDrawingController: UIViewController, CALayerDelegate { |
需要在
blueLayer
上显式调用-display
,当图层显示在屏幕上时,CALayer
不会自动重绘它的内容
当使用
CALayerDelegate
绘制寄宿图的时候,并没有对超出边界外的内容提供绘制支持。
图层几何学
布局
UIView
有三个比较重要的布局属性:frame
,bounds
和center
,CALayer对应地叫做frame
,bounds
和position
。为了能清楚区分,图层用了“position
”,视图用了“center
”,但是他们都代表同样的值。
UIView的frame
,bounds
和center
属性仅仅是存取方法,当操纵视图的frame
,实际上是在改变位于视图下方CALayer的frame
,不能够独立于图层之外改变视图的frame
。
frame
代表了图层在父图层上占据的空间,bounds是
内部坐标({0, 0}
通常是图层的左上角)center
和position
都代表了相对于父图层anchorPoint
所在的位置。
当对图层做变换的时候,比如旋转或者缩放,
frame
实际上代表了覆盖在图层旋转之后的整个轴对齐的矩形区域,也就是说frame
的宽高可能和bounds
的宽高不再一致了。
锚点
anchorPoint
默认位于图层中点,所以图层的将会以这个点为中心放置。这也是视图的position
属性被叫做“center
”的原因。
anchorPoint
使用单位坐标,默认{0.5,0.5}
。
但是图层的anchorPoint
可以被移动,比如可以把它置于图层frame
的左上角,于是图层的内容将会向右下角的position
方向移动,而不是居中了。
坐标系
一个图层的position
依赖于它父图层的bounds
,如果父图层发生了移动,它的所有子图层也会跟着移动。
CALayer给不同坐标系之间的图层转换提供了一些工具类方法:
1 | - (CGPoint)convertPoint:(CGPoint)point fromLayer:(CALayer *)layer; |
翻转的几何结构
常规说来,在iOS上,一个图层的position
位于父图层的左上角,但是在Mac OS上,通常是位于左下角。Core Animation
可以通过geometryFlipped
属性来适配这两种情况,它决定了一个图层的坐标是否相对于父图层垂直翻转,是一个BOOL
类型。在iOS上通过设置它为YES意味着它的子图层将会被垂直翻转,也就是将会沿着底部排版而不是通常的顶部(它的所有子图层也同理,除非把它们的geometryFlipped
属性也设为YES
)。
Z坐标轴
CALayer
有另外两个属性,zPosition
和anchorPointZ
,二者都是在Z轴上描述图层位置的浮点类型。
通常,图层是根据它们子图层的sublayers
出现的顺序来类绘制的,后被绘制上的图层将会遮盖住之前的图层,但是通过增加图层的zPosition
,就可以把图层前置,于是它就在所有其他图层的前面了(或者至少是小于它的zPosition
值的图层的前面)。
Hit Testing
CALayer
并不关心任何响应链事件,所以不能直接处理触摸事件或者手势。但是它有一系列的方法可以处理事件:-containsPoint:
和-hitTest:
。
-containsPoint:
接受一个在本图层坐标系下的CGPoint
,如果这个点在图层frame
范围内就返回YES。-hitTest:
方法同样接受一个CGPoint
类型参数,而不是BOOL
类型,它返回图层本身,或者包含这个坐标点的叶子节点图层。
注意当调用图层的
-hitTest:
方法时,返回的顺序严格依赖于图层树当中的图层顺序(和UIView
处理事件类似)。之前提到的zPosition
属性可以明显改变屏幕上图层的顺序,但不能改变事件传递的顺序。
自动布局
如果想随意控制CALayer
的布局,就需要手工操作。最简单的方法就是使用CALayerDelegate
如下函数:
1 | - (void)layoutSublayersOfLayer:(CALayer *)layer; |
当图层的bounds
发生改变,或者图层的-setNeedsLayout
方法被调用的时候,这个函数将会被执行。可以手动地重新摆放或者重新调整子图层的大小,但是不能像UIView
的autoresizingMask
和constraints
属性做到自适应屏幕旋转。
视觉效果
阴影
shadowOpacity
:是一个必须在0.0(不可见)和1.0(完全不透明)之间的浮点数。如果设置为1.0,将会显示一个有轻微模糊的黑色阴影稍微在图层之上。shadowOffset
:控制着阴影的方向和距离。它是一个CGSize
的值,宽度控制这阴影横向的位移,高度控制着纵向的位移。shadowOffset
的默认值是 {0, -3},意即阴影相对于Y轴有3个点的向上位移。shadowRadius
:控制着阴影的模糊度,当它的值是0的时候,阴影就和视图一样有一个非常确定的边界线。当值越来越大的时候,边界线看上去就会越来越模糊和自然。shadowPath
:实时计算阴影也是一个非常消耗资源的,指定一个shadowPath
来提高性能
阴影裁剪:阴影是根据寄宿图的轮廓来确定的,而不是根据边界和角半径来确定。
shouldRasterize
:如果它被设置为YES
,在应用透明度之前,图层及其子图层都会被整合成一个整体的图片,这样就没有透明度混合的问题了。
变换
仿射变换
主要是2D变换,主要API:
1 | CGAffineTransformMakeRotation(CGFloat angle) //旋转 |
3D变换
1 | CATransform3DMakeRotation(CGFloat angle, CGFloat x, CGFloat y, CGFloat z) |
X轴和Y轴分别以右和下为正方向(这是iOS上的标准结构,在Mac OS,Y轴朝上为正方向),Z轴和这两个轴分别垂直,指向视角外为正方向。
旋转方向确定:左手点赞,大拇指朝向箭头方向,握手指的方向即为旋转方向
透视投影<重点>
CATransform3D
的透视效果通过一个矩阵中一个很简单的元素来控制:m34
。m34
用于按比例缩放X和Y的值来计算到底要离视角多远。
m34
的默认值是0,可以通过设置m34为-1.0 /d
来应用透视效果,d代表了想象中视角相机和屏幕之间的距离,以像素为单位,不需要精细计算,通常500-1000就已经很好了,但对于特定的图层有时候更小后者更大的值会看起来更舒服,减少距离的值会增强透视效果,所以一个非常微小的值会让它看起来更加失真,然而一个非常大的值会让它基本失去透视效果。
解决了图层看起来并没有被旋转,而是仅仅在水平方向上的一个压缩。
1 | //create a new transform |
灭点<重点>
当在透视角度绘图的时候,远离相机视角的物体将会变小变远,当远离到一个极限距离,它们可能就缩成了一个点,于是所有的物体最后都汇聚消失在同一个点。
为了在应用中创建拟真效果的透视,这个点应该聚在屏幕中点,或者至少是包含所有3D对象的视图中点。
Core Animation
定义了这个点位于变换图层的anchorPoint
(通常位于图层中心,但也有例外)。这就是说,当图层发生变换时,这个点永远位于图层变换之前anchorPoint
的位置。
当改变一个图层的position
,也改变了它的灭点,做3D变换的时候要时刻记住这一点,当视图通过调整m34
来让它更加有3D效果,应该首先把它放置于屏幕中央,然后通过平移来把它移动到指定位置(而不是直接改变它的position
),这样所有的3D图层都共享一个灭点。
sublayerTransform 属性<重点>
sublayerTransform
是CATransform3D
类型,但和对一个图层的变换不同,它影响到所有的子图层。这意味着可以一次性对包含这些图层的容器做变换,于是所有的子图层都自动继承了这个变换方法。
用于解决有多个视图或者图层,每个都做3D变换,那就需要分别设置相同的m34值,并且确保在变换之前都在屏幕中央共享同一个
position
。
灭点被设置在容器图层的中点,从而不需要再对子图层分别设置了。这意味着你可以随意使用
position
和frame
来放置子图层,而不需要把它们放置在屏幕中点,然后为了保证统一的灭点用变换来做平移。
背面
图层是双面绘制的,反面显示的是正面的一个镜像图片。
但这并不是一个很好的特性,因为如果图层包含文本或者其他控件,那用户看到这些内容的镜像图片当然会感到困惑。另外也有可能造成资源的浪费:想象用这些图层形成一个不透明的固态立方体,既然永远都看不见这些图层的背面,那为什么浪费GPU来绘制它们呢?
CALayer
有一个叫做doubleSided
的属性来控制图层的背面是否要被绘制。这是一个BOOL类型,默认为YES,如果设置为NO,那么当图层正面从相机视角消失的时候,它将不会被绘制。
扁平化图层
由于尽管Core Animation
图层存在于3D空间之内,但它们并不都存在同一个3D空间。每个图层的3D场景其实是扁平化的,从正面观察一个图层,看到的实际上由子图层创建的想象出来的3D场景,但当倾斜这个图层,实际上这个3D场景仅仅是被绘制在图层的表面。
当在玩一个3D游戏,实际上仅仅是把屏幕做了一次倾斜,或许在游戏中可以看见有一面墙在你面前,但是倾斜屏幕并不能够看见墙里面的东西。所有场景里面绘制的东西并不会随着观察它的角度改变而发生变化;图层也是同样的道理。
固体对象
用六个独立的视图来构建一个立方体的各个面,不需要不关心在这个容器中如何摆放它们的位置,因为后续将会用图层的transform
对它们进行重新布局。
如果需要动态地创建光线效果,可以根据每个视图的方向应用不同的alpha
值做出半透明的阴影图层,但为了计算阴影图层的不透明度,需要得到每个面的正太向量(垂直于表面的向量),然后根据一个想象的光源计算出两个向量叉乘结果。叉乘代表了光源和图层之间的角度,从而决定了它有多大程度上的光亮。
专用图层
CAShapeLayer
CAShapeLayer
是一个通过矢量图形而不是bitmap
来绘制的图层子类。指定诸如颜色和线宽等属性,用CGPath
来定义想要绘制的图形,最后CAShapeLayer
就自动渲染出来了。也可以用Core Graphics
直接向原始的CALyer的内容中绘制一个路径,使用CAShapeLayer
有以下一些优点:
- 渲染快速。
CAShapeLayer
使用了硬件加速,绘制同一图形会比用Core Graphics
快很多。 - 高效使用内存。一个
CAShapeLayer
不需要像普通CALayer
一样创建一个寄宿图形,所以无论有多大,都不会占用太多的内存。 - 不会被图层边界剪裁掉。一个
CAShapeLayer
可以在边界之外绘制。你的图层路径不会像在使用Core Graphics
的普通CALayer
一样被剪裁掉。 - 不会出现像素化。当你给
CAShapeLayer
做3D变换时,它不像一个有寄宿图的普通图层一样变得像素化。
CATextLayer
CATextLayer
它以图层的形式包含了UILabel
几乎所有的绘制特性,并且额外提供了一些新的特性。
1 | let containerView = UIView() |
CATransformLayer
Core Animation
图层可以在2D环境下做出这样的层级体系下的变换,但是3D情况下就不太可能,因为所有的图层都把他的子图层都平面化到一个场景中。
CATransformLayer
解决了这个问题,CATransformLayer
不同于普通的CALayer,因为它不能显示它自己的内容。只有当存在了一个能作用域子图层的变换它才真正存在。CATransformLayer
并不平面化它的子图层,所以它能够用于构造一个层级的3D结构,
1 | let cube = CATransformLayer() |
CAGradientLayer
CAGradientLayer
是用来生成两种或更多颜色平滑渐变的,真正好处在于绘制使用了硬件加速
。
startPoint
和endPoint
:决定了渐变的方向。这两个参数是以单位坐标系进行的定义,所以左上角坐标是{0, 0},右下角坐标是{1, 1}。locations
:调整颜色空间,需要和colors
长度相同
1 | let gradientLayer = CAGradientLayer() |
CAReplicatorLayer
CAReplicatorLayer
的目的是为了高效生成许多相似的图层。它会绘制一个或多个图层的子图层,并在每个复制体上应用不同的变换。
CAReplicatorlayer
真正应用到实际程序上的场景比如:一个游戏中导弹的轨迹云,或者粒子爆炸。还有一个实际的用处反射。
1 | let replicator = CAReplicatorLayer() |
1 | /** |
CAScrollLayer
CAScrollLayer
可以显示一个大图层里面的一小部分,通过-scrollToPoint:
可以实现支持滑动。
Core Animation
并不处理用户输入,所以CAScrollLayer
并不负责将触摸事件转换为滑动事件,既不渲染滚动条,也不实现任何iOS指定行为例如滑动反弹。
主要是下面两个方法:
-scrollToPoint:
从图层树中查找并找到第一个可用的CAScrollLayer
,然后滑动它使得指定点成为可视的。-scrollRectToVisible:
:实现了同样的事情只不过是作用在一个矩形上的。visibleRect
属性决定图层(如果存在的话)的哪部分是当前的可视区域。
1 | /* |
滑动视图类并没有实现任何形式的边界检查(
bounds checking
)。图层内容极有可能滑出视图的边界并无限滑下去。
CAScrollLayer
并没有等同于UIScrollView
中contentSize
的属性,所以当CAScrollLayer
滑动的时候完全没有一个全局的可滑动区域的概念,也无法自适应它的边界原点至你指定的值。它之所以不能自适应边界大小是因为它不需要,内容完全可以超过边界。
CATiledLayer
在渲染大内存图片会遇到以下问题:
UIImage
的-imageNamed:
方法或者-imageWithContentsOfFile:
方法)将会阻塞用户界面,至少会引起动画卡顿现象。OpenGL
有一个最大的纹理尺寸(通常是2048x2048,或4096x4096,这个取决于设备型号)。如果在单个纹理中显示一个比这大的图,即便图片已经存在于内存中了,仍然会遇到很大的性能问题,因为Core Animation
强制用CPU处理图片而不是更快的GPU。
CATiledLayer
为载入大图造成的性能问题提供了一个解决方案:将大图分解成小片然后将他们单独按需载入
。
1 | let tileLayer = CATiledLayer() |
1 | let layer = layer as! CATiledLayer |
CAEmitterLayer
CAEmitterLayer
是一个高性能的粒子引擎,被用来创建实时例子动画如:烟雾,火,雨等等这些效果。
CAEmitterLayer
看上去像是许多CAEmitterCell
的容器,这些CAEmitierCell
定义了一个粒子效果。你将会为不同的例子效果定义一个或多个CAEmitterCell
作为模版,同时CAEmitterLayer
负责基于这些模版实例化一个粒子流。一个CAEmitterCell
类似于一个CALayer
:它有一个contents
属性可以定义为一个CGImage
,另外还有一些可设置属性控制着表现和行为。
1 | let emitter = CAEmitterLayer() |
CAEAGLLayer
OpenGL
提供了Core Animation
的基础,它是底层的C接口,直接和iPhone,iPad的硬件通信,极少地抽象出来的方法。OpenGL
没有对象或是图层的继承概念。它只是简单地处理三角形。OpenGL
中所有东西都是3D空间中有颜色和纹理的三角形。
为了能够以高性能使用Core Animation
,需要判断你需要绘制哪种内容(矢量图形,例子,文本,等等),但后选择合适的图层去呈现这些内容,Core Animation
中只有一些类型的内容是被高度优化的;所以如果绘制的东西并不能找到标准的图层类,高性能就很难实现。
因为OpenGL
根本不会对内容进行假设,所以很多游戏都喜欢用OpenGL
(这些情况下,Core Animation
的限制就明显了:它优化过的内容类型并不一定能满足需求),但是这样依赖,方便的高度抽象接口就没了。
在iOS 5中,苹果引入了一个新的框架叫做GLKit
,它去掉了一些设置OpenGL
的复杂性,提供了一个叫做CLKView
的UIView
的子类,帮助处理大部分的设置和绘制工作。前提是各种各样的OpenGL
绘图缓冲的底层可配置项仍然需要用CAEAGLLayer
完成,它是CALayer
的一个子类,用来显示任意的OpenGL
图形。
AVPlayerLayer
AVPlayerLayer
的使用相当简单:你可以用+playerLayerWithPlayer:
方法创建一个已经绑定了视频播放器的图层,或者你可以先创建一个图层,然后用player
属性绑定一个AVPlayer
实例。
因为AVPlayerLayer
是CALayer
的子类,它继承了父类的所有特性。并不会受限于要在一个矩形中播放视频,可以增加圆角、边框甚至3D变换。
隐式动画
事务
Core Animation
基于一个假设,说屏幕上的任何东西都可以(或者可能)做动画。动画并不需要在Core Animation
中手动打开,相反需要明确地关闭,否则他会一直存在。
所谓的隐式动画指并没有指定任何动画的类型。仅仅改变了一个属性,然后Core Animation
来决定如何并且何时去做动画。
但当改变一个属性,Core Animation
是如何判断动画类型和持续时间的呢?实际上动画执行的时间取决于当前事务的设置,动画类型取决于图层行为。
事务实际上是Core Animation
用来包含一系列属性动画集合的机制,任何用指定事务去改变可以做动画的图层属性都不会立刻发生变化,而是当事务一旦提交的时候开始用一个动画过渡到新值。
事务是通过CATransaction
类来做管理,这个类的设计有些奇怪,不像你从它的命名预期的那样去管理一个简单的事务,而是管理了一叠你不能访问的事务。CATransaction
没有属性或者实例方法,并且也不能用+alloc
和-init
方法创建它。但是可以用+begin
和+commit
分别来入栈或者出栈。
任何可以做动画的图层属性都会被添加到栈顶的事务,可以通过+setAnimationDuration:
方法设置当前事务的动画时间,或者通过+animationDuration
方法来获取值(默认0.25秒)。
Core Animation
在每个run loop
周期中自动开始一次新的事务,即使不显式的用[CATransaction begin]
开始一次事务,任何在一次run loop
循环中属性的改变都会被集中起来,然后做一次0.25秒的动画。
CATransaction
的+begin
和+commit
方法在+animateWithDuration:animations:
内部自动调用,这样block中所有属性的改变都会被事务所包含。这样也可以避免开发者由于对+begin
和+commit
匹配的失误造成的风险。
完成回调
基于UIView
的block
的动画允许你在动画结束的时候提供一个完成的动作。CATranscation
接口提供的+setCompletionBlock:
方法也有同样的功能。
1 | CATransaction.begin() |
注意旋转动画要比颜色渐变快得多,这是因为完成块是在颜色渐变的事务提交并出栈之后才被执行,于是,用默认的事务做变换,默认的时间也就变成了0.25秒。
隐式动画实现<重要>
Core Animation
通常对CALayer
的所有属性(可动画的属性)做动画,但是UIView
把它关联的图层的这个特性关闭了。
把改变属性时CALayer
自动应用的动画称作行为,当CALayer
的属性被修改时候,它会调用-actionForKey:
方法,传递属性的名称。剩下的操作都在CALayer
的头文件中有详细的说明,实质上是如下几步:
- 图层首先检测它是否有委托,并且是否实现
CALayerDelegate
协议指定的-actionForLayer:forKey
方法。如果有,直接调用并返回结果。 - 如果没有委托,或者委托没有实现
-actionForLayer:forKey
方法,图层接着检查包含属性名称对应行为映射的actions
字典。 - 如果
actions字典
没有包含对应的属性,那么图层接着在它的style
字典接着搜索属性名。 - 最后,如果在
style
里面也找不到对应的行为,那么图层将会直接调用定义了每个属性的标准行为的-defaultActionForKey:
方法。
所以一轮完整的搜索结束之后,-actionForKey:
要么返回空(这种情况下将不会有动画发生),要么是CAAction
协议对应的对象,最后CALayer
拿这个结果去对先前和当前的值做动画。
于是这就解释了UIKit是如何禁用隐式动画的:每个UIView
对它关联的图层都扮演了一个委托,并且提供了-actionForLayer:forKey
的实现方法。当不在一个动画块的实现中,UIView
对所有图层行为返回nil
,但是在动画block范围之内,它就返回了一个非空值。
当属性在动画块之外发生改变,UIView
直接通过返回nil
来禁用隐式动画。但如果在动画块范围之内,根据动画具体类型返回相应的属性。
返回nil
并不是禁用隐式动画唯一的办法,CATransacition
有个方法叫做+setDisableActions:
,可以用来对所有属性打开或者关闭隐式动画。如果在[CATransaction begin]
之后添加下面的代码,同样也会阻止动画的发生:
CATransaction.setDisableActions(true)
UIView
关联的图层禁用了隐式动画,对这种图层做动画的唯一办法就是使用UIView
的动画函数(而不是依赖CATransaction
),或者继承UIView
,并覆盖-actionForLayer:forKey:
方法,或者直接创建一个显式动画。- 对于单独存在的图层,可以通过实现图层的
-actionForLayer:forKey:
委托方法,或者提供一个actions
字典来控制隐式动画。
自定义隐式动画:
1 |
|
呈现与模型
CALayer
的属性行为其实很不正常,因为改变一个图层的属性并没有立刻生效,而是通过一段时间渐变更新。
当设置CALayer
的属性,实际上是在定义当前事务结束之后图层如何显示的模型。Core Animation扮演了一个控制器的角色,并且负责根据图层行为和事务设置去不断更新视图的这些属性在屏幕上的状态。
CALayer
是一个连接用户界面(就是MVC中的view)虚构的类,但是在界面本身这个场景下,CALayer
的行为更像是存储了视图如何显示和动画的数据模型。
在iOS中,屏幕每秒钟重绘60次。如果动画时长比60分之一秒要长,Core Animation就需要在设置一次新值和新值生效之间,对屏幕上的图层进行重新组织。这意味着CALayer
除了“真实”值(就是你设置的值)之外,必须要知道当前显示在屏幕上的属性值的记录。
每个图层属性的显示值都被存储在一个叫做呈现图层的独立图层当中,他可以通过-presentationLayer
方法来访问。这个呈现图层实际上是模型图层的复制,但是它的属性值代表了在任何指定时刻当前外观效果。
呈现树通过图层树中所有图层的呈现图层所形成。注意呈现图层仅仅当图层首次被提交(就是首次第一次在屏幕上显示)的时候创建,所以在那之前调用-presentationLayer
将会返回nil
。
有一个叫做–modelLayer
的方法。在呈现图层上调用–modelLayer
将会返回它正在呈现所依赖的CALayer
。通常在一个图层上调用-modelLayer
会返回–self
(实际上已经创建的原始图层就是一种数据模型)。
大多数情况下,不需要直接访问呈现图层,你可以通过和模型图层的交互,来让Core Animation更新显示。两种情况下呈现图层会变得很有用,一个是同步动画,一个是处理用户交互。
- 如果实现一个基于定时器的动画,而不仅仅是基于事务的动画,这个时候准确地知道在某一时刻图层显示在什么位置就会对正确摆放图层很有用了。
- 如果想让做动画的图层响应用户输入,你可以使用
-hitTest:
方法来判断指定图层是否被触摸,这时候对呈现图层而不是模型图层调用-hitTest:
会显得更有意义,因为呈现图层代表了用户当前看到的图层位置,而不是当前动画结束之后的位置。
1 | override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { |
显式动画
属性动画
当更新属性的时候,我们需要设置一个新的事务,并且禁用图层行为。否则动画会发生两次,一个是因为显式的CABasicAnimation
,另一次是因为隐式动画。
1 | @objc func basicAnimation() { |
对CAAnimation
而言,使用委托模式而不是一个完成块会带来一个问题,就是当有多个动画的时候,无法在在回调方法中区分。
动画本身会作为一个参数传入委托的方法,也许可以控制器中把动画存储为一个属性,然后在回调用比较,但实际上并不起作用,因为委托传入的动画参数是原始值的一个深拷贝,从而不是同一个值。
像所有的NSObject
子类一样,CAAnimation
实现了KVC(键-值-编码)协议,可以用-setValue:forKey:
和-valueForKey:
方法来存取属性。但是CAAnimation
有一个不同的性能:它更像一个NSDictionary
,可以随意设置键值对,即使使用的动画类所声明的属性并不匹配。这意味着你可以对动画用任意类型打标签。
在
-animationDidStop:finished:
委托方法调用之前,指针会迅速返回到原始值,可以用一个fillMode
属性来解决这个问题。
关键帧动画
CAKeyframeAnimation
同样是CAPropertyAnimation
的一个子类,它依然作用于单一的一个属性,但是和CABasicAnimation
不一样的是,它不限制于设置一个起始和结束的值,而是可以根据一连串随意的值来做动画。
1 | @objc func keyframeAnimation() { |
注意到序列中开始和结束的颜色都是蓝色,这是因为CAKeyframeAnimation
并不能自动把当前值作为第一帧(就像CABasicAnimation
那样把fromValue
设为nil
)。动画会在开始的时候突然跳转到第一帧的值,然后在动画结束的时候突然恢复到原始的值。所以为了动画的平滑特性,需要开始和结束的关键帧来匹配当前属性的值。
当然可以创建一个结束和开始值不同的动画,那样的话就需要在动画启动之前手动更新属性和最后一帧的值保持一致,就和之前讨论的一样。
通过贝塞尔曲线对图层做动画:
1 | // 贝塞尔曲线动画 |
虚拟属性
属性动画实际上是针对于关键路径而不是一个键,这就意味着可以对子属性甚至是虚拟属性做动画。
考虑一个旋转的动画:如果想要对一个物体做旋转的动画,那就需要作用于transform
属性,因为CALayer
没有显式提供角度或者方向之类的属性。
为了旋转图层,我们可以对transform.rotation
关键路径应用动画,而不是transform
本身。
用transform.rotation
而不是transform
做动画的好处如下:
- 可以不通过关键帧一步旋转多于180度的动画。
- 可以用相对值而不是绝对值旋转(设置
byValue
而不是toValue
)。 - 可以不用创建
CATransform3D
,而是使用一个简单的数值来指定角度。 - 不会和
transform.position
或者transform.scale
冲突(同样是使用关键路径来做独立的动画属性)。
transform.rotation
属性有一个奇怪的问题是它其实并不存在。这是因为CATransform3D
并不是一个对象,它实际上是一个结构体,也没有符合KVC相关属性,transform.rotation
实际上是一个CALayer
用于处理动画变换的虚拟属性。
不可以直接设置transform.rotation
或者transform.scale
,他们不能被直接使用。当你对他们做动画时,Core Animation自动地根据通过CAValueFunction
来计算的值来更新transform
属性。
动画组
CAAnimationGroup
是另一个继承于CAAnimation
的子类,它添加了一个animations
数组的属性,用来组合别的动画。
1 | let groupAnimation = CAAnimationGroup() |
过渡
属性动画只对图层的可动画属性起作用,所以如果要改变一个不能动画的属性(比如图片),或者从层级关系中添加或者移除图层,属性动画将不起作用。
过渡并不像属性动画那样平滑地在两个值之间做动画,而是影响到整个图层的变化。过渡动画首先展示之前的图层外观,然后通过一个交换过渡到新的外观。
为了创建一个过渡动画,我们将使用CATransition
,同样是另一个CAAnimation
的子类,和别的子类不同,CATransition
有一个type
和subtype
来标识变换效果。type
属性是一个NSString
类型,可以被设置成如下类型:
kCATransitionFade
kCATransitionMoveIn
kCATransitionPush
kCATransitionReveal
后面三种过渡类型都有一个默认的动画方向,它们都从左侧滑入,但是你可以通过subtype
来控制它们的方向,提供了如下四种类型:
kCATransitionFromRight
kCATransitionFromLeft
kCATransitionFromTop
kCATransitionFromBottom
对图层树的动画
CATransition
并不作用于指定的图层属性,就是可以在即使不能准确得知改变了什么的情况下对图层做动画,例如,在不知道UITableView
哪一行被添加或者删除的情况下,直接就可以平滑地刷新它,或者在不知道UIViewController
内部的视图层级的情况下对两个不同的实例做过渡动画。
它们不仅涉及到图层的属性,而且是整个图层树的改变–我们在这种动画的过程中手动在层级关系中添加或者移除图层。
一般来说,你只需要将动画添加到被影响图层的superlayer
。
1 | - (void)tabBar:(UITabBar *)tabBar didSelectItem:(UITabBarItem *)item { |
在动画过程中取消动画
用-addAnimation:forKey:
方法中的key
参数来在添加动画之后检索一个动画。
但并不支持在动画运行过程中修改动画,所以这个方法主要用来检测动画的属性,或者判断它是否被添加到当前图层中。
为了终止一个指定的动画,你可以用如下方法把它从图层移除掉:
- (void)removeAnimationForKey:(NSString *)key;
或者移除所有动画:
- (void)removeAllAnimations;
动画一旦被移除,图层的外观就立刻更新到当前的模型图层的值。一般说来,动画在结束之后被自动移除,除非设置removedOnCompletion
为NO
,如果你设置动画在结束之后不被自动移除,那么当它不需要的时候你要手动移除它;否则它会一直存在于内存中,直到图层被销毁。
通过代理判断是动画结束还是手动移除:
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
//log that the animation stopped
NSLog(@"The animation stopped (finished: %@)", flag? @"YES": @"NO");
}
图层时间
CAMediaTiming
协议
CAMediaTiming
协议定义了在一段动画内用来控制逝去时间的属性的集合,CALayer
和CAAnimation
都实现了这个协议,所以时间可以被任意基于一个图层或者一段动画的类控制。
持续和重复
duration
是一个CFTimeInterval
的类型(类似于NSTimeInterval
的一种双精度浮点类型),对将要进行的动画的一次迭代指定了时间。
CAMediaTiming
另外还有一个属性叫做repeatCount
,代表动画重复的迭代次数。如果duration
是2,repeatCount
设为3.5(三个半迭代),那么完整的动画时长将是7秒。
duration
和repeatCount
默认都是0。但这不意味着动画时长为0秒,或者0次,这里的0仅仅代表了“默认”,也就是0.25秒和1次。
创建重复动画的另一种方式是使用repeatDuration
属性,它让动画重复一个指定的时间,而不是指定次数,INFINITY
代表无限循环。设置autoreverses
的属性(BOOL类型)在每次间隔交替循环过程中自动回放。这对于播放一段连续非循环的动画很有用,例如打开一扇门,然后关上。
相对时间
每个动画都有它自己描述的时间,可以独立地加速,延时或者偏移。
beginTime
指定了动画开始之前的的延迟时间。这里的延迟从动画添加到可见图层的那一刻开始测量,默认是0(就是说动画会立刻执行)。
speed
是一个时间的倍数,默认1.0,减少它会减慢图层/动画的时间,增加它会加快速度。如果2.0的速度,那么对于一个duration
为1的动画,实际上在0.5秒的时候就已经完成了。
timeOffset
和beginTime
类似,但是和增加beginTime
导致的延迟动画不同,增加timeOffset
只是让动画快进到某一点,例如,对于一个持续1秒的动画来说,设置timeOffset
为0.5意味着动画将从一半的地方开始。
和beginTime
不同的是,timeOffset
并不受speed
的影响。所以如果你把speed
设为2.0,把timeOffset
设置为0.5,那么动画将从动画最后结束的地方开始,因为1秒的动画实际上被缩短到了0.5秒。然而即使使用了timeOffset
让动画从结束的地方开始,它仍然播放了一个完整的时长,这个动画仅仅是循环了一圈,然后从头开始播放。
fillMode
removeOnCompletion
被设置为NO
的动画将会在动画结束的时候仍然保持之前的状态。
一种可能是属性和动画没被添加之前保持一致,也就是在模型图层定义的值。
另一种可能是保持动画开始之前那一帧,或者动画结束之后的那一帧。这就是所谓的填充,因为动画开始和结束的值用来填充开始之前和结束之后的时间。
它可以被CAMediaTiming
的fillMode
来控制。fillMode
是一个NSString
类型,可以接受如下四种常量:
kCAFillModeForwards
kCAFillModeBackwards
kCAFillModeBoth
kCAFillModeRemoved
默认是kCAFillModeRemoved
,当动画不再播放的时候就显示图层模型指定的值剩下的三种类型向前,向后或者即向前又向后去填充动画状态,使得动画在开始前或者结束后仍然保持开始和结束那一刻的值。
需要把
removeOnCompletion
设置为NO
,另外需要给动画添加一个非空的键,于是可以在不需要动画的时候把它从图层上移除。
animation.fillMode = CAMediaTimingFillMode.both
animation.isRemovedOnCompletion = false
层级关系时间
每个动画和图层在时间上都有它自己的层级概念,相对于它的父亲来测量。对图层调整时间将会影响到它本身和子图层的动画,但不会影响到父图层。另一个相似点是所有的动画都被按照层级组合(使用CAAnimationGroup
实例)。
对CALayer
或者CAGroupAnimation
调整duration
和repeatCount
/repeatDuration
属性并不会影响到子动画。但是beginTime
,timeOffset
和speed
属性将会影响到子动画。然而在层级关系中,beginTime
指定了父图层开始动画(或者组合关系中的父动画)和对象将要开始自己动画之间的偏移。类似的,调整CALayer
和CAGroupAnimation
的speed
属性将会对动画以及子动画速度应用一个缩放的因子。
全局时间和本地时间
CoreAnimation有一个全局时间的概念,也就是所谓的马赫时间(“马赫”实际上是iOS和Mac OS系统内核的命名)。马赫时间在设备上所有进程都是全局的–但是在不同设备上并不是全局的–不过这已经足够对动画的参考点提供便利了,使用CACurrentMediaTime
函数来访问马赫时间:
CFTimeInterval time = CACurrentMediaTime();
这个函数返回的值其实无关紧要(它返回了设备自从上次启动后的秒数,并不是你所关心的),它真实的作用在于对动画的时间测量提供了一个相对值。注意当设备休眠的时候马赫时间会暂停,也就是所有的CAAnimations
(基于马赫时间)同样也会暂停。
因此马赫时间对长时间测量并不有用。比如用CACurrentMediaTime
去更新一个实时闹钟并不明智。(可以用[NSDate date]
代替)。
每个CALayer
和CAAnimation
实例都有自己本地时间的概念,是根据父图层/动画层级关系中的beginTime
,timeOffset
和speed
属性计算。就和转换不同图层之间坐标关系一样,CALayer
同样也提供了方法来转换不同图层之间的本地时间。如下:
- (CFTimeInterval)convertTime:(CFTimeInterval)t fromLayer:(CALayer *)l;
- (CFTimeInterval)convertTime:(CFTimeInterval)t toLayer:(CALayer *)l;
当用来同步不同图层之间有不同的speed
,timeOffset
和beginTime
的动画,这些方法会很有用。
暂停,倒回和快进
设置动画的speed
属性为0可以暂停动画,但在动画被添加到图层之后不太可能再修改它了,所以不能对正在进行的动画使用这个属性。
给图层添加一个CAAnimation
实际上是给动画对象做了一个不可改变的拷贝,所以对原始动画对象属性的改变对真实的动画并没有作用。相反,直接用-animationForKey:
来检索图层正在进行的动画可以返回正确的动画对象,但是修改它的属性将会抛出异常。
如果移除图层正在进行的动画,图层将会急速返回动画之前的状态。但如果在动画移除之前拷贝呈现图层到模型图层,动画将会看起来暂停在那里。但是不好的地方在于之后就不能再恢复动画了。
1 |
|
一个简单的方法是可以利用CAMediaTiming
来暂停图层本身。如果把图层的speed
设置成0,它会暂停任何添加到图层上的动画。类似的,设置speed
大于1.0将会快进,设置成一个负值将会倒回动画。
通过增加主窗口图层的speed
,可以暂停整个应用程序的动画。
self.window.layer.speed = 100;
也可以通过这种方式来减速,但其实也可以在模拟器通过切换慢速动画来实现。
缓冲
动画速度
动画实际上就是一段时间内的变化,这就暗示了变化一定是随着某个特定的速率进行。速率由以下公式计算而来:
velocity = change / time
对于这种恒定速度的动画我们称之为“线性步调”,而且从技术的角度而言这也是实现动画最简单的方式,但也是完全不真实的一种效果。
CAMediaTimingFunction
首先需要设置CAAnimation
的timingFunction
属性,是CAMediaTimingFunction
类的一个对象。如果想改变隐式动画的计时函数,同样也可以使用CATransaction
的+setAnimationTimingFunction:
方法。
这里有一些方式来创建CAMediaTimingFunction
,最简单的方式是调用+timingFunctionWithName:
的构造方法。这里传入如下几个常量之一:
kCAMediaTimingFunctionLinear
kCAMediaTimingFunctionEaseIn // 慢慢加速然后突然停止
kCAMediaTimingFunctionEaseOut // 一个全速开始,然后慢慢减速停止
kCAMediaTimingFunctionEaseInEaseOut // 慢慢加速然后再慢慢减速
kCAMediaTimingFunctionDefault // 同上 但加速和减速的过程都稍微有些慢
CATransaction.begin()
CATransaction.setDisableActions(true)
CATransaction.setAnimationDuration(1.2)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name:.easeOut))
colorLayer.backgroundColor = UIColor.random().cgColor
CATransaction.commit()
UIView
的动画缓冲
UIKit的动画也同样支持这些缓冲方法的使用,尽管语法和常量有些不同,为了改变UIView
动画的缓冲选项,给options
参数添加如下常量之一:
UIViewAnimationOptionCurveEaseInOut
UIViewAnimationOptionCurveEaseIn
UIViewAnimationOptionCurveEaseOut
UIViewAnimationOptionCurveLinear
它们和CAMediaTimingFunction
紧密关联,UIViewAnimationOptionCurveEaseInOut
是默认值(这里没有kCAMediaTimingFunctionDefault
相对应的值了)。
缓冲和关键帧动画
CAKeyframeAnimation
有一个NSArray
类型的timingFunctions
属性,我们可以用它来对每次动画的步骤指定不同的计时函数。但是指定函数的个数一定要等于keyframes
数组的元素个数减一,因为它是描述每一帧之间动画速度的函数。
let animation = CAKeyframeAnimation()
animation.keyPath = "backgroundColor"
animation.duration = 2
animation.values = [UIColor.random().cgColor,UIColor.random().cgColor,UIColor.random().cgColor,UIColor.blue.cgColor]
let fn = CAMediaTimingFunction(name:.easeOut)
animation.timingFunctions = [fn,fn,fn,fn]
colorLayer.add(animation, forKey: nil)
自定义缓冲函数
除了+functionWithName:
之外,CAMediaTimingFunction
同样有另一个构造函数,一个有四个浮点参数的+functionWithControlPoints::::
(注意这里奇怪的语法,并没有包含具体每个参数的名称,这在objective-C中是合法的,但是却违反了苹果对方法命名的指导方针,而且看起来是一个奇怪的设计)。
CAMediaTimingFunction
函数的主要原则在于它把输入的时间转换成起点和终点之间成比例的改变。
CAMediaTimingFunction
使用了一个叫做三次贝塞尔曲线的函数,它只可以产出指定缓冲函数的子集。
曲线的斜率代表了速度,斜率的改变代表了加速度。先加速,然后减速,最后快到达终点的时候又加速。
CAMediaTimingFunction
有一个叫做-getControlPointAtIndex:values:
的方法,可以用来检索曲线的点,但是使用它我们可以找到标准缓冲函数的点,然后用UIBezierPath
和CAShapeLayer
来把它画出来。
基于关键帧的缓冲
为了使用关键帧实现反弹动画,需要在缓冲曲线中对每一个显著的点创建一个关键帧(在这个情况下,关键点也就是每次反弹的峰值),然后应用缓冲函数把每段曲线连接起来。同时,我们也需要通过keyTimes
来指定每个关键帧的时间偏移,由于每次反弹的时间都会减少,于是关键帧并不会均匀分布。
1 | func animate() { |
流程自动化
用代码获取属性动画的起始值之间的任意插值,我们就可以把动画分割成许多独立的关键帧,然后产出一个线性的关键帧动画。
需要做以下两点:
- 自动把任意属性动画分割成多个关键帧。
- 用一个数学函数表示弹性动画,使得可以对帧做偏移。
公式如下:
value = (endValue – startValue) × time + startValue;
使用60 x 动画时间(秒做单位)作为关键帧的个数,这时因为Core Animation按照每秒60帧去渲染屏幕更新,所以如果我们每秒生成60个关键帧,就可以保证动画足够的平滑(尽管实际上很可能用更少的帧率就可以达到很好的效果)。
基于定时器的动画
NSTimer
iOS上的每个线程都管理了一个NSRunloop
,字面上看就是通过一个循环来完成一些任务列表。但是对主线程,这些任务包含如下几项:
- 处理触摸事件
- 发送和接受网络数据包
- 执行使用gcd的代码
- 处理计时器行为
- 屏幕重绘
当设置一个NSTimer
,他会被插入到当前任务列表中,然后直到指定时间过去之后才会被执行。但是何时启动定时器并没有一个时间上限,而且它只会在列表中上一个任务完成之后开始执行。这通常会导致有几毫秒的延迟,但是如果上一个任务过了很久才完成就会导致延迟很长一段时间。
屏幕重绘的频率是一秒钟六十次,但是和定时器行为一样,如果列表中上一个执行了很长时间,它也会延迟。这些延迟都是一个随机值,于是就不能保证定时器精准地一秒钟执行六十次。有时候发生在屏幕重绘之后,这就会使得更新屏幕会有个延迟,看起来就是动画卡壳了。有时候定时器会在屏幕更新的时候执行两次,于是动画看起来就跳动了。
可以通过一些途径来优化:
- 可以用
CADisplayLink
让更新频率严格控制在每次屏幕刷新之后。 - 基于真实帧的持续时间而不是假设的更新频率来做动画。
- 调整动画计时器的
run loop
模式,这样就不会被别的事件干扰。
CADisplayLink
CADisplayLink
是CoreAnimation提供的另一个类似于NSTimer
的类,它总是在屏幕完成一次更新之前启动
,它的接口设计的和NSTimer
很类似,所以它实际上就是一个内置实现的替代,但是和timeInterval
以秒为单位不同,CADisplayLink
有一个整型的frameInterval
属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。但是如果动画的代码执行起来超过了六十分之一秒,可以指定frameInterval
为2,就是说动画每隔一帧执行一次(一秒钟30帧)或者3,也就是一秒钟20次,等等。
用CADisplayLink
而不是NSTimer
,会保证帧率足够连续,使得动画看起来更加平滑,但即使CADisplayLink
也不能保证每一帧都按计划执行,一些失去控制的离散的任务或者事件(例如资源紧张的后台程序)可能会导致动画偶尔地丢帧。
当使用
NSTimer
的时候,一旦有机会计时器就会开启,但是CADisplayLink
却不一样:如果它丢失了帧,就会直接忽略它们,然后在下一次更新的时候接着运行。
Run Loop 模式
当创建CADisplayLink
的时候,需要指定一个run loop
和run loop mode
,对于run loop来说,就使用了主线程的run loop,因为任何用户界面的更新都需要在主线程执行,但是模式的选择就并不那么清楚了,每个添加到run loop的任务都有一个指定了优先级的模式,为了保证用户界面保持平滑,iOS会提供和用户界面相关任务的优先级,而且当UI很活跃的时候的确会暂停一些别的任务。
一个典型的例子就是当是用UIScrollview
滑动的时候,重绘滚动视图的内容会比别的任务优先级更高,所以标准的NSTimer
和网络请求就不会启动,一些常见的run loop模式如下:
NSDefaultRunLoopMode
- 标准优先级NSRunLoopCommonModes
- 高优先级UITrackingRunLoopMode
- 用于UIScrollView
和别的控件的动画
用了NSDefaultRunLoopMode
,但是不能保证动画平滑的运行,所以就可以用NSRunLoopCommonModes
来替代。但是要小心,因为如果动画在一个高帧率情况下运行,会发现一些别的类似于定时器的任务或者类似于滑动的其他iOS动画会暂停,直到动画结束。
同样可以同时对CADisplayLink
指定多个run loop模式,于是可以同时加入NSDefaultRunLoopMode
和UITrackingRunLoopMode
来保证它不会被滑动打断,也不会被其他UIKit控件动画影响性能,像这样:
1 | self.timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(step:)]; |
物理模拟
略
性能调优<重点>
动画的舞台
动画和屏幕上组合的图层实际上被一个单独的进程管理,而不是你的应用程序。这个进程就是所谓的渲染服务。在iOS5和之前的版本是SpringBoard进程(同时管理着iOS的主屏)。在iOS6之后的版本中叫做BackBoard
。
当运行一段动画时候,这个过程会被四个分离的阶段被打破:
- 布局 - 这是准备你的视图/图层的层级关系,以及设置图层属性(位置,背景色,边框等等)的阶段。
- 显示 - 这是图层的寄宿图片被绘制的阶段。绘制有可能涉及你的
-drawRect:
和-drawLayer:inContext:
方法的调用路径。 - 准备 - 这是Core Animation准备发送动画数据到渲染服务的阶段。这同时也是Core Animation将要执行一些别的事务例如解码动画过程中将要显示的图片的时间点。
- 提交 - 这是最后的阶段,Core Animation打包所有图层和动画属性,然后通过IPC(内部处理通信)发送到渲染服务进行显示。
一旦打包的图层和动画到达渲染服务进程,他们会被反序列化来形成另一个叫做渲染树的图层树(在第一章“图层树”中提到过)。使用这个树状结构,渲染服务对动画的每一帧做出如下工作:
- 对所有的图层属性计算中间值,设置OpenGL几何形状(纹理化的三角形)来执行渲染
在屏幕上渲染可见的三角形
所以一共有六个阶段;最后两个阶段在动画过程中不停地重复。前五个阶段都在软件层面处理(通过CPU),只有最后一个被GPU执行。而且,你真正只能控制前两个阶段:布局和显示。Core Animation框架在内部处理剩下的事务,你也控制不了它。
GPU相关的操作
GPU为一个具体的任务做了优化:它用来采集图片和形状(三角形),运行变换,应用纹理和混合然后把它们输送到屏幕上。现代iOS设备上可编程的GPU在这些操作的执行上又很大的灵活性,但是Core Animation并没有暴露出直接的接口。除非想绕开Core Animation并编写你自己的OpenGL着色器,从根本上解决硬件加速的问题,那么剩下的所有都还是需要在CPU的软件层面上完成。
宽泛的说,大多数CALayer
的属性都是用GPU来绘制。比如设置图层背景或者边框的颜色,那么这些可以通过着色的三角板实时绘制出来。如果对一个contents
属性设置一张图片,然后裁剪它 - 它就会被纹理的三角形绘制出来,而不需要软件层面做任何绘制。
但是有一些事情会降低(基于GPU)图层绘制,比如:
- 太多的几何结构 - 这发生在需要太多的三角板来做变换,以应对处理器的栅格化的时候。现代iOS设备的图形芯片可以处理几百万个三角板,所以在Core Animation中几何结构并不是GPU的瓶颈所在。但由于图层在显示之前通过IPC发送到渲染服务器的时候(图层实际上是由很多小物体组成的特别重量级的对象),太多的图层就会引起CPU的瓶颈。这就限制了一次展示的图层个数。
- 重绘 - 主要由重叠的半透明图层引起。GPU的填充比率(用颜色填充像素的比率)是有限的,所以需要避免重绘(每一帧用相同的像素填充多次)的发生。在现代iOS设备上,GPU都会应对重绘;即使是iPhone 3GS都可以处理高达2.5的重绘比率,并任然保持60帧率的渲染(这意味着你可以绘制一个半的整屏的冗余信息,而不影响性能),并且新设备可以处理更多。
- 离屏绘制 - 这发生在当不能直接在屏幕上绘制,并且必须绘制到离屏图片的上下文中的时候。离屏绘制发生在基于CPU或者是GPU的渲染,或者是为离屏图片分配额外内存,以及切换绘制上下文,这些都会降低GPU性能。对于特定图层效果的使用,比如圆角,图层遮罩,阴影或者是图层光栅化都会强制Core Animation提前渲染图层的离屏绘制。但这不意味着你需要避免使用这些效果,只是要明白这会带来性能的负面影响。
- 过大的图片 - 如果视图绘制超出GPU支持的2048x2048或者4096x4096尺寸的纹理,就必须要用CPU在图层每次显示之前对图片预处理,同样也会降低性能。
CPU相关的操作
大多数工作在Core Animation的CPU都发生在动画开始之前。这意味着它不会影响到帧率,所以很好,但是他会延迟动画开始的时间
,让界面看起来会比较迟钝。
以下CPU的操作都会延迟动画的开始时间:
- 布局计算 - 如果你的视图层级过于复杂,当视图呈现或者修改的时候,计算图层帧率就会消耗一部分时间。特别是使用iOS6的自动布局机制尤为明显,它应该是比老版的自动调整逻辑加强了CPU的工作。
- 视图懒加载 - iOS只会当视图控制器的视图显示到屏幕上时才会加载它。这对内存使用和程序启动时间很有好处,但是当呈现到屏幕上之前,按下按钮导致的许多工作都会不能被及时响应。比如控制器从数据库中获取数据,或者视图从一个nib文件中加载,或者涉及IO的图片显示,都会比CPU正常操作慢得多。
- Core Graphics绘制 - 如果对视图实现了
-drawRect:
方法,或者CALayerDelegate
的-drawLayer:inContext:
方法,那么在绘制任何东西之前都会产生一个巨大的性能开销。为了支持对图层内容的任意绘制,Core Animation必须创建一个内存中等大小的寄宿图片。然后一旦绘制结束之后,必须把图片数据通过IPC传到渲染服务器。在此基础上,Core Graphics绘制就会变得十分缓慢,所以在一个对性能十分挑剔的场景下这样做十分不好。 - 解压图片 - PNG或者JPEG压缩之后的图片文件会比同质量的位图小得多。但是在图片绘制到屏幕上之前,必须把它扩展成完整的未解压的尺寸(通常等同于图片宽 x 长 x 4个字节)。为了节省内存,iOS通常直到真正绘制的时候才去解码图片。根据你加载图片的方式,第一次对图层内容赋值的时候(直接或者间接使用
UIImageView
)或者把它绘制到Core Graphics中,都需要对它解压,这样的话,对于一个较大的图片,都会占用一定的时间。
当图层被成功打包,发送到渲染服务器之后,CPU仍然要做如下工作:为了显示屏幕上的图层,Core Animation必须对渲染树种的每个可见图层通过OpenGL循环转换成纹理三角板。由于GPU并不知晓Core Animation图层的任何结构,所以必须要由CPU做这些事情。这里CPU涉及的工作和图层个数成正比,所以如果在你的层级关系中有太多的图层,就会导致CPU没一帧的渲染,即使这些事情不是你的应用程序可控的。
IO相关操作
上下文中的IO(输入/输出)指的是例如闪存或者网络接口的硬件访问。一些动画可能需要从闪存(甚至是远程URL)来加载。一个典型的例子就是两个视图控制器之间的过渡效果,这就需要从一个nib文件或者是它的内容中懒加载,或者一个旋转的图片,可能在内存中尺寸太大,需要动态滚动来加载。
IO比内存访问更慢,所以如果动画涉及到IO,就是一个大问题。总的来说,这就需要使用聪敏但尴尬的技术,也就是多线程,缓存和预加载(提前加载当前不需要的资源,但是之后可能需要用到)。
高效绘图
术语绘图通常在Core Animation的上下文中指代软件绘图(意即:不由GPU协助的绘图)。在iOS中,软件绘图通常是由Core Graphics框架完成来完成。但是,在一些必要的情况下,相比Core Animation和OpenGL,Core Graphics要慢了不少。
软件绘图不仅效率低,还会消耗可观的内存。CALayer
只需要一些与自己相关的内存:只有它的寄宿图会消耗一定的内存空间。即使直接赋给contents
属性一张图片,也不需要增加额外的照片存储大小。如果相同的一张图片被多个图层作为contents
属性,那么他们将会共用同一块内存,而不是复制内存块。
但是一旦你实现了CALayerDelegate
协议中的-drawLayer:inContext:
方法或者UIView
中的-drawRect:
方法(其实就是前者的包装方法),图层就创建了一个绘制上下文,这个上下文需要的大小的内存可从这个算式得出:图层宽*图层高*4字节,宽高的单位均为像素。对于一个在Retina iPad上的全屏图层来说,这个内存量就是 2048*1526*4字节,相当于12MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。
软件绘图的代价昂贵,除非绝对必要,应该避免重绘视图。提高绘制性能的秘诀就在于尽量避免去绘制。
矢量图形
用Core Graphics来绘图的一个通常原因就是只是用图片或是图层效果不能轻易地绘制出矢量图形。矢量绘图包含一下这些:
- 任意多边形(不仅仅是一个矩形)
- 斜线或曲线
- 文本
- 渐变
用Core Graphics做一个简单的『画板』。这样实现的问题在于,画得越多,程序就会越慢。因为每次移动手指的时候都会重绘整个贝塞尔路径(UIBezierPath
),随着路径越来越复杂,每次重绘的工作就会增加,直接导致了帧数的下降。
CAShapeLayer
可以绘制多边形,直线和曲线。CATextLayer
可以绘制文本。CAGradientLayer
用来绘制渐变。这些总体上都比Core Graphics更快,同时他们也避免了创造一个寄宿图。
用CAShapeLayer
替代Core Graphics,性能就会得到提高。虽然随着路径复杂性的增加,绘制性能依然会下降,但是只有当非常非常浮躁的绘制时才会感到明显的帧率差异。
1 | class DrawingView: UIView { |
脏矩形
为了减少不必要的绘制,Mac OS和iOS设备将会把屏幕区分为需要重绘的区域和不需要重绘的区域。那些需要重绘的部分被称作『脏区域』。在实际应用中,鉴于非矩形区域边界裁剪和混合的复杂性,通常会区分出包含指定视图的矩形位置,而这个位置就是『脏矩形』。
当一个视图被改动过了,TA可能需要重绘。但是很多情况下,只是这个视图的一部分被改变了,所以重绘整个寄宿图就太浪费了。但是Core Animation通常并不了解你的自定义绘图代码,它也不能自己计算出脏区域的位置。然而,你的确可以提供这些信息。
当你检测到指定视图或图层的指定部分需要被重绘,你直接调用`-setNeedsDisplayInRect:`来标记它,然后将影响到的矩形作为参数传入。这样就会在一次视图刷新时调用视图的`-drawRect:`(或图层代理的`-drawLayer:inContext:`方法)。
传入-drawLayer:inContext:
的CGContext
参数会自动被裁切以适应对应的矩形。为了确定矩形的尺寸大小,你可以用CGContextGetClipBoundingBox()
方法来从上下文获得大小。调用-drawRect()
会更简单,因为CGRect
会作为参数直接传入。
异步绘制
UIKit的单线程天性意味着寄宿图通畅要在主线程上更新,这意味着绘制会打断用户交互,甚至让整个app看起来处于无响应状态。
针对这个问题,有一些方法可以用到:一些情况下,可以推测性地提前在另外一个线程上绘制内容,然后将由此绘出的图片直接设置为图层的内容。这实现起来可能不是很方便,但是在特定情况下是可行的。Core Animation提供了一些选择:CATiledLayer
和drawsAsynchronously
属性。
CATiledLayer
CATiledLayer
还有一个有趣的特性:在多个线程中为每个小块同时调用-drawLayer:inContext:
方法。这就避免了阻塞用户交互而且能够利用多核心新片来更快地绘制。只有一个小块的CATiledLayer
是实现异步更新图片视图的简单方法。
drawsAsynchronously
drawsAsynchronously
属性对传入-drawLayer:inContext:
的CGContext进行改动,允许CGContext延缓绘制命令的执行以至于不阻塞用户交互。
它与CATiledLayer
使用的异步绘制并不相同。它自己的-drawLayer:inContext:
方法只会在主线程调用,但是CGContext并不等待每个绘制命令的结束。相反地,它会将命令加入队列,当方法返回时,在后台线程逐个执行真正的绘制。
根据苹果的说法。这个特性在需要频繁重绘的视图上效果最好(比如我们的绘图应用,或者诸如`UITableViewCell`之类的),对那些只绘制一次或很少重绘的图层内容来说没什么太大的帮助。
图像IO
- 大图需要异步加载
- PNG图片加载会比JPEG更长,因为文件可能更大,但是解码会相对较快,而且Xcode会把PNG图片进行解码优化之后引入工程。JPEG图片更小,加载更快,但是解压的步骤要消耗更长的时间,因为JPEG解压算法比基于zip的PNG算法更加复杂。
当加载图片的时候,iOS通常会延迟解压图片的时间,直到加载到内存之后。这就会在准备绘制图片的时候影响性能,因为需要在绘制之前进行解压(通常是消耗时间的问题所在)。
最简单的方法就是使用UIImage
的+imageNamed:
方法避免延时加载。不像+imageWithContentsOfFile:
(和其他别的UIImage
加载方法),这个方法会在加载图片之后立刻进行解压(就和本章之前我们谈到的好处一样)。
[UIImage imageNamed:]
方法有另一个非常显著的好处:它在内存中自动缓存了解压后的图片,即使你自己没有保留对它的任何引用。
但是并不是对应用程序需要显示的所有类型的图片都适用:
[UIImage imageNamed:]
方法仅仅适用于在应用程序资源束目录下的图片,但是大多数应用的许多图片都要从网络或者是用户的相机中获取,所以[UIImage imageNamed:]
就没法用了。[UIImage imageNamed:]
缓存用来存储应用界面的图片(按钮,背景等等)。如果对照片这种大图也用这种缓存,那么iOS系统就很可能会移除这些图片来节省内存。那么在切换页面时性能就会下降,因为这些图片都需要重新加载。对传送器的图片使用一个单独的缓存机制就可以把它和应用图片的生命周期解耦。[UIImage imageNamed:]
缓存机制并不是公开的,不能很好地控制它。例如,没法做到检测图片是否在加载之前就做了缓存,不能够设置缓存大小,当图片没用的时候也不能把它从缓存中移除。
使用NSCache做预加载代替[UIImage imageNamed:
可以实现自定义缓存机制。
图层性能
光栅化
CALayer
的shouldRasterize
属性,它可以解决重叠透明图层的混合失灵问题。它也是作为绘制复杂图层树结构的优化方法。
启用shouldRasterize
属性会将图层绘制到一个屏幕之外的图像。然后这个图像将会被缓存起来并绘制到实际图层的contents
和子图层。如果有很多的子图层或者有复杂的效果应用,这样做就会比重绘所有事务的所有帧划得来得多。但是光栅化原始图像需要时间,而且还会消耗额外的内存。
当使用得当时,光栅化可以提供很大的性能优势,但是一定要避免作用在内容不断变动的图层上,否则它缓存方面的好处就会消失,而且会让性能变的更糟。
为了检测是否正确地使用了光栅化方式,用Instrument查看一下Color Hits Green和Misses Red项目,是否已光栅化图像被频繁地刷新(这样就说明图层并不是光栅化的好选择,或则无意间触发了不必要的改变导致了重绘行为)。
离屏渲染
图层的以下属性将会触发屏幕外绘制:
- 圆角(当和
maskToBounds
一起使用时) - 图层蒙板
- 阴影
屏幕外渲染和我们启用光栅化时相似,除了它并没有像光栅化图层那么消耗大,子图层并没有被影响到,而且结果也没有被缓存,所以不会有长期的内存占用。但是,如果太多图层在屏幕外渲染依然会影响到性能。
对于那些需要动画而且要在屏幕外渲染的图层来说,可以用CAShapeLayer
,contentsCenter
或者shadowPath
来获得同样的表现而且较少地影响到性能。
CAShapeLayer
cornerRadius
和maskToBounds
独立作用的时候都不会有太大的性能问题,但是当他俩结合在一起,就触发了屏幕外渲染。有时候想显示圆角并沿着图层裁切子图层的时候,会发现你并不需要沿着圆角裁切,这个情况下用CAShapeLayer
就可以避免这个问题了。
shadowPath
如果图层是一个简单几何图形如矩形或者圆角矩形(假设不包含任何透明部分或者子图层),创建出一个对应形状的阴影路径就比较容易,而且Core Animation绘制这个阴影也相当简单,避免了屏幕外的图层部分的预排版需求。这对性能来说很有帮助。
如果图层是一个更复杂的图形,生成正确的阴影路径可能就比较难了,这样子的话可以用绘图软件预先生成一个阴影背景图。
混合和过度绘制
GPU每一帧可以绘制的像素有一个最大限制(就是所谓的fill rate),这个情况下可以轻易地绘制整个屏幕的所有像素。但是如果由于重叠图层的关系需要不停地重绘同一区域的话,掉帧就可能发生了。
GPU会放弃绘制那些完全被其他图层遮挡的像素,但是要计算出一个图层是否被遮挡也是相当复杂并且会消耗处理器资源。同样,合并不同图层的透明重叠像素(即混合)消耗的资源也是相当客观的。所以为了加速处理进程,不到必须时刻不要使用透明图层。任何情况下,应该这样做:
- 给视图的
backgroundColor
属性设置一个固定的,不透明的颜色 - 设置
opaque
属性为YES
该属性为BOOL值,UIView的默认值是YES,但UIButton等子类的默认值都是NO。\
opaque表示当前UIView是否不透明,不过搞笑的是事实上它却决定不了当前UIView是不是不透明,比如你将opaque设为NO,该UIView照样是可见的。其作用在于:给绘图系统提供一个性能优化开关。如果该值为YES,那么绘图在绘制该视图的时候把整个视图当做不透明对待。这样,绘图系统在执行绘图过程中会优化一些操作并提供系统性能;如果是设置为NO,绘图系统将其和其他内容平等对待,不去做优化操作。为了性能方面的考量,默认被置为YES(意味着优化)。
- UIView当有背景颜色时:并且背景颜色有透明度(透明度不为1时),将opaque设置为YES性能较高。
- UIVIew有背景颜色时:并且背景颜色的透明度为1,opaque的值不影响性能。
- UIVIew没有背景颜色时:opaque的值不影响性能。
这样做减少了混合行为(因为编译器知道在图层之后的东西都不会对最终的像素颜色产生影响)并且计算得到了加速,避免了过度绘制行为因为Core Animation可以舍弃所有被完全遮盖住的图层,而不用每个像素都去计算一遍。
如果用到了图像,尽量避免透明除非非常必要。如果图像要显示在一个固定的背景颜色或是固定的背景图之前,你没必要相对前景移动,你只需要预填充背景图片就可以避免运行时混色了。
如果是文本的话,一个白色背景的UILabel
(或者其他颜色)会比透明背景要更高效。
最后,明智地使用shouldRasterize
属性,可以将一个固定的图层体系折叠成单张图片,这样就不需要每一帧重新合成了,也就不会有因为子图层之间的混合和过度绘制的性能问题了。
减少图层数量
初始化图层,处理图层,打包通过IPC发给渲染引擎,转化成OpenGL几何图形,这些是一个图层的大致资源开销。事实上,一次性能够在屏幕上显示的最大图层数量也是有限的。
在对图层做任何优化之前,需要确定你不是在创建一些不可见的图层,图层在以下几种情况下回事不可见的:
- 图层在屏幕边界之外,或是在父图层边界之外。
- 完全在一个不透明图层之后。
- 完全透明
Core Animation非常擅长处理对视觉效果无意义的图层。但是经常性地,代码会比Core Animation更早地想知道一个图层是否是有用的。理想状况下,在图层对象在创建之前就想知道,以避免创建和配置不必要图层的额外工作。
对象回收
对象回收的基础原则就是你需要创建一个相似对象池。当一个对象的指定实例(本例子中指的是图层)结束了使命,你把它添加到对象池中。每次当你需要一个实例时,你就从池中取出一个。当且仅当池中为空时再创建一个新的。
这样做的好处在于避免了不断创建和释放对象(相当消耗资源,因为涉及到内存的分配和销毁)而且也不必给相似实例重复赋值。