概述
这篇文章主要想尝试解释一下“为什么Lottie动画无法使用AVVideoCompositionCoreAnimationTool导出“。有些说法是自己的理解,可能不会十分准确。如果有不正确的地方,可以一起讨论。
首先,为什么想要知道“Lottie动画无法使用AVVideoCompositionCoreAnimationTool导出”的原因呢?因为如果知道无法导出的原因,我们就可能可以通过“修改Lottie”,或者“调整AVVideoCompositionCoreAnimationTool或相关类的使用方式”等,从而让Lottie动画可以使用AVVideoCompositionCoreAnimationTool导出。如果可以的话,我们就可以:
- 更简单的方式实现导出Lottie动画;
- 可能获得更好的导出性能。因为,按我现在的理解,通过AVVideoCompositionCoreAnimationTool导出,动画layer是通过GPU渲染的;而不使用AVVideoCompositionCoreAnimationTool导出Lottie动画的话,核心就是需要根据视频时间渲染相应的Lottie动画帧,这一步是通过CPU渲染的。
那么,为什么Lottie动画无法使用AVVideoCompositionCoreAnimationTool导出呢?简单来说,就是因为“Lottie的实现方式”和“AVFoundation的视频合成逻辑”决定了Lottie动画无法使用AVVideoCompositionCoreAnimationTool导出。后面会逐步说明原因。
Core Animation的一些知识点
CAMediaTiming
CALayer和CAAnimation都实现了CAMediaTiming。如果能理解CAMediaTiming的属性,特别地,beginTime、speed等,可以更好的理解相关内容。之前有写过一篇关于beginTime的文章,有需要的话可以看一下理解CAMediaTiming的beginTime。
CALayer的动态属性
CALayer有一个特性,支持动态属性。例如,在继承CALayer的子类里声明一个属性,并且在实现里用@dynamic声明这个属性。然而,当你使用属性时,并不会崩溃。因为,虽然没有相应的get/set方法,但CALayer会在运行时自动为你实现。下面这个是CALayer头文件里的一些说明:
/* CALayer implements the standard NSKeyValueCoding protocol for all
* Objective C properties defined by the class and its subclasses. It
* dynamically implements missing accessor methods for properties
* declared by subclasses.
而且,可以为这个动态属性应用动画,即,可以通过动画改变这个属性的值。
CALayer的needsDisplayForKey类方法
CALayer支持一种机制,可以做到“当属性的值变化时,可以触发调用CALayer的display方法”。那么,我们就可以做到,当某个属性值变化时,根据这个属性的新值对UI做相应的更新(例如,可以的display方法里设置其他UI属性,或在drawInContext方法里用Core Graphics API画新的内容)。实现方式很简单,只需要在CALayer子类重写needsDisplayForKey类方法,对相应的属性名返回YES即可。有需要的话可以看一下needsDisplayForKey方法和display方法的说明。
需要注意的是,如果为某个属性实现了needsDisplayForKey方法,那么,通过动画改变属性时,同样会触发display方法。理解这个“needsDisplayForKey机制“很重要,因为Lottie的实现根本就是使用这个机制。
“在线渲染(online)”
这里说的“在线渲染”指的就是APP显示在屏幕的那颗layer tree,或叫visual tree可视化树的渲染。这里想说明几点内容:
- layer有一个属性presentationLayer。一般来说,我们可以认为,只有当layer在可视化树上时,presentationLayer才不会返回空(但不是只有这种情况才不为空,后面“离线渲染”会看到);
- 如果presentationLayer不为空,可以认为layer是committed状态。committed已提交,可以表示为layer相关数据已提交到iOS的Render Server进程。Render Server进程可以调用图形API,进行GPU渲染相关工作;
- “动画模块”会根据“当前时间CACurrentMediaTime()”+“动画的配置”去更新相应的属性的值,更新的值会反应在presentationLayer(这里主要说的是属性动画)。“动画模块”只会对commited的layer进行工作。而且,“动画模块”的更新间隔就是屏幕刷新频率。
下面这个是CALayer头文件里对presentationLayer的一些说明:
/* Returns a copy of the layer containing all properties as they were
* at the start of the current transaction, with any active animations
* applied. This gives a close approximation to the version of the layer
* that is currently displayed. Returns nil if the layer has not yet
* been committed.
“离线渲染(offline)”
这里的“离线渲染”指的是非可视化树的layer的渲染。什么时候会有这种情况呢?使用AVVideoCompositionCoreAnimationTool时,就属于这种情况。因为传递的layer是不在可视化树上的。这里想说明几点内容:
- 有一个时间参数,表示想渲染的时间点;
- 如果layer上有动画,那么会根据动画有效期是否在时间参数里,来决定是使用layer上的属性值还是对应属性动画在这个时间参数里的值去渲染;
- “离线渲染”时,layer的presentationLayer是不为空的。那么,layer是属于committed状态的;
- 既然layer属于committed状态,如果layer上添加了动画,“动画模块”的逻辑也是要起作用的。这里的关键是“动画模块”是否会更新属性的值(如果更新,会反应在presentationLayer),取决于layer的动画是否在有效期(根据当前时间CACurrentMediaTime()是否在layer的动画的时间范围里);
- 需要注意的是,“离线渲染”中的“根据时间参数,获取属性值”的操作,是不会反应在layer和presentationLayer上的;另一方面,对于“动画模块”的逻辑,如果值有更新,新值就会反应在presentationLayer。
Lottie的实现方式
Lottie V2的实现方式
Lottie V2实现方式指的是Lottie从版本2.0.0开始到现在的Swifit版本的实现方式。最核心的就是使用前面提到的“CALayer的needsDisplayForKey机制”。简单来说就是:
- 核心的CALayer子类,增加一个动态属性customFrame,表示第几帧;实现needsDisplayForKey方法,对customFrame返回YES;在display方法里根据customFrame的值,驱动各个子节点的绘制;
- 当播放Lottie动画时,关键就是对这个核心layer增加一个customFrame的属性动画。
下面是版本2.5.3的相关逻辑(2.5.3是OC版本,Swift版本也是类似的逻辑):
// LOTLayerContainer.m
+ (BOOL)needsDisplayForKey:(NSString *)key {
if ([key isEqualToString:@"currentFrame"]) {
return YES;
}
return [super needsDisplayForKey:key];
}
...
- (void)display {
@synchronized(self) {
LOTLayerContainer *presentation = self;
if (self.animationKeys.count &&
self.presentationLayer) {
presentation = (LOTLayerContainer *)self.presentationLayer;
}
[self displayWithFrame:presentation.currentFrame];
}
}
// LOTAnimationView.m
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"currentFrame"];
animation.speed = _animationSpeed;
animation.fromValue = fromStartFrame;
animation.toValue = toEndFrame;
animation.duration = duration;
animation.fillMode = kCAFillModeBoth;
animation.repeatCount = _loopAnimation ? HUGE_VALF : 1;
animation.autoreverses = _autoReverseAnimation;
animation.delegate = self;
animation.removedOnCompletion = NO;
if (offset != 0) {
CFTimeInterval currentTime = CACurrentMediaTime();
CFTimeInterval currentLayerTime = [self.layer convertTime:currentTime fromLayer:nil];
animation.beginTime = currentLayerTime - (offset * 1 / _animationSpeed);
}
[_compContainer addAnimation:animation forKey:kCompContainerAnimationKey];
需要注意几点:
- 对customFrame这个动态属性增加动画。按前面“在线渲染”提到,在动画有效期里,customFrame的更新的值会反应在layer的presentationLayer里;
- 使用“needsDisplayForKey机制”,那么当customFrame变化时,就会相应调用display方法,display方法会更加customFrame的最新值(注意,会优先取presentationLayer里的值)去驱动绘制;
- 那么,如果customFrame动画没有生效,那么customFrame的值就不会更新,那么Lottie动画就不会更新。
Lottie V1的实现方式
Lottie V1的实现方式指的是Lottie在版本1.0.0+的实现方式。与V2不同,没有使用“CALayer的needsDisplayForKey机制”。简单来说,是将After Effect动画直接翻译为Core Animation动画。从这方面来说,V1的实现方式是可以用AVVideoCompositionCoreAnimationTool导出的。但是,缺点是低版本对After Effect动画的支持有限,而且有部分动画效果还是用到了“needsDisplayForKey机制”。如果Lottie动画简单,而且没有用到需要“needsDisplayForKey机制”的动画效果,理论上来说,是可以使用V1版本的。这里主要还是说明V2的情况。
AVFoundation的视频合成逻辑
导出视频时,需要将“视频帧”和“与视频帧时间对应的layer帧”合成起来。这里可以认为是属于“离线渲染”的逻辑。可以想象可能会有这样的操作:“offline_render(time, layer)”,根据某个视频时间点来渲染这个layer。根据之前提到的“离线渲染”的逻辑,有以下几点说明:
- time为视频帧的时刻。例如,对于帧率为30的视频,第一帧的时间为0,第二帧的时间为0.033333333333333333;
- layer为要渲染的层。如果layer上有动画,而且动画在这个时间是有效的,那么会直接使用动画在这个时间点的值来渲染。并且,layer和presentationLayer对应的属性值是不会反应这个新值的;
- “动画模块”也会看对于当前时间CACurrentMediaTime()(注意这个当前时间不是我们输入的视频帧的时刻),layer上的动画是否有效,如果有效就会更新相应的属性值,更新的值会反应在presentationLayer。需要注意的是,“动画模块”的更新间隔就是屏幕刷新频率;
- 正常情况来说,layer上的动画都不会也不应该在当前时间CACurrentMediaTime()上有效。因为对于导出视频的情况,layer上的动画应对于视频时间点的,而视频时间点都是比较小的,而CACurrentMediaTime()是比较大。了解这种情况,就可以解释后面提到的“错误的使用方式”。
分析原因
根据上面的分析,应该就可以得出:因为“Lottie的实现方式”和“AVFoundation的视频合成逻辑”决定了Lottie动画无法使用AVVideoCompositionCoreAnimationTool导出。视频导出时,因为“离线渲染”的逻辑,会得到新的customFrame的值,但这个新的customFrame值没有反应在layer或presentationLayer上,从而不会触发display方法来获得Lottie的新帧画面,因此画面是没有更新的。
一种错误的使用方式
为了让“Lottie动画使用AVVideoCompositionCoreAnimationTool导出”勉强的可以工作,有一种错误的使用方式:layer.beginTime = CACurrentMediaTime() + delay。这种方式,勉强的让Lottie动画动起来了,但是可能动得很慢,动得不全,而且不同型号的设备表现的程度不一样。根据上面的分析,我们就可以很好的解释为什么这样做会有这个勉强的效果。简单来说,就是因为:
- “离线渲染”时,“动画模块”逻辑的介入,更新了customFrame,并反应在presentationLayer,因此会触发display的调用。那么在下一个渲染时,会出现Lottie动画新的画面;
- 离线渲染的频率和屏幕刷新频率是不同的。一个屏幕刷新周期里,可能可以离线渲染好多帧;
- 不同机型,不同硬件,会有不同的计算能力。就算是同一个设备,每次执行同一个操作的时间都很可能不会完全一样。
如何使用AVVideoCompositionCoreAnimationTool导出
根据上面的分析,如果要使用AVVideoCompositionCoreAnimationTool导出Lottie动画,就需要修改Lottie的实现方式了,在支持最新的AE导出的动画文件前提,使用Lottie V1的实现方式,并且完全不使用“needsDisplayForKey机制”。
另一方面,在不改Lottie的实现方式下,还有没有可能可以做到呢?如果有一个类似于“offline_render(time, layer)”的客户端函数入口点,我们有机会执行“根据time计算出对应的Lottie动画帧数,然后更新customFrame”的逻辑,那么,理论上是有一点可能可以的(很可能会有其他问题)。
参考资料