iOS CoreAnimation 初探

本文由我们团队史正谦 童鞋总结分享。


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

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

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

1.UIView动画实现原理

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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时,getter、setter的调用顺序是这样的:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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:方法

1
2
3
4
5
- (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的实际值:

1
2
3
4
_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的两个实例方法presentationLayer和modelLayer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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分别打印layer,layer.presentationLayer,layer.modelLayer和layer.presentationLayer.modelLayer :
明显,layer.presentationLayer是动画当前状态的值,而layer.modelLayer 和 layer.presentationLayer.modelLayer 都是layer本身。(关于modelLayer注释中两句话的区别还请各位指教~)

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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代码地址

参考资料