剖析 lottie-web 动画实现原理
图片来源: https:// aescripts.com/bodymovin /
本文作者: 青舟
前言
Lottie 是一个复杂帧动画的解决方案,它提供了一套从设计师使用 AE(Adobe After Effects)到各端开发者实现动画的工具流。在设计师通过 AE 完成动画后,可以使用 AE 的扩展程序 Bodymovin 导出一份 JSON 格式的动画数据,然后开发同学可以通过 Lottie 将生成的 JSON 数据渲染成动画。
1、如何实现一个 Lottie 动画
- 设计师使用 AE 制作动画。
- 通过 Lottie 提供的 AE 插件 Bodymovin 把动画导出 JSON 数据文件。
- 加载 Lottie 库结合 JSON 文件和下面几行代码就可以实现一个 Lottie 动画。
import lottie from 'lottie-web';
import animationJsonData from 'xxx-demo.json'; // json 文件
const lot = lottie.loadAnimation({
container: document.getElementById('lottie'),
renderer: 'svg',
loop: true,
autoplay: false,
animationData: animationJsonData,
// 开始播放动画
lot.play();
更多动画 JSON 模板可以查看 https:// lottiefiles.com/
2、解读 JSON 文件数据格式
笔者自己制作了 Lottie Demo -> 点我预览
-
0s 至 3s,
scale
属性值从 100% 变到 50%。 -
3s 至 6s,
scale
属性值从 50% 变到 100%,完成动画。
通过 Bodymovin 插件导出 JSON 数据结构如下图所示:
详细 JSON 信息可以通过 Demo 查看,JSON 信息命名比较简洁,第一次看可能难以理解。接下来结合笔者自己制作的 Demo 进行解读。
2.1 全局信息
左侧为使用 AE 新建动画合成需要填入的信息,和右面第一层 JSON 信息对应如下:
-
w
和h
: 宽 200、高 200 -
v
:Bodymovin 插件版本号 4.5.4 -
fr
: 帧率 30fps -
ip
和op
:开始帧 0、结束帧 180 -
assets
:静态资源信息(如图片) -
layers
:图层信息(动画中的每一个图层以及动作信息) -
ddd
:是否为 3d -
comps
:合成图层
其中
fr
、
ip
、
op
在 Lottie 动画过程中尤为重要,前面提到我们的动画 Demo 是 0 - 6s,但是 Lottie 是以帧率计算动画时间的。Demo 中设置的帧率为 30fps,那么 0 - 6s 也就等同于 0 - 180 帧。
2.2 图层相关信息
理解 JSON 外层信息后,再来展开看下 JSON 中
layers
的具体信息,首先
demo
制作动画细节如下:
主要是 3 个区域:
- 内容区域,包含形状图层的大小、位置、圆度等信息。
- 变化区域,包含 5 个变化属性(锚点、位置、缩放、旋转、不透明度)。
- 缩放 3 帧(图中绿色区域),在 0 帧、90 帧、180 帧对缩放属性进行了修改,其中图中所示为第 90 帧,图层缩放至 50%。
对应上图动画制作信息,便可以对应到 JSON 中的
layers
了。如下图所示:
2.3 属性变化信息
接下来再看
ks
(变化属性) 中的
s
展开,也就是缩放信息。
其中:
-
t
代表关键帧数 -
s
代表变化前(图层为二维,所以第 3 个值 固定为 100)。 -
e
代表变化后(图层为二维,所以第 3 个值 固定为 100)。
3、Lottie 如何把 JSON 数据动起来
前面简单理解了 JSON 的数据意义,那么 Lottie 是如何把 JSON 数据动起来的呢?接下来结合 Demo 的 Lottie 源码阅读,只会展示部分源码,重点是理清思路即可,不要执着源代码。
以下源码介绍主要分为 2 大部分:
- 动画初始化(3.1小节 - 3.3小节)
- 动画播放(3.4 小节)
3.1 初始化渲染器
如
Demo
所示,Lottie 通过
loadAnimation
方法来初始化动画。渲染器初始化流程如下:
function loadAnimation(params){
// 生成当前动画实例
var animItem = new AnimationItem();
// 注册动画
setupAnimation(animItem, null);
// 初始化动画实例参数
animItem.setParams(params);
return animItem;
function setupAnimation(animItem, element) {
// 监听事件
animItem.addEventListener('destroy', removeElement);
animItem.addEventListener('_active', addPlayingCount);
animItem.addEventListener('_idle', subtractPlayingCount);
// 注册动画
registeredAnimations.push({elem: element, animation:animItem});
len += 1;
-
AnimationItem
这个类是 Lottie 动画的基类,loadAnimation
方法会先生成一个AnimationItem
实例并返回,开发者使用的 配置参数和方法 都是来自于这个类。 -
生成
animItem
实例后,调用setupAnimation
方法。这个方法首先监听了destroy
、_active
、_idle
三个事件等待被触发。由于可以多个动画并行,因此定义了全局的变量len
、registeredAnimations
等,用于判断和缓存已注册的动画实例。 -
接下来调用
animItem
实例的setParams
方法初始化动画参数,除了初始化loop
、autoplay
等参数外,最重要的是选择渲染器。如下:
AnimationItem.prototype.setParams = function(params) {
// 根据开发者配置选择渲染器
switch(animType) {
case 'canvas':
this.renderer = new CanvasRenderer(this, params.rendererSettings);
break;
case 'svg':
this.renderer = new SVGRenderer(this, params.rendererSettings);
break;
default:
// html 类型
this.renderer = new HybridRenderer(this, params.rendererSettings);
break;
// 渲染器初始化参数
if (params.animationData) {
this.configAnimation(params.animationData);
Lottie 提供了 SVG、Canvas 和 HTML 三种渲染模式,一般使用第一种或第二种。
- SVG 渲染器支持的特性最多,也是使用最多的渲染方式。并且 SVG 是可伸缩的,任何分辨率下不会失真。
- Canvas 渲染器就是根据动画的数据将每一帧的对象不断重绘出来。
- HTML 渲染器受限于其功能,支持的特性最少,只能做一些很简单的图形或者文字,也不支持滤镜效果。
每个渲染器均有各自的实现,复杂度也各有不同,但是动画越复杂,其对性能的消耗也就越高,这些要看实际的状况再去判断。渲染器源码在
player/js/renderers/
文件夹下,本文 Demo 只分析 SVG 渲染动画的实现。由于 3 种 Renderer 都是基于
BaseRenderer
类,所以下文中除了
SVGRenderer
也会出现
BaseRenderer
类的方法。
3.2 初始化动画属性,加载静态资源
确认使用 SVG 渲染器后,调用
configAnimation
方法初始化渲染器。
AnimationItem.prototype.configAnimation = function (animData) {
if(!this.renderer) {
return;
// 总帧数
this.totalFrames = Math.floor(this.animationData.op - this.animationData.ip);
this.firstFrame = Math.round(this.animationData.ip);
// 渲染器初始化参数
this.renderer.configAnimation(animData);
// 帧率
this.frameRate = this.animationData.fr;
this.frameMult = this.animationData.fr / 1000;
this.trigger('config_ready');
// 加载静态资源
this.preloadImages();
this.loadSegments();
this.updaFrameModifier();
// 等待静态资源加载完毕
this.waitForFontsLoaded();
在这个方法中将会初始化更多动画对象的属性,比如总帧数
totalFrames
、帧率
frameMult
等。然后加载一些其他资源,比如图像、字体等。如下图所示:
同时在
waitForFontsLoaded
方法中等待静态资源加载完毕,加载完毕后便会调用 SVG 渲染器的
initItems
方法绘制动画图层,也就是将动画绘制出来。
AnimationItem.prototype.waitForFontsLoaded = function(){
if(!this.renderer) {
return;
// 检查加载完毕
this.checkLoaded();
AnimationItem.prototype.checkLoaded = function () {
this.isLoaded = true;
// 初始化所有元素
this.renderer.initItems();
setTimeout(function() {
this.trigger('DOMLoaded');
}.bind(this), 0);
// 渲染第一帧
this.gotoFrame();
// 自动播放
if(this.autoplay){
this.play();
在
checkLoaded
方法中可以看到,通过
initItems
初始化所有元素后,便通过
gotoFrame
渲染第一帧,如果开发者配置了
autoplay
为
true
,则会直接调用
play
方法播放。这里有个印象就好,会在后面详细讲。接下来还是先看
initItems
实现细节。
3.3 绘制动画初始图层
initItems
方法主要是调用
buildAllItems
创建所有图层。
buildItem
方法又会调用
createItem
确定具体图层类型,这里的方法源码中拆分较细,本文只保留了
createItem
方法,其他感兴趣可以查看源码细节。
在制作动画时,设计师操作的图层元素有很多种,比如图片、形状、文字等等。所以
layers
中每个图层会有一个字段
ty
来区分。结合
createItem
方法来看,一共有以下 8 中类型。
BaseRenderer.prototype.createItem = function(layer) {
// 根据图层类型,创建相应的 svg 元素类的实例
switch(layer.ty){
case 0:
// 合成
return this.createComp(layer);
case 1:
// 固态
return this.createSolid(layer);
case 2:
// 图片
return this.createImage(layer);
case 3:
// 兜底空元素
return this.createNull(layer);
case 4:
// 形状
return this.createShape(layer);
case 5:
// 文字
return this.createText(layer);
case 6:
// 音频
return this.createAudio(layer);
case 13:
// 摄像机
return this.createCamera(layer);
return this.createNull(layer);
由于笔者以及大多数开发者,都不是专业的 AE 玩家,因此不必不过纠结每种类型是什么,理清主要思路即可。结合笔者的 Demo ,只有一个图层,并且图层的
ty
为 4 。是一个
Shape
形状图层,因此在初始化图层过程中只会执行
createShape
方法。
其他图层类型的渲染逻辑,如
Image
、
Text
、
Audio
等等,每一种元素的渲染逻辑都实现在源码
player/js/elements/
文件夹下,具体实现逻辑这里就不进行展开了,感兴趣的同学自行查看。
接下来便是执行
createShape
方法,初始化元素相关属性。
除了一些细节的初始化方法,其中值得注意的是
initTransform
方法。
initTransform: function() {
this.finalTransform = {
mProp: this.data.ks
? TransformPropertyFactory.getTransformProperty(this, this.data.ks, this)
: {o:0},
_matMdf: false,
_opMdf: false,
mat: new Matrix()
利用
TransformPropertyFactory
对
transform
初始化,结合 Demo 第 0 帧,对应如下:
- 不透明度 100%
- 缩放 100%
transform: scale(1);
opacity: 1;
那么为什么在初始化渲染图层时,需要初始化
transform
和
opacity
呢?这个问题会在 3.4 小节中进行回答。
3.4 Lottie 动画播放
在分析 Lottie 源码动画播放前,先来回忆下。笔者 Demo 的动画设置:
-
0s 至 3s,
scale
属性值从 100% 变到 50%。 -
3s 至 6s,
scale
属性值从 50% 变到 100%。
如果按照这个设置,3s 进行一次改变的话,那动画就过于生硬了。因此设计师设置了帧率为 30fps ,意味着每隔 33.3ms 进行一次
变化
,使得动画不会过于僵硬。那么如何实现这个
变化
,便是 3.3 小节提到的
transform
和
opacity
。
在 2.2 小节中提到的 5 个变化属性(锚点、位置、缩放、旋转、不透明度)。其中不透明度通过 CSS 的
opacity
来控制,其他 4 个(锚点、位置、缩放、旋转)则通过
transform
的
matrix
来控制。笔者的 Demo 中实际上初始值如下:
transform: matrix(1, 0, 0, 1, 100, 100);
/* 上文的 transform: scale(1); 只是为了方便理解*/
opacity: 1;
这是因为无论是旋转还是缩放等属性,本质上都是应用
transform
的
matrix()
方法实现的,因此 Lottie 统一使用
matrix
处理。平时开发者使用的类似于
transform: scale
这种表现形式,只是因为更容易理解,记忆与上手。
matrix
相关知识点可以学习张鑫旭老师的
理解CSS3 transform中的Matrix
。
所以 Lottie 动画播放流程可 暂时 小结为:
-
渲染图层,初始化所有图层的
transform
和opacity
-
根据帧率 30fps,计算每一帧(每隔 33.3ms )对应的
transform
和opacity
并修改 DOM
然而 Lottie 如何控制 30fps 的时间间隔呢?如果设计师设置 20fps or 40fps 怎么处理?可以通过
setTimeout
、
setInterval
实现吗?带着这个问题看看源码是如何处理的,如何实现一个通用的解决方案。
Lottie 动画播放主要是使用
AnimationItem
实例的
play
方法。如果开发者配置了
autoplay
为
true
,则会在所有初始化工作准备完毕后(3.2 小节有提及),直接调用
play
方法播放。否则由开发者主动调用
play
方法播放。
接下来从
play
方法了解一下整个播放流程的细节:
AnimationItem.prototype.play = function (name) {
this.trigger('_active');
去掉多余代码,
play
方法主要是触发了
_active
事件,这个
_active
事件便是在 3.1 小节初始化时注册的。
animItem.addEventListener('_active', addPlayingCount);
function addPlayingCount(){
activate();
function activate(){
// 触发第一帧渲染
window.requestAnimationFrame(first);
触发后通过调用
requestAnimationFrame
方法,不断的调用
resume
方法来控制动画。
function first(nowTime){
initTime = nowTime;
// requestAnimationFrame 每次都进行计算修改 DOM
window.requestAnimationFrame(resume);
前文提到的动画参数:
- 开始帧为 0
- 结束帧为 180
- 帧率为 30 fps
requestAnimationFrame
在正常情况下能达到 60 fps(每隔 16.7ms 左右)。那么 Lottie 如何保证动画按照 30 fps (每隔 33.3ms)流畅运行呢。这个时候我们要转化下思维,设计师希望按照每隔 33.3ms 去计算变化,那也可以通过
requestAnimationFrame
方法,每隔 16.7ms 去计算,也可以计算动画的变化。只不过计算的更细致而已,而且还会使得动画更流畅,这样无论是 20fps or 40fps 都可以处理了,来看下源码是如何处理的。
在不断调用的
resume
方法中,主要逻辑如下:
function resume(nowTime) {
// 两次 requestAnimationFrame 间隔时间
var elapsedTime = nowTime - initTime;
// 下一次计算帧数 = 上一次执行的帧数 + 本次间隔的帧数
// frameModifier 为帧率( fr / 1000 = 0.03)
var nextValue = this.currentRawFrame + value * this.frameModifier;
this.setCurrentRawFrameValue(nextValue);
initTime = nowTime;
if(playingAnimationsNum && !_isFrozen) {
window.requestAnimationFrame(resume);
} else {
_stopped = true;
AnimationItem.prototype.setCurrentRawFrameValue = function(value){
this.currentRawFrame = value;
// 渲染当前帧
this.renderFrame();
resume
方法:
-
首先会计算当前时间和上次时间的
diff
时间。 - 之后计算动画开始到现在的时间的当前帧数。注意这里的 帧数 只是相对 AE 设置的一个计算单位,可以有小数。
-
最后通过
renderFrame()
方法更新当前帧对应的 DOM 变化。
举例说明:
假设上一帧为 70.25 帧,本次
requestAnimationFrame
间隔时间为 16.78 ms,那么:
当前帧数:70.25 + 16.78 * 0.03 = 70.7534帧
由于 70.7534 帧在 Demo 中的 0 - 90 帧动画范围内,因此帧比例(代表动画运行时间百分比)的计算如下:
帧比例:70.7534 / 90 = 0.786148889
0 - 90 帧的动画为图层从 100% 缩放至 50% ,因为仅计算 50% 的变化,所以缩放到如下:
缩放比例: 100 - (50 * 0.781666)= 60.69255555%