返回 登录
-7

iOS CoreAnimation 初探

声明: 本文来自 Qunar 技术沙龙,已获授权,版权归作者所有,未经允许,请勿转载。
作者: 史正谦,去哪儿网机票事业部 iOS 开发工程师
原文地址: http://mp.weixin.qq.com/s/X1JYfLsokaD2v4kF7kfxzA
CSDN 有奖征稿啦】技术之路,共同进步,有优质移动开发、物联网原创文章欢迎发送邮件至 mobilehub@csdn.net

CoreAnimation 是苹果提供的一套基于绘图的动画框架,关于 CoreAnimation 框架和图形学的基础知识可以参考前两篇:

  1. CoreAnimation 初探(一) —— 图形学基础
  2. CoreAnimation 初探(二) —— 初识 CALayer 与动画

还有我们团队的刘明志整理的《CoreAnimation(核心动画)概述》,大家也可以参考下。

本篇将从以下两点谈谈 CoreAnimation 的一些原理。

1. UIView 动画实现原理

UIView 提供了一系列 UIViewAnimationWithBlocks,我们只需要把改变可动画属性的代码放在 animations 的 block 中即可实现动画效果,比如:

- (void)btnClick:(id)sender
{
    [UIView animateWithDuration:1 animations:^(void){        
          if (_testView.bounds.size.width > 150)
          {
              _testView.bounds = CGRectMake(0, 0, 100, 100);
          }
          else
          {
              _testView.bounds = CGRectMake(0, 0, 200, 200);
          }
      } completion:^(BOOL finished){
          NSLog(@"%d",finished);
      }];
}

效果如下:

UIView 对象持有一个 CALayer,真正来做动画的是这个 layer,UIView 只是对它做了一层封装,可以通过一个简单的实验验证一下:我们写一个 MyTestLayer 类继承 CALayer,并重写它的 set 方法;再写一个 MyTestView 类继承 UIView,重写它的 layerClass 方法指定图层类为 MyTestLayer:

@interface MyTestLayer : CALayer
@end
@implementation MyTestLayer
- (void)setBounds:(CGRect)bounds
{
    NSLog(@"----layer setBounds");
    [super setBounds:bounds];
    NSLog(@"----layer setBounds end");
}
...
@end

@interface MyTestView : UIView
- (void)setBounds:(CGRect)bounds
{
    NSLog(@"----view setBounds");
    [super setBounds:bounds];
    NSLog(@"----view setBounds end");
}
...
+(Class)layerClass
{
    return [MyTestLayer class];
}
@end

当我们给 view 设置 bounds 时,gettersetter 的调用顺序是这样的:

也就是说,在 view 的 setBounds 方法中,会调用 layer 的 setBounds;同样 view 的 getBounds 也会调用 layer 的 getBounds。其他属性也会得到相同的结论。那么动画又是怎么产生的呢?当我们 layer 的属性发生变化时,会调用代理方法 actionForLayer: forKey: 来获得这次属性变化的动画方案,而 view 就是它所持有的 layer 的代理:

@interface CALayer : NSObject <NSCoding, CAMediaTiming>
...
@property(nullable, weak) id <CALayerDelegate> delegate;
...
@end

@protocol CALayerDelegate <NSObject>
@optional
...
/* 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

注释中说明,该方法返回一个实现了 CAAction 的对象,通常是一个动画对象;当返回 nil 时执行默认的隐式动画,返回 null 时不执行动画。还是上面那个改变 bounds 的动画,我们在 MyTestView 中重写 actionForLayer: 方法。

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event
{
    id<CAAction> action = [super actionForLayer:layer forKey:event];
    return action;
}

观察它的返回值:

是一个内部使用的 _UIViewAddtiveAnimationAction 对象,其中包含一个 CABassicAnimation,默认 fillMode 为 both,默认时间函数为淡入淡出,只包含 fromValue,即动画之前的值,会在这个值和当前值(block 中修改过后的值)之间做动画。我们可以尝试在重写的这个方法中强制返回 nil,会发现我们不写任何动画的代码直接改变属性也将产生一个默认 0.25s 的隐式动画,这和上面的注释描述是一致的。

如果两个动画重叠在一起会是什么效果呢?

还是最开始的例子,我们添加两个相同的 UIView 动画,一个时间为 3s,一个时间为 1s,并打印 finished 的值和两个动画的持续时间。先执行 3s 的动画,当它还没有结束时加上一个 1s 的动画,可以先看下实际效果:

很明显,两个动画的 finished 都为 true 且时间也是我们设置好的 3s 和 1s。也就是说第二个动画并不会打断第一个动画的执行,而是将动画进行了叠加。我们先来观察一下运行效果:

  • 最开始方块的 bounds 为(100,100),点击执行 3s 动画,bounds 变为(200,200),并开始展示变大的动画;
  • 动画过程中(假设到了(120,120)),点击 1s 动画,由于这时真实 bounds 已经是(200,200)了,所以 bounds 将变回 100,并产生一个 fromValue 为(200,200)的动画。

但此时方块并没有从 200 开始,而是马上开始变小,并明显变到一个比 100 更小的值。

  • 1s 动画结束,finished 为 1,耗时 1s。此时屏幕上的方块是一个比 100 还要小的状态,又缓缓变回到 100—3s 动画结束,finished 为 1,耗时 3s,方块最终停在(100,100)的大小。

从这个现象我们可以猜想 UIView 动画的叠加方式:当我们通过改变 View 属性实现动画时,这个属性的值是会立即改变的,动画只是展示出来的效果。当动画还未结束时如果对同个属性又加上另一个动画,两个动画会从当前展示的状态开始进行叠加,并最终停在 view 的真实位置。

举个通俗点的例子,我们 8 点从家出发,要在 9 点到达学校,我们按照正常的步速行走,这可以理解为一个动画;假如我们半路突然想到忘记带书包了,需要回家拿书包(相当于又添加了一个动画),这时我们肯定需要加快步速,当我们拿到书包时相当于第二个动画结束了,但我们上学这个动画还要继续执行,我们要以合适的速度继续往学校赶,保证在 9 点准时到达终点—学校。

所以刚才那个方块为什么会有一个比 100 还小的过程就不难理解了:当第二个动画加上去的时候,由于它是一个 1s 由 200 变为 100 的动画,肯定要比 3s 动画执行的快,而且是从 120 的位置开始执行的,所以一定会朝反方向变化到比 100 还小;1s 动画结束后,又会以适当的速度在 3s 的时间点回到最终位置(100,100)。当然叠加后的整个过程在内部实现中可能是根据时间函数已经计算好的。

这么做或许是为了让动画显得更流畅平滑,那么既然我们设置属性值是立即生效的,动画只是看上去的效果,那刚才叠加的时刻屏幕展示上的位置(120,120)又是什么呢?这就是本篇要讨论的下一个话题。

2. 展示层(presentationLayer)和模型层(modelLayer)

我们知道 UIView 动画其实是 layer 层做的,而 view 是对 layer 的一层封装,我们对 view 的 bounds 等这些属性的操作其实都是对它所持有的 layer 进行操作,我们做一个简单的实验—在 UIView 动画的 block 中改变 view 的 bounds 后,分别查看下 view 和 layer 的 bounds 的实际值:

_testView.bounds = CGRectMake(0, 0, 100, 100);
    [UIView animateWithDuration:1 animations:^(void){
        _testView.bounds = CGRectMake(0, 0, 200, 200);
    } completion:nil];

赋值完成后我们分别打印 view,layer 的 bounds:

都已经变成了(200,200),这是肯定的,之前已经验证过 set view 的 bounds 实际上就是 set 它的 layer 的 bounds。可动画不是 layer 实现的么?layer 也已经到达终点了,它是怎么将动画展示出来的呢?

这里就要提到 CALayer 的两个实例方法 presentationLayermodelLayer

@interface CALayer : NSObject <NSCoding, CAMediaTiming>
...
/* 以下参考官方api注释 */
/* presentationLayer
 * 返回一个layer的拷贝,如果有任何活动动画时,包含当前状态的所有layer属性
 * 实际上是逼近当前状态的近似值。
 * 尝试以任何方式修改返回的结果都是未定义的。
 * 返回值的sublayers 、mask、superlayer是当前layer的这些属性的presentationLayer
 */
- (nullable instancetype)presentationLayer;

/* modelLayer
 * 对presentationLayer调用,返回当前模型值。
 * 对非presentationLayer调用,返回本身。
 * 在生成表示层的事务完成后调用此方法的结果未定义。
 */
- (instancetype)modelLayer;
...

从注释不难看出,这个 presentationLayer 即是我们看到的屏幕上展示的状态,而 modelLayer 就是我们设置完立即生效的真实状态,我们动画开始后延迟 0.1s 分别打印 layerlayer.presentationLayerlayer.modelLayerlayer.presentationLayer.modelLayer:

明显,layer.presentationLayer 是动画当前状态的值,而 layer.modelLayerlayer.presentationLayer.modelLayer 都是 layer 本身。(关于 modelLayer 注释中两句话的区别还请各位指教~)

到这里,CALayer 动画的原理基本清晰了,当有动画加入时,presentationLayer 会不断的(从按某种插值或逼近得到的动画路径上)取值来进行展示,当动画结束被移除时则取 modelLayer 的状态展示。这也是为什么我们用 CABasicAnimation 时,设定当前值为 fromValue 时动画执行结束又会回到起点的原因,实际上动画结束并不是回到起点而是到了 modelLayer 的位置。

虽然我们可以使用 fillMode 控制它结束时保持状态,但这种方法在动画执行完之后并没有将动画从 layer 上移除,相当于一个一直停在终点的动画。如果我们想让动画停在终点,更合理的办法是一开始就将 layer 设置成终点状态,其实前文提到的 UIView 的 block 动画就是这么做的。

如果我们一开始就将 layer 设置成终点状态再加入动画,会不会造成动画在终点位置闪一下呢?其实是不会的,因为我们看到的实际上是 presentationLayer,而我们修改 layer 的属性,presentationLayer 是不会立即改变的:

MyTestView *view = [[MyTestView alloc]initWithFrame:CGRectMake(200, 200, 100, 100)];
    [self.view addSubview:view];

    view.center = CGPointMake(1000, 1000);

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((1/60) * NSEC_PER_SEC)), dispatchQueue, ^{
        NSLog(@"presentationLayer %@ y %f",view.layer.presentationLayer, view.layer.presentationLayer.position.y);
        NSLog(@"layer.modelLayer %@ y %f",view.layer.modelLayer,view.layer.modelLayer.position.y);
    });
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((1/20) * NSEC_PER_SEC)), dispatchQueue, ^{
        NSLog(@"presentationLayer %@ y %f",view.layer.presentationLayer, view.layer.presentationLayer.position.y);
        NSLog(@"layer.modelLayer %@ y %f",view.layer.modelLayer,view.layer.modelLayer.position.y);
    });

在上面代码中我们改变 view 的 center,modelLayer 是立即改变的因为它就是 layer 本身。但 presentationLayer 是没有变的,我们尝试延迟一定时间再去取 presentationLayer,发现它是在一个很短的时间之后才发生变化的,这个时间跟具体设备的屏幕刷新频率有关。也就是说我们给 layer 设置属性后,当下次屏幕刷新时,presentationLayer 才会获取新值进行绘制。因为我们不可能对每一次属性修改都进行一次绘制,而是将这些修改保存在 model 层,当下次屏幕刷新时再统一取 model 层的值重绘。

如果我们添加了动画,并将 modelLayer 设置到终点位置,下次屏幕刷新时,presentationLayer 会优先从动画中取值来绘制,所以并不会造成在终点位置闪一下。

总结

UIView 持有一个 CALayer 负责展示,view 是这个 layer 的 delegate。改变 view 的属性实际上是在改变它持有的 layer 的属性,layer 属性发生改变时会调用代理方法 actionForLayer: forKey: 来得知此次变化是否需要动画。对同一个属性叠加动画会从当前展示状态开始叠加并最终停在 modelLayer 的真实位置。

CALayer 内部控制两个属性 presentationLayer 和 modelLayer,modelLayer 为当前 layer 真实的状态,presentationLayer 为当前 layer 在屏幕上展示的状态。presentationLayer 会在每次屏幕刷新时更新状态,如果有动画则根据动画获取当前状态进行绘制,动画移除后则取 modelLayer 的状态。

Demo 代码地址:https://github.com/Shizq5509/CADemo

参考资料


了解最新移动开发、VR/AR 干货技术分享,请关注 mobilehub 微信公众号(ID: mobilehub)。

mobilehub

评论