概述

这篇文章主要想尝试解释一下“为什么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”的逻辑,那么,理论上是有一点可能可以的(很可能会有其他问题)。

参考资料