iOS开发:CAMediaTiming与动画

iOS

写在前面

在体验至上的时代,一些自然酷炫流畅的动画无疑能提高用户对app好感度以及留存率。常见动画有如下拉加载、加载进度以及转场动画等,iOS开发中的动画大家都不陌生。Core Animation框架提供了一套CALayer层动画接口。因为CALayer作为UIView的backing layer的关系,UIKit基于CA框架也提供了一套视图动画接口,归根到底还是CA动画。

这篇文章,主要是来聊聊动画的一些基本概念,不是来解析具体的动画效果实现。不妨先停下来想一想,动画是什么,它的基本实现原理是什么,我们能做什么。我们从框架提供的接口的角度来认识动画。

动画是什么:CAAnimation

CAAnimation是Core Animation框架中的一个基类,但并不实际用于动画实现,只是定义了“动画”对象所遵循的基本接口,具体动画效果通过子类提供。其子类有如:CAPropertyAnimation属性动画及其两个子类CABasicAnimationCAKeyFrameAnimation,iOS9推出的CASpringAnimationCATransition(都是继承CABasicAnimation)以及动画组CAAnimationGroup(继承于CAAnimation)。我们主要基于基本动画进行分析,动画组及帧动画也是基本动画的组合。

CAAnimation遵循两个关键的协议:CAAction以及CAMediaTiming

CAAction是一个事件处理的接口(动画行为),要求实现接口的对象实现-(void)runActionForKey:(NSString *)event object:(id)anObject arguments:(nullable NSDictionary *)dict进行事件处理(也就是执行动画)。通常而言,object是layer,event则是key,每个layer保存了一份从event到action的映射表(可参考接口- (void)addAnimation:(CAAnimation *)anim forKey:(nullable NSString *)key)。对于系统支持的隐式动画,每当layer的(可动画)属性被修改时,会触发相同名称(属性名)的事件,系统有相应的CAAnimation对象与该事件绑定。另外,系统提供了两个非直接关联到属性的事件:onOrderInonOrderOut,当layer可见或非可见时触发。

CAMediaTiming是一个关键的协议,也是本文所关注的点。CALayerCAAnimation都遵循实现了该协议。CAMediaTiming对分层时间系统进行了建模,每个对象描述了其父对象时间(parent time)到本身时间(local time)的映射,local time包含两个:active local time及basic local time。时间的转换包含了两个阶段:一是,从父对象时间(parent time)转换为本地活跃时间(active local time),包含了本对象出现在父对象时间线上的点以及其相对(父对象)时间速度;二是,从active local time到basic local time,允许对象本身时长的周期性重复以及逆向播放。

除此之外,CAAnimation定义了另外一个关键属性:timingFunction,类型是CAMediaTimingFunction,这是一个动画时间控制函数。

看完了基本接口,回想一下最初提出的问题,“动画是什么?”。举些大家日常都比较熟悉的例子:电影慢镜头及GIF。电影慢镜头可以将一些短时间内发生的变化场景捕获后,以更长的时间来展示;GIF简单来说,将很多张图片进行合并到同一个图片文件就可以制作出一个GIF,每张图片可以控制周期内播放时长。这些都跟“时间”紧密相关,我们对场景变化的感知是基于时间的。动画,是一定时间内场景(状态)的变化所创造的效果。

CAMediaTiming就如Core Animation框架的时间系统。我们接触的基本动画、交互动画以及物理模拟动画,其实都跟这个概念密切相关。

时间系统:CAMediaTiming

CAMediaTiming提供了8个属性,分别是:

  1. duration:“The basic duration of the object. Defaults to 0.”(API的解释,下同),这个不一定是真实的动画时长,真实的还跟速度speed以及上层对象时间体系有关。
  2. speed:“The rate of the layer. Used to scale parent time to local time, e.g. if rate is 2, local time progresses twice as fast as parent time. Defaults to 1.”,当前层的速率,用于将父对象时间拉伸到本身时间。
  3. beginTime:“The begin time of the object, in relation to its parent object, if applicable. Defaults to 0.”,本对象在父对象的时间线中开始的位置,比如本身一个anmation添加到一个animation group中时,beginTime设定了动画组开始后当前动画的开始时间(也即是延迟)。但对于将animation添加到layer中时,layer作为parent object它的开始时间是过去时间不能这样使用,需要明确确定其beginTime可以选择对当前绝对时间的偏移。当前绝对时间可通过CACurrentMediaTime()获取并[layer convertTime:CACurrentMediaTime() fromLayer:nil]转换到layer的时间坐标系中。
  4. timeoffset:“Additional offset in active local time. i.e. to convert from parent time tp to active local time t: t = (tp - begin) * speed + offset. One use of this is to “pause” a layer by setting speed to zero andoffset to a suitable value. Defaults to 0.”,这里给出了一条时间转换关系公式,对于父对象中的时间点tp,在本身时间坐标系统时间为t = (tp - begin) * speed + offset,也就是父时间进行伸缩并加上偏移就得到当前对象的时间点。不是很直观理解,举个例子:动画组anigroup(作为parent object)时长3秒分别对应状态p1、p2、p3,一个动画anim时长6秒分别对应状态a1~a6,其动画速度speed为2、偏移offset为1,被添加到动画组anigroup中;那么当动画组anigroup状态为p2时,动画anim的状态为a5。
  5. fillmoderepeatCount ,repeatDurationautoreverses动画重复及逆向播放的控制属性。

如上面提到的,动画是一定的时间规律下的状态切换,这包含两层含义:一是时间变化规律,二是状态切换。动画效果需要分别对这二者进行控制(或者可以认为是动画状态变化的两面体现,动画既包括值的变化也包括时间的变化;动画的插值与时间相关,时间变化规律指的也是插值的变化,否则感知上时间就是线性变化的)。不过实际上,开发中通常是指定动画的状态切换效果,其时间规律通常是采用已有的动画时间控制函数。除此之外,时基动画需要对时间进度进行管理,上面所列的公式t = (tp - begin) * speed + offset不是用来直接确定动画状态切换的时间规律(效果)的,而是用于确定父对象时间到本对象时间映射(进度)的。其中三组控制变量:父时间进度(tp-begin)、速度(speed)、偏移(offset)。

举几个例子说明时间的进度控制:

  1. Xcode模拟器的动画调试慢放,通过修改动画播放的speed
  2. 动画暂停,将speed置为0,并指定相应的offset;甚至还可以通过定时器控制offset的方式,来实现逆向播放等。

插值与时间控制函数:CAMediaTimingFunction

时基系统的动画,按时间的步进更新动画对象的状态,这涉及了动画状态的插值问题。

比如说一个位置平移动画(比如说弹簧效果),仅知道动画时间duration、始末状态valueBeginvalueEnd是不够的,动画的过程需要知道动画时间t时的位置状态valueT。实现这个具体的动画也很简单,我们需要定义一个以时间t为控制变量的插值函数value=mapfunc(t, duration, valueBegin, valueEnd),随着时间t的步进对location进行更新即可。

但上述的插值函数mapfunc的问题在于,它是与具体特定动画的动画时间duration、始末状态值相关的。即使是同一效果,不同振幅或不同动画时间都需要提供一个相应的插值函数!不具备可重用性。那假如把动画定义为一种“效果”(比如,弹簧震荡效果),我们如何将这种效果抽象出来?

CAMediaTimingFunction登场。那CAMediaTimingFunction做了什么?CAMediaTimingFunction能将某种“效果”的插值过程抽象出来吗?最初的需求,动画状态的插值还是要实现的。

回到问题的本质,动画的状态切换是基于时间的插值,其实是一个映射问题,value=mapfunc(t, duration, valueBegin, valueEnd)是从线性变化变量t到value的一个非线性变换。为了将动画效果与具体动画状态区分开来,我们需要将时间参数t与其他参数分离,分别构造两个映射函数:

  1. mapvalue(tProgress, duration, valueBegin, valueEnd)
  2. tProgress = maptime(tReal)

将状态的切换转换为基于进度的插值,tProgress是一个进度值,mapvalue进行线性映射;另外增加一个时间映射函数maptime,将(单位)动画时间映射为状态切换进度tProgress,动画效果将时间控制部分分离了。这正是CAMediaTimingFunction的职责:时间控制(时间映射)。

CAMediaTimingFunction做了动画时间到动画进度(映射到始末状态的线性插值)的映射,其中系统预定义了五种时间控制方法,另外支持定义三次贝塞尔曲线的控制函数。

然后我们回到UI及动画的渲染过程,结合上面所说的时间控制理解一下UI渲染的过程。

####CALayer的渲染及动画

图层树

CALayer拥有与UIView一样的树状层次结构,实际上作为UIView的backing layer是用做内容显示的管理,而UIView则主要添加了事件机制。对应UIView树形结构,CALayer有它的图层树(模型树)、呈现树及渲染树,对应每个CALayer的modelLayerpresentationLayerrenderLayermodelLayer是我们代码层面反应对layer的属性的修改,presentationLayer则是动画过程中与界面展示一致的图层数据,renderLayer则是框架私有的(见上图)。可参考Apple官网的图示:

作为UIView的layer也被称为Root Layer,其代理指定为关联的UIView,用于管理layer的绘制/渲染,代理事件主要包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@protocol CALayerDelegate <NSObject>

- (void)displayLayer:(CALayer *)layer;
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
- (void)layerWillDraw:(CALayer *)layer CA_AVAILABLE_STARTING (10.12, 10.0, 10.0, 3.0);
- (void)layoutSublayersOfLayer:(CALayer *)layer;

/* If defined, called by the default implementation of the
* -actionForKey: method. Should return an object implementating the
* CAAction protocol. May return 'nil' if the delegate doesn't specify
* a behavior for the current event. Returning the null object (i.e.
* '[NSNull null]') explicitly forces no further search. (I.e. the
* +defaultActionForKey: method will not be called.) */
- (nullable id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event;
@end

界面的渲染过程在objc.io有一期介绍。下面是渲染的基本过程图解:

渲染

总的来说,每次运行循环CPU按需要创建好UIView的缓冲区,对view内容进行绘制(CPU工作),然后缓冲区交给GPU渲染,最后实际显示到屏幕上:

  • 每个UIView的backing layer有一个content字段,指向的是一块位图缓冲区,称为backing store(可以理解成CGImageRef);

  • CPU对UIView的绘制,主要在动画事务(下文详细介绍)提交、完成布局工作后,[CALayer display]触发的各层事件回调([CALayer drawInContext:][UIView(CALayerDelegate) drawLayer:inContext:][UIView drawRect:])中在CPU层面对界面的绘制,数据通过CGContextRef

    写入backing store;

  • backing store写完后,通过渲染服务交给GPU进行渲染,将backing store中的位图数据进行显示。

每当修改视图或层的属性,将导致位图缓冲区的重新创建,重新绘制后,layer的content字段会重新指向新的位图缓冲区。CPU影响的是布局与CPU层面的渲染。

GPU是基于OpenGL对Texture纹理数据进行处理的,Core Animation框架可以创建Texture并将其与位图数据绑定。整个过程如下图示:

GPU处理需要将位图数据从RAM搬到VRAM,主要是处理Texture的渲染工作,包括:

  • 纹理组合,相当于UIView的层次叠加处理,公式R = S+D*(1-Sa),图层的透明度有重大影响;
  • 像素缩放采样,位图数据与最终渲染的数据大小不一致需调整
  • 离屏渲染
动画事务:CATransaction

Core Animation假设屏幕上所有东西均可进行动画,对CALayer的可动画属性进行修改时,默认会导致动画的发生来保证平滑过渡,这便是隐式动画。之所以称之为隐式,是因为没有指定它的动画时间、类型等。实际上,动画的执行时间取决于当前事务的设置,动画类型取决于图层行为。

动画事务CATransaction是Core Animation用来包含一系列属性动画集合的机制,用于将多个可动画属性的修改集中提交执行。任何用指定事务来改变图层的可动画属性时,不会立即发生变化而是等事务提交后才有动画过渡,所有动画属性的修改都应处于事务中。CATransaction分为显式及隐式事务,显式的事务使用如下:

1
2
3
[CATransaction begin];
// 动画
[CATransaction commit];

动画事务可嵌套,嵌套的动画事务,只有最外层事务提交时才执行整个动画。CATransaction没有实例属性,对duration及是否动画等配置的修改会影响当前事务。对于想无动画设置属性可以设置kCATransactionDisableActions的key或+setDisableActions(停止了动画action查询)。

Core Animation在每个Runloop周期中自动开始一次新的事务,任何一次Runloop中的动画属性的修改都被集中起来,做一次0.25s(默认)的动画。这也是隐式动画的所使用事务。只有非Root Layer的可动画属性修改会触发隐式动画,因为Root Layer有关联的UIView作为其delegate,在查询对应属性key的action时返回了nil。

UIView中的类方法+beginAnimations:context:+commitAnimations以及基于block的动画接口中,都有添加了事务。

动画的创建与执行

iOS显示系统是vSync信号驱动,硬件中断信号之后,会通知到runloop,在新的一轮runloop循环中CPU完成好界面布局及绘制。对于动画,首先是上面所提的CATransaction事务会将我们认为的“动画”行为提交到Core Animation,Core Animation在runloop中注册了事件状态监听,并在回调中,将中间状态提交到GPU处理。硬件时钟的回调基本是线性时间的,这也是为什么需要“时间控制函数”的原因。另外GPU或CPU层面的处理耗时过长会导致直接丢弃当前帧渲染,表现出来就是界面卡顿。

篇幅过长,有部分简单带过,有时间再补上。

Author: Jason

Permalink: http://blog.knpc21.com/ios/ios-animation-mediatiming/

文章默认使用 CC BY-NC-SA 4.0 协议进行许可,使用时请注意遵守协议。

Comments