×

解析 iOS 动画原理与实现

96
胖花花
2015.11.26 15:49 字数 4093

这篇文章不会教大家如何实现一个具体的动画效果,我会从动画的本质出发,来说说 iOS 动画的原理与实现方式。

什么是动画

动画,顾名思义,就是能“动”的画。
人的眼睛对图像有短暂的记忆效应,所以当眼睛看到多张图片连续快速的切换时,就会被认为是一段连续播放的动画了。

比如,中国古代的“走马灯”,就是用的这个原理。
有些人还会在一个本子每页上手绘一些漫画,当快速翻页的时候,也会看到动画的效果,比如:


图片来自网络

计算机动画的实现方式

动画是由一张张图片组成的,在计算机中,我们称每一张图片为 一帧画面

如果我们想实现这么一个动画:一个水杯放在桌子的左边,移动到右边,那么我们实际操作的,只是水杯。
所以动画的实现,只是对运动变化了的部分的处理。

逐帧 与 关键帧

类似于上面提到的手绘翻页方式,我们可以将这个水杯在每帧画面中的位置一一找出来,这样实现动画的方式就叫作 逐帧动画,我们需要处理动画中的每一帧。

我们一般在计算机上用 FPS ( Frames Per Second) ,即 每秒的帧数 来表示动画的刷新速度,基于屏幕的刷新率等其他原因,在计算机上一般采用 60 FPS。
如果运动变化幅度较缓,减半到 30 FPS 时,我们肉眼也是可接受的。
较低的 FPS 会让我们有“卡顿”的感觉。

逐帧动画是最直接的,但要处理的帧数太多,所以实现过程是会麻烦。

计算机的工作就是来完成重复单调的工作的,所以,有些工作是可以考虑让计算机来完成的。


上面的例子,可以变成一个涉及数学和物理的问题:一个杯子初始位置在左边,n秒后匀速运动到右边,那么在每 1/60 秒的时候,这个杯子的位置显然是可以计算出来的了。
所以,我们其实只需要指定一些 关键 信息就能让计算机自己计算出每一帧杯子的位置了:

  • 起始位置,比如一个坐标 (0,0)
  • 结束位置,再比如一个坐标 (100,0)
  • 动画总时间,比如 0.25 秒
  • 匀速运动

这种方式就称之为 关键帧动画。即我们只需要给定几个关键帧的画面信息,关键帧与关键帧之间的过渡帧都将由计算机自动生成。

这里说的 关键帧动画,是指的广义上的一种动画制作方式,并不仅指 CAKeyframeAnimationCABasicAnimation的实现方式也属于 关键帧动画

iOS 动画

说完广义上的动画,就可以来说说 iOS 的动画了。
先来说说动画的本质。

动画的本质

继续用上面的简单例子:一个 UIView 从 (0,0) 匀速移动到 (100,0)的动画,动画总时间是0.25秒。
假设我们基于 60 FPS 来显示动画,那么在0.25秒内就应该有15帧画面,在每帧画面中,这个 UIViewx坐标,每次应移动 100/15 的距离。
如果我们每隔 0.25/15 秒刷新一次UIViewx坐标,那么就能实现这个动画效果了。
对于 x坐标而言,每帧的位置就可以通过一个基于时间变化量的函数来求得:x=f(t)

所以,一个动画的本质,就是动画对象(这里是 UIView)的状态,基于时间变化的反应了。
简单说,就是给定任意一个时刻,如果你都能得到这个动画对象的位置和、形状等等属性,你就能实现这个动画了。
属性值的变化,既可能是位置、透明度、旋转角度等的变化,也包括形状的改变,比如从一条直线变化成一个圆圈,目标就是要得到变化过程中特定时刻的中间态。

动画的实现

我们也可将 iOS 的动画分为两大类:

  • 系统提供的 关键帧动画 实现方式;用户指定 关键 信息,系统实现动画过程,对用户而言操作起来会简单些。
  • 逐帧动画 实现方式;用户自己 出每一帧画面,系统操作方法简单,但用户操作的工作量就会大一些。

逐帧动画实现方式

简单的说,要实现逐帧的方式,就是需要 周期性 的调用 绘制 方法,绘制每帧的动画对象。

这里说的 绘制,不光是指覆写 UIView- drawRect:的方法来手动重绘视图,也包括修改 UIView 它的属性,比如位置、颜色等。

iOS 的动画都是基于 CALayer 的,iOS 的 UIView 背后都有一个对应的 CALayer 。对 UIView 的修改实际上都是对背后 CALayer 的修改。
但如果在逐帧绘制的方法中修改了一个自建的 CALayer,这个 CALayer 不是对应某个 UIView 的,需注意系统的 隐式动画 的影响,后面会提到这点。

周期性,就需要一个定时器来完成了,即 CADisplayLink
CADisplayLinkNSTimer 比较类似,可以周期性的调用指定的方法。
之所以用 CADisplayLink,是因为它是基于屏幕刷新率的,即屏幕每次刷新时就会触发调用。
iPhone 的屏幕刷新率是 60 FPS。

如果绘制过程过于复杂,不能在屏幕刷新一帧的时间内完成,可以考虑改为每隔一帧绘制,相当于是 30 FPS的刷新率。
不然可能会使动画不连贯,有卡顿感。

用逐帧方法绘制的原理不是很麻烦,麻烦的是绘制过程。
对于一个复杂动画,你可能需要运用各种物理、几何知识去计算视图中间状态的信息。
比如要实现一条直线卷曲变化为一个圆的动画,你就需要计算出中间态的曲线的弯曲程度和位置。

著名的 facebook 的 pop 动画框架,就是使用 CADisplayLink 这种逐帧绘制的方式实现的。

关键帧动画实现方式

采用关键帧的方式来实现动画,要讲的内容相对逐帧的方式就多的多了。

还是用 UIView 移动的简单例子。
这里面有两个关键帧,起始帧和结束帧,除此之外还有2个关键信息:

  • 起始帧,变化信息:坐标为 (0,0)
  • 结束帧,变化信息:坐标为 (100,0)
  • 动画时间,0.25秒
  • 匀速运动

坐标 信息是 UIView 的一个属性(实际是对应到 CALayer 的属性),在动画实现里,我们只需要指定起始和结束的两个关键值就够了,中间的过渡值都有系统自动生成。
这里出现了两种值,一个是我们设定的,一个是系统生成的,所以要先在这里插入一个 模型层展现层 的概念了

CALayer 的同一个属性值,会分别保存在模型层 modelLayer ,和展现层 presentationLayer 中。当我们修改属性值时,是修改的模型层的数值,动画时系统根据模型层的变化,生成的过渡值,是保存在展现层中的。

CALayer 的对象里能直接访问到这两层的信息。
CALayer 的底层实现实际不止这两层,但我们现在讨论动画的时候,可以只关心这两层。

在整个动画过程中,呈现出来的过程是这样的:

  1. 动画前,显示模型层的当前值;
  2. 动画开始,切换显示展现层的值;
  3. 动画过程中,展现层的值根据时间变化,我们看到的实际是展现层的值在变化;
  4. 动画结束,切换回显示模型层的值,此时模型层的值应被修改为动画结束时的值。

用一段代码来解释下动画过程。

    UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
    view.backgroundColor = [UIColor redColor];
    [self.view addSubview:view];

    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position"];
    animation.fromValue = [NSValue valueWithCGPoint:CGPointMake(50, 0)];
    animation.toValue = [NSValue valueWithCGPoint:CGPointMake(150, 0)];
    [view.layer addAnimation:animation forKey:nil];

//    view.frame = CGRectOffset(view.frame, 100, 0);

你会发现动画结束后,view 又跳回了原来的位置,这是因为最后一行代码注释了,而这行代码的功能就是实现第4步,将模型层的值修改为动画结束时的值。

动画实现

代码中的 CABasicAnimation 就是真正的动画实现部分,也就是设定关键帧信息的地方。

将动画加入 CALayer 的代码定义为:
- (void)addAnimation:(CAAnimation *)anim forKey:(NSString *)key
接受的类型是 CAAnimation 类型,有下面这些子类:

  • CABasicAnimation,可设定起始结束两个关键帧的信息。
  • CAKeyframeAnimation,除首尾外,还可添加多个中间关键点。
  • CAAnimationGroup ,可组合多个动画,因为上面两种动画一次只能设置一个属性值。
  • CATransition,图层过渡动画,默认是淡入。比如修改一个 CALayer的背景色时,是从初始色慢慢淡入过渡到结束色。
    可修改为新颜色把旧颜色顶出去等效果。还可使用 CIFilter 滤镜做过渡效果,一些开源 UIViewController 的过渡动画使用了这种方式。

动画中,除了属性值外,我们还设置了两个和时间有关的信息:动画时间0.25秒,运动方式是匀速运动。

动画持续时间很简单,是通过 CAAnimation 遵守的 CAMediaTiming 协议设定的。

匀速运动是通过设置 CAAnimationtimingFunction 实现的,这是一个 CAMediaTimingFunction 类的对象。

之前已经说到,动画过程实际是一个时间的函数,横坐标是时间的变化值,纵坐标是动画属性的变化量。那么我们就可以在一个直角坐标系中,通过作图来画出这个函数。比如匀速运动的图形,就是一条通过原点的直线。

所以这个类的功能就是画出一条曲线,来表示时间和属性变化之间的关系。而画图的方法,是使用的是画贝叶斯曲线的方法。

系统提供了几个常用的函数,比如 kCAMediaTimingFunctionLinear 就是匀速运动;kCAMediaTimingFunctionEaseInEaseOut 就是一般系统动画的默认值,渐入渐出,即在动画开始和结束的时候速度稍慢些。


图片来源自网络

隐式动画

上面的过程,我们是 显式 的向一个 CALayer 添加了一个动画,所以这种方式叫做 显式动画
对应的,还有 隐式动画,即系统自动添加上的动画。

    CALayer *layer = [CALayer layer];
    layer.backgroundColor = [UIColor greenColor].CGColor;
    layer.frame = CGRectMake(0, 0, 100, 100);
    [self.view.layer addSublayer:layer];

    layer.frame = CGRectOffset(layer.frame, 100, 0);

这段代码里,我们没有添加 CAAnimation 动画,但 layer 不是直接变化到新的位置,而是有一个动画效果。
这就是 隐式动画 的效果。

当我们改变 CALayer 的一个可动画的属性值时,就会触发系统的隐式动画。
可动画的属性值,可以在 CALayer 的文档中找到,属性说明中标有 Animatable 的,就是可自动添加动画的属性。

但是,有一个例外,对于 UIView 背后对应的 CALayer,系统关闭了隐式动画,所以当我们直接修改 UIView 或者是其底层的 CALayer 时,变化是直接生效的,没有动画效果。

所以当我们在逐帧方式生成动画时,是可以直接修改 UIView 或者是其底层的 CALayer 的信息。
但是如果修改的是一个自建的单独 CALayer 时,帧与帧之间的变化还是会触发系统的默认隐式动画,这个时候就需要我们来手动关闭隐式动画。
当快速动画的时候不会察觉到这点,但这明显会带来性能上的浪费。

隐式动画所做的事情和显示动画是一样的,我们设置的属性值都是模型层的数值,而系统会自动添加属性对应的 CAAnimation 动画到 CALayer 上。

UIView 有一系列的 animateWithDuration 动画方法,在这些方法中 UIView 会恢复隐式动画,所以在动画的 block 中修改属性时,又会触发隐式动画。


那么系统是如果知道对一个属性应该添加哪种动画呢,这就需要让 CAAction 协议登场了。

当修改一个 CALayer 的属性时,它会通过 - actionForKey: 来查询这个属性对应的 action,而 key 就是对应的属性名称。
CAAnimation 遵守 CAAction 协议,返回的 action 其实是个 CAAnimation 动画。
也就是说, CALayer 通过 - actionForKey: 来查询某个属性被修改时,需要调用哪个动画去展现这个变化。
一般默认返回的是 CABasicAnimation ,默认动画时间 0.25秒,时间函数为渐入渐出 kCAMediaTimingFunctionEaseInEaseOut。

- actionForKey: 查询 action 的步骤有4步,在这个方法中有详细的说明。
其中一种方式就是通过 CALayer 的 delegate 返回 action。而对于 UIView 背后对应的 CALayer,其代理就是它对应的 UIViewUIView 就是用这种方式关闭了隐式动画。

动画事务

创建动画事务的目的是为了操作的原子性,保证动画的所有修改能同时生效。
CATransaction 就是动画事务的操作类。

在创建隐式动画的时候,系统也会隐式的创建一个动画事务,以保证所有的动画能同时进行。

除此之外,还可以显式的创建一个事务。
显式事务中可以定义事务中所有动画的运行时间和时间函数,此外,还有这个方法 + (void)setDisableActions:(BOOL)flag 能显式的关闭这个事务中的 action 查询操作。
关闭了查询也就是关闭了动画效果,属性值的变化就会立即生效,而没有动画效果了:

    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    ///...
    layer.frame = CGRectOffset(layer.frame, 100, 0);
    ///...
    [CATransaction commit];

注意别把 CATransaction 和 CATransition 搞混了,一个单词是 transaction 事务,另一个是 transition 转变。

对比 总结

关键帧动画的实现方式,只需要修改某个属性值就可以了,简单方便,但涉及的深层次内容较多,需要更多的理解和练习。

采用逐帧动画的实现方式,实现原理简单,但绘制动画的过程要复杂。如果动画过程处理的事情较多,也会带来较大的开销,就有可能造成动画帧数的下降,出现卡顿的现象,因此需要较多的测试和调试。
动画绘制的过程中,会要求较多的数学、物理等知识来计算中间态的数据。

但这两种方式也不是绝对分离开的。
关键帧动画实现方式,一般只能对系统实现了可动画的属性做动画处理,但其实也是允许实现自定义属性的动画处理的。
这就需要自己来实现系统中自动计算过渡帧的操作了,也就是逐帧实现动画的方式了。
实现自定义属性的动画可以参考这篇文章: Layer 中自定义属性的动画

对于 iOS 系统提供的动画方法,上面只是从整体的角度作了一个全面的整理,还有很多细节内容没有写出来,比如 CALayer 的三维变换、CAKeyframeAnimation 的延路径动画,CAMediaTiming 的时间控制,等等。感兴趣的话,可以再看看这些内容:

iOS
Web note ad 1