CoreAnimation初探(四) —— 深入理解动画时间
之前只是不断的提到时间是个很重要的东西,并没有过多深入探讨,因为我自己理解的也不是很深(→_→)。最近又详细学了下,有一些新的理解。
1.谈谈动画
先撇开技术,想想自己看到“动画”两个字的时候首先想到的是什么?
反正我想到的是死神、火影、海贼王,哈哈😅。当初火影漫画完结的时候真是又想看结局,又想等着看动画。谁知一等几年过去了,也不知道动画现在完结了没。。。
为什么比起漫画我更想看动画呢?很简单啊,漫画是静止的,哪会有动画看着爽快。其实动画就是干这件事的,让静止的画面“动起来”,英文animate的意思就是“赋予...以生命”。再想想“动”,动就是指位置发生了变化,初中物理老师告诉我们,位移等于速度乘以时间。要想动起来肯定要经历时间的(这不废话吗,博尔特跑的再快不给他时间能到终点吗?。。) 所以动画注定与时间难解难分。
早在两万五千年前的石器时代,人类就在洞穴上画了野牛奔跑的动作图,捕捉不同时间牛的动作。而真正推动动画产生的是1824年英国伦敦大学教授皮特·马克·罗葛特发现的视觉暂留现象。利用人的视觉暂留特性,播放一个连续动作的多个瞬间画面,就会造成流畅的视觉变化效果,这就是动画。
咳..扯多了。。。回到我们程序员熟悉的画风,怎么实现一个动画呢?最简单的办法,隔个零点零几秒改变一次,不就动起来了吗?这就是基于定时器的动画。我们的目的是获取动画过程的不同时间点来做变化,其实还可以借助屏幕刷新。(记得我们玩游戏时通常会有一个选项“垂直同步”么?如果我们打开垂直同步,就会根据显示器的频率在每次刷新时更新画面。)又扯远了,CoreAnimation提供了一个实现同步刷新的类CADisplayLink,我们可以借助它获取屏幕刷新的信号:
// 便利构造方法生成一个CADisplayLink,并指定target和selector,屏幕刷新时回调
_displk = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplay1:)];
// 需要它加入runloop中
[_displk addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
简单的两行就可以同步屏幕刷新了。首先要记录动画开始时的时间:
_beginTime = CACurrentMediaTime();
这个CACurrentMediaTime()是获取CoreAnimation中的当前时间,后面会细说。然后在onDisplay1:得到时间差计算当前状态,比如我们想实现一个view在1s内从页面上方移动到底端,如果是匀速的:
- (void)onDisplay1:(CADisplayLink *)displk
{
NSTimeInterval duration = 1;
CGFloat fromY = 180;
CGFloat toY = self.view.frame.size.height - 25;
NSTimeInterval showedTime = CACurrentMediaTime() - _beginTime;
CGFloat percent = showedTime/duration;
if (percent > 1)
{
percent = 1;
[displk invalidate];
}
CGFloat nowX = _testView.center.x;
CGFloat nowY = fromY + percent*(toY - fromY);
_testView.center = CGPointMake(nowX, nowY);
}
很简单,当前时间过去了多少,距离就移动多少。需要注意的时,当我们不使用DisplayLink时需要执行[displk invalidate]方法将其从runloop中移除,这里当时间到达1s后就执行invalidate。如果想实现重力的匀加速效果也是一样,只要回顾下物理课上学过的公式能计算出某时刻所处的状态即可,效果如下:
这里由于gif图片的原因显得有些卡顿,实际上是很流畅的。实现一个动效就这么简单,但这些还远远不够。站在开发者的角度,稳定性、性能等都是必须考虑的,我们先从最简单的方面看看CoreAnimation是怎么对时间建模封装的。
2.从概念到代码
刚才那段代码,最关键的就是CACurrentMediaTime()了,通过它我们记录动画开始的时间,在每次屏幕刷新时算出经过了多长时间从而改变view的位置,到达事先指定的持续时间1s时停止。这个函数定义在CABase.h中:
/* Returns the current CoreAnimation absolute time. This is the result of
* calling mach_absolute_time () and converting the units to seconds. */
/* 返回当前CoreAnimation绝对时间。为mach_absolute_time()转换为秒的结果*/
CA_EXTERN CFTimeInterval CACurrentMediaTime (void)
__OSX_AVAILABLE_STARTING (__MAC_10_5, __IPHONE_2_0);
绝对时间和相对时间也比较好理解,比如今天是2017年2月25日,在漫漫历史长河中指的就是今天;再比如我大学毕业1年了,这是一个相对时间。为什么要建立这种分层的时间系统,可以再回想下物理课上选择参照物的重要性。先看看官方的介绍:
CAMediaTiming协议由图层和动画来实现,它建立了一个分层的时间系统模型,每个对象描述从父对象时间到本地时间的映射。
从父时间到本地时间的转换有两个阶段:
1.到“本地活动时间(active local time,不知道该怎么翻译,自行理解吧。。)”的转换:包括对象在父时间轴中出现的点以及相对于父对象的速度。
2.到“基本本地时间(basic local time)”的转换:时间模型允许对象重复其基本持续时间多次,并且可选地在重复之前向后播放。
就像我们在做界面布局时,视图的位置都是基于父视图的,这样方便我们构建复杂的界面。建立分层的时间系统也是为了方便实现复杂的动画效果,比如一个在火车上一边行走一边扇扇子的人,人的父对象(或者说参照物)是火车、扇子的父对象是人。从火车开动起可以看成absolute time,人从座位上站起来开始行走起为active local time,而人每扇动一次扇子对应的就是basic local time。(当然我们开发app一般是不会有这么复杂的需求的,如果有干脆拍摄一段好了...)
干说概念可能比较晦涩,我们通过一个简单的例子深入理解一下。下面两张图是网上随处可见的关于timeOffset的例子。
第一张图里通过拖动滑块,改变layer的timeOffset将动画的进度可视化,主要代码如下:
//...
[_slider addTarget:self action:@selector(onSliderChanged:) forControlEvents:UIControlEventValueChanged];
//...
- (void)onSliderChanged:(id)sender
{
_testLayer.timeOffset = _slider.value;
}
第二张图是通过timeOffset实现动画的暂停和继续,主要代码如下:
- (void)pauseAnimation
{
_testLayer.timeOffset = [_testLayer convertTime:CACurrentMediaTime() fromLayer:nil];
_testLayer.speed = 0;
}
- (void)continueAnimation
{
_testLayer.beginTime = CACurrentMediaTime() - _testLayer.timeOffset;
_testLayer.timeOffset = 0;
_testLayer.speed = 1;
}
看起来很简单的样子,但要搞明白其中的原理却也不容易。可以试着把这两个效果合在一起,既可以滑动滑块控制进程,又可以点击按钮让它继续播放:
如果不明白原理,只是将两个demo写到一起,并不能达到想要的效果。(如果到这里你脑中已经清晰的浮现出如何实现的代码,那就可以不用往下看了~下面这些看法不保证是正确的,您最好只将它当做一个参考)
这里一共涉及到了时间的三个属性:beginTime、speed、timeOffset。我们先看一下官方的解释:
/* The begin time of the object, in relation to its parent object, if
* applicable. Defaults to 0.
* 对象的开始时间(动画开始之前的延迟时间),相对于父对象而言。 */
@property CFTimeInterval beginTime;
/* 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.
* 控制动画的执行速度,也是相对于父对象 */
@property float speed;
/* 时间偏移量,下文详细讨论 */
@property CFTimeInterval timeOffset;
父时间tp和本地活动时间(active local time)t之间的关系为:t = (tp - begin) * speed + offset。这个公式暂且放在这里,先来看看第一个滑块的例子,当滑块valueChange的时候,取滑块的值(0到1之间)赋给了timeOffset,显然是将timeOffset作为了一个duration的相对值;而在第二个暂停的例子中,timeOffset用的是绝对时间转换到layer上的值,如果在运行中查看这个值,可以发现它并不是0到duration的相对值(而是一个很大的值)。
这里就有疑问了,timeOffset到底是怎么定义的?上面两个例子的代码(从官方定义上讲)是否都是正确的?
时间和空间最大的区别在于,时间不能被复用 -- 弗斯特梅里克
仔细想想,这个timeOffset不好理解的原因可能就是因为它试图表示已经逝去的时间。使用timeOffSet实现暂停、继续的原理大概如下图所示(凑合看吧。。)
继续时相当于把暂停经历的时间挪到前面让layer接着暂停时的状态继续活动。这么看来第二个例子暂停、继续是没毛病的,timeOffSet在这里是相当于取的layer层面的“绝对时间”。那第一个例子又该怎么解释?
我尝试在滑动滑块时加入动画并把layer的speed设置为0,使用滑块的value按相对值改变timeOffSet,然后让动画继续,惊奇的发现它仍然会从当前位置继续活动!这里确实困扰了我很久,怎么也闹不明白这个timeOffSet在CA里面是怎么运作的。后来想想一开始把滑块改变timeOffSet当做相对值就错了,因为在滑块那个例子中,动画扔到layer上,layer的速度设置为0,动画压根就没有执行,这时改变timeOffSet对动画来讲刚好相当于一个相对值,因此按继续的逻辑它仍会正常往前走。如果这个动画已经执行了一段时间再按照第一个例子的方法改变timeOffSet就不会有效了。
所以,对timeOffSet的理解和使用请参考第二个例子(暂停、继续)和上面那张图,第一个滑块的例子只是刚好表现正常而已,正确的使用滑块可视化动画进程的方法可以参考demo。(这里说一下,第一个例子本意是为了描述animation的timeOffSet,CAAnimation和CALayer都实现了CAMediaTiming协议,都有时间相关的属性,参考前文提到的active local time和basic local time)
3.从设计到实现
上面说的那么多算是我陷入理解误区钻牛角尖的一个过程吧,我觉得比起直接说结论,这个过程更能帮助理解关于动画时间的一些概念。毕竟CA只是人家封装好的一个框架,理解其中面向对象建模的原理才能更好的掌握这门技术,在接触别的动画库甚至自己写相关代码时也能有理论基础,举一反三。
其实任何技术都是这样,再花哨的东西最终都是一堆0和1。就拿动画来讲,首先是对真实世界现象的研究,物理学家和数学家告诉我们它是什么,遵循什么规律;计算机科学家提出的面向对象思想,我们可以用编程语言来对它进行建模;设计师设计出的酷炫的动画效果,从根本上讲也就是这些自然现象的组合,所以我们做的不过是把设计师的思想映射到这些基本原理上。
总结
参考上面那张关于timeOffSet的手图。。。
参考资料
性能和时间
iOS版动画 - 从不会到熟练应用
iOS开发之动画中的时间
iOS-Core-Animation-Advanced-Techniques.pdf
iOS-Core-Animation-Advanced-Techniques中文翻译