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。如果想实现重力的匀加速效果也是一样,只要回顾下物理课上学过的公式能计算出某时刻所处的状态即可,效果如下:


CoreAnimation初探(四) —— 深入理解动画时间_第1张图片

这里由于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的例子。


CoreAnimation初探(四) —— 深入理解动画时间_第2张图片

第一张图里通过拖动滑块,改变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实现暂停、继续的原理大概如下图所示(凑合看吧。。)


CoreAnimation初探(四) —— 深入理解动画时间_第3张图片

继续时相当于把暂停经历的时间挪到前面让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的手图。。。

Demo

参考资料

性能和时间
iOS版动画 - 从不会到熟练应用
iOS开发之动画中的时间
iOS-Core-Animation-Advanced-Techniques.pdf
iOS-Core-Animation-Advanced-Techniques中文翻译

你可能感兴趣的:(CoreAnimation初探(四) —— 深入理解动画时间)