Lottie Web动效基本原理
前段时间在《Lottie Web动效在React中的构建》一文中和大家聊了如何通过lottie-web
将AE导出来的JSON文件自动生成动效。在该文中,聊的主要是设计软件Figma、Sketch和AE软件之间如何通过相关的插件完成设计资源的互通,并从AE导出动效相关的JSON文件。这些都是最基本的操作链路,但在使用lottie-web
还有相应的API可用来控制Lottie动效。今天这篇文章我们主要来聊聊Lottie Web动效的基本原理。
回忆Lottie Web动效的实现
其实在《Lottie Web动效在React中的构建》中详细介绍了如何使用Lottie Web实现AE导出来的动效。这里简单地回顾一下。
import bodymovin from "https://cdn.skypack.dev/lottie-web";
var animation = bodymovin.loadAnimation({
// 动效加载到的DOM元素
container: document.getElementById("lottie"), // 必须项
// 包含动效的JSON文件的相对路径,也可以是绝对路径
path:
"https://static.fedev.cn/sites/default/files/blogs/2021/2102/lottie-web.json",
// 必须项,描述动效的JSON文件,一般由AE软件中导出的JSON文件
// 动效渲染出来的格式,可以是svg、canvas和html
renderer: "svg",
// 必须项,除了svg选择之外还可以是canvas和html
// 用于指定动效是否循环播放,true是循环播放,false不是循环播放
loop: true, // 可选项
// 指定动效是不是加载后就立即播放,true是立即播放,false不是立即播放
autoplay: true, // 可选项
// 指定动效的名称
name: "mic animation" // 可选项
// animationData与path互斥,是一个包含导出的动画数据的对象
// animationData: { // ... }
});
效果如下:
特别声明,示例中的动效的JSON文件来自于lottiefiles.com网站上 @Arjun Matail 的作品。这里的示例除了提供的动效相关的JSON文件之外还提供了AE的原始文件。
Lottie Web相关API
尝试着使用console.log()
输出loadAnimation()
对应的API。基于上面的示例,像下面来输出:
console.log(animation);
在Chrome浏览器开发者工具中,可以看到输出的相关参数:
其中.loadAnimation()
有几个重要的参数:
container
:用来放置Lottie动效的容器,即渲染容器,一般可以使用一个带有id
的div
元素,也要以是其他的有效HTML容器元素renderer
:渲染器,即渲染的动画是什么格式,目前主要支持svg
、canvas
和html
三种格式,其中svg
的兼容性最佳。上面的示例就是以svg
的格式渲染的name
:动画名称,主要用于reference
,名称建议尽要能的命名成具有语义化的loop
:用来定义渲染出来的Lottie Web播放次数,是一个布尔值,如果取值为true
,表示无限次的播放,反之则只播放一次autoplay
:定义动效是不是自动播放,也是一个布尔值,如果取值为true
,则表示加载之后就立即播放,反之则不是立即播放path
:用来指定Lottie Web动效的JSON文件路径animationData
:可以用来替代path
引入的JSON数据,简单地说,可以将path
引入的JSON数据放置到animationData
中,但不建议这样做,更建议使用path
来引入动效对应的JSON数据,因为animaionData
会将数据打包进来,使得JS的Bundle过大
另外还提供了一些控制动效的API。比如:
stop
:用来停止动画play
:用来播放动画pause
:停止动画播放setSpeed(speed)
: 数据类型是Number
,设置动效播放速度,1
表示1
倍速度,0.5
表示0.5
倍速度setDirection(direction)
: 数据类型是Number
,设置播放方向,1
表示正向播放,-1
表示反向播放goToAndStop(value, isFrame)
:跳到某一帧或某一秒停止,第二个参数iFrame
为是否基于帧模式还是时间,默认为false
goToAndPlay(value, isFrame)
:跳到某一帧或某一秒开始,第二个参数iFrame
为是否基于帧模式还是时间,默认为false
playSegments(segments, forceFlag)
: 播放片段,参数1
为数组,两个元素为开始帧和结束帧;参数2
指的是否立即播放片段,还是等之前的动画播放完成destroy
: 动效销毁setSubframe(useSubFrames)
:如果useSubFrames
值为false
,它将使用AE原始帧数;如果为true
,它将在每个requestAnimationFrame
上用中间值更新。默认为true
getDuration(inFrames)
:inFrames
为true
,则以帧为单位返回持续时间;如果为false
,则以秒为单位返回持续时间
另外有几个全局的lottie
方法会影响所有的动画:
lottie.play()
:使用可选的name
参数,用于指定一个特定的动画播放lottie.stop()
:使用可选的name
参数,用于指定一个特定的动画停止lottie.goToAndStop(value, isFrame, name)
:将指定名称播放的动画移动到定义的时间。如果name
被省略,将移动所有的动画实例lottie.setSpeed()
: 第一个参数speed
(1
表示正常事度),另外有一个name
为可选参数,用来指定动画名称lottie.setDirection()
: 第一个参数direction
(1
表示正常方向,即正向),另外有一个name
为可选参数,用来指定动画名称lottie.searchAnimations()
: 查找带有lottie
或bodymovin
类的元素lottie.loadAnimation()
: 单独返回一个控件的动画实例lottie.destroy(name)
: 销毁指定名称(name
)的动画。如果省略name
,则销毁所有动画实例。DOM元素将被清空lottie.registerAnimation()
: 可以直接使用registerAnimation
注册一个元素,它必须有data-animation-path
属性指向的JSON数据(JSON对应的url
)lottie.getRegisteredAnimations()
: 返回所有动画实例lottie.setQuality()
:用来设置动画的质量,默认为high
,要以传high
、medium
、low
或>1
来提高性能lottie.setLocationHref()
:设置带有id
的<svg>
元素的相对位置lottie.freeze()
: 冻结所有正在播放的动画或将要加载的动画lottie.unfreeze()
: 解冻所有动画lottie.inBrowser()
: 如果库正在浏览器中运行,则为true
lottie.resize()
: 整所有动画实例的大小
Lottie Web动画也有一些事件方法,比如onComplete
、onLoopComplete
、onEnterFrame
、onSegmentStart
,除此之外还可以通过addEventListener
来监听以下事件:
complete
loopComplete
enterFrame
segmentStart
config_ready
(初始配置完成时)data_ready
(当动画的所有部分都已加载)data_failed
(当部分动画无法加载时)loaded_images
(当所有的图像加载都成功或错误时)DOMLoaded
(元素已添加到DOM)destroy
有关于这部分更详细的介绍,可以参阅Lottie Web的官方文档。
在上面的示例基础上,我们添加三个按钮,在click
事件中分别给animation
动画加上.play()
(播放动画)、.pause()
(中止动画播放,暂停)和.stop()
(停止播放动画):
PlayHander.addEventListener("click", () => {
animation.play();
});
PauseHander.addEventListener("click", () => {
animation.pause();
});
StopHander.addEventListener("click", () => {
animation.stop();
});
Lottie Web的JSON文件
在前面的示例中,.loadAnimation(obj)
的obj
中有一个重要的参数,那就是path
或animationData
,用来指定Lottie Web动画所需的数据,即JSON。那么JSON文件如何获得呢?
如果你只是想尝试着使用Lottie Web动效,需要一个测试性的JSON文件,那么很简单,只需要在LottieFiles市场上获取AE动效的JSON文件。如果你是在项目是使用Lottie Web制作动效,则需要AE导出的JSON文件。因为你或你的设计师很有可能是使用AE软件来制作动效。然后使用AE的插件导出JSON文件:
你可以点击这里下载AE导出来的JSON文件。打开该JSON文件,可以看到JSON数据:
看到上图这样的JSON数据,一脸懵逼吧!
简单地来说一下JSON数据中的重要参数。在AE软件中,我们可以在创建合层或已有的合层上进行设置,如下图所示:
在弹出的合成配置面板中,可以看到一些配置参数的设置,比如“宽度”、“高度”、“帧率”等:
这些基本配置参数和JSON数据中的一些Key
是有对应关系的:
- ①
nm
:合层的名称 - ②
w
:宽度 - ③
h
:高度 - ④
fr
:帧速率 - ⑤
ip
: 开始帧 - ⑥
op
: 结束帧 - ⑦
ddd
: 是事为3d
- ⑧
assets
: 静态资源信息 - ⑨
layers
:图层信息 - ⑩
v
:Bodymovin插件版本号
其中fr
、ip
和 op
是Lottie动画中最为重要的参数,只不过Lottie动画中是有帧率来计算动画时间的,比如JSON文件中的fr
是29.6409606933594
,op
是48.9999350215785
。
另外,Lottie动画还有assets
和layers
两个关键属性,这两个关键属性对应AE设计软件中的:
其中assets
(静态资源)主要包括:
image
chars
precomp
这三种静态资源中,chars
是不常用的。对于image
静态资源,其对应的类型压缩:
{
"id": "1",
"w": 130,
"h": 487,
"u": "",
"p": "",
"e": 1
}
这里的关键属性是p
,指的是图像资源的路径,它可以是相对路径,也可以是绝对路径,还可以是Base64。个人更建议使用Base64,更利于JSON文件的携带,不过会增加JSON文件大小;如果采用图片的路径(相对或绝对),不会额外增加JSON文件大小,但会增加额外的网络请求。
layers
在AE设计软件中主要有三个区域:
- 内容区域:包括形状图层的大小、位置、圆度等信息
- 变换区域:包含五个变换属性(锚点、位置、缩放、旋转和不透明度)
- 帧
可以在对应的JSON数据中的layers
:
Lottie如何将JSON数据动起来
使用Lottie Web制作动效最大的优势是能将AE导出的JSON数据(描述动效的JSON数据)转换成Web动效。那么是怎么将JSON数据动起来呢?我们分成几个阶段来介绍。
初始化渲染器
从前面的示例中我们可以得知Lottie Web通过loadAnimation
方法来初始化动画。渲染器初始化流程经过下图这样的过程:
可以查看lottie-web
中player/js/animation
中的AnimationManager.js
:
// 注册动画
function setupAnimation(animItem, element) {
// 事件监听
animItem.addEventListener('destroy', removeElement);
animItem.addEventListener('_active', addPlayingCount);
animItem.addEventListener('_idle', subtractPlayingCount);
// 注册动画
registeredAnimations.push({ elem: element, animation: animItem });
len += 1;
}
// 加载动画(加载AE导出的JSON数据)
function loadAnimation(params) {
// 生成当前动画实例
var animItem = new AnimationItem();
// 注册动画
setupAnimation(animItem, null);
// 初始化动画实例参数
animItem.setParams(params);
return animItem;
}
Lottie Web渲染器的代码(查看lottie-web
中player/js/animation
中的AnimationItem.js
):
AnimationItem.prototype.setParams = function (params) {
if (params.wrapper || params.container) {
this.wrapper = params.wrapper || params.container;
}
var animType = 'svg';
if (params.animType) {
animType = params.animType;
} else if (params.renderer) {
animType = params.renderer;
}
// 根据开发者配置选择渲染器
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;
}
this.imagePreloader.setCacheType(animType, this.renderer.globalData.defs);
this.renderer.setProjectInterface(this.projectInterface);
this.animType = animType;
if (params.loop === ''
|| params.loop === null
|| params.loop === undefined
|| params.loop === true) {
this.loop = true;
} else if (params.loop === false) {
this.loop = false;
} else {
this.loop = parseInt(params.loop, 10);
}
this.autoplay = 'autoplay' in params ? params.autoplay : true;
this.name = params.name ? params.name : '';
this.autoloadSegments = Object.prototype.hasOwnProperty.call(params, 'autoloadSegments') ? params.autoloadSegments : true;
this.assetsPath = params.assetsPath;
this.initialSegment = params.initialSegment;
if (params.audioFactory) {
this.audioController.setAudioFactory(params.audioFactory);
}
// 渲染器初始化参数
if (params.animationData) {
this.configAnimation(params.animationData);
} else if (params.path) {
if (params.path.lastIndexOf('\\') !== -1) {
this.path = params.path.substr(0, params.path.lastIndexOf('\\') + 1);
} else {
this.path = params.path.substr(0, params.path.lastIndexOf('/') + 1);
}
this.fileName = params.path.substr(params.path.lastIndexOf('/') + 1);
this.fileName = this.fileName.substr(0, this.fileName.lastIndexOf('.json'));
assetLoader.load(params.path, this.configAnimation.bind(this), function () {
this.trigger('data_failed');
}.bind(this));
}
};
来看流程图中几个重要的部分:
AnimationItem
: 它是Lottie Web动画的基类,loadAnimation
方法会生成一个AnimationItem
实例并返回,我们在使用loadAnimation
方法配置Lottie Web动画相关的参数都是来于这个类animItem
:使用new AnimationItem()
生成animItem
实例后,调用setupAnimation
方法setupAnimation
: 该方法首先监听了destroy
、_active
和_idle
三个事件等待被触发。由于可以多个动画并行,因此定义了全局的变量len
、registeredAnimations
等,用于判断和缓存已注册的动画实例setParams
: 接下来调用animItem
实例的setParams
方法来初始化动画参数,其中最重要的是选择动画的渲染器
在AnimationItem.prototype.setParams
设置了三种渲染模式,因此Lottie Web提供了 SVG、Canvas 和 HTML 三种渲染模式,其中默认是HTML模式。不过,一般使用SVG或Canvas渲染模式。
switch (animType) {
case 'canvas':
this.renderer = new CanvasRenderer(this, params.rendererSettings);
break;
case 'svg':
this.renderer = new SVGRenderer(this, params.rendererSettings);
break;
default:
this.renderer = new HybridRenderer(this, params.rendererSettings);
break;
}
Lottie Web中每个渲染器都有各自的实现方式,而且复杂度也各有不同,但是动画越复杂,其对性有的消耗也就越高。我们可以在lottie-web
的player/js/renderers
找到对应渲染器的源码:
初始化动画属性,加载静态资源
这里我们只看SVG渲染器。lottie-web
调用SVGRenderer
渲染器之后,会调用configAnimation
方法来初始化SVG渲染器。我们可以在lottie-web
的player/js/animation
文件夹的AnimationItem.js
文件中找到configAnimation
方法对应的代码:
// 渲染器初始化
AnimationItem.prototype.configAnimation = function (animData) {
if (!this.renderer) {
return;
}
try {
this.animationData = animData;
// 总帧数
if (this.initialSegment) {
this.totalFrames = Math.floor(this.initialSegment[1] - this.initialSegment[0]);
this.firstFrame = Math.round(this.initialSegment[0]);
} else {
this.totalFrames = Math.floor(this.animationData.op - this.animationData.ip);
this.firstFrame = Math.round(this.animationData.ip);
}
// 渲染器初始化参数
this.renderer.configAnimation(animData);
if (!animData.assets) {
animData.assets = [];
}
this.assets = this.animationData.assets;
// 帧率
this.frameRate = this.animationData.fr;
this.frameMult = this.animationData.fr / 1000;
this.renderer.searchExtraCompositions(animData.assets);
this.trigger('config_ready');
// 加载静态资源
this.preloadImages();
this.loadSegments();
this.updaFrameModifier();
// 等待静态资源加载完毕
this.waitForFontsLoaded();
if (this.isPaused) {
this.audioController.pause();
}
} catch (error) {
this.triggerConfigError(error);
}
};
// 等待资源加载
AnimationItem.prototype.waitForFontsLoaded = function () {
if (!this.renderer) {
return;
}
if (this.renderer.globalData.fontManager.isLoaded) {
// 检查加载完毕
this.checkLoaded();
} else {
setTimeout(this.waitForFontsLoaded.bind(this), 20);
}
};
AnimationItem.prototype.checkLoaded = function () {
if (!this.isLoaded
&& this.renderer.globalData.fontManager.isLoaded
&& (this.imagePreloader.loaded() || this.renderer.rendererType !== 'canvas')
) {
this.isLoaded = true;
dataManager.completeData(this.animationData, this.renderer.globalData.fontManager);
if (expressionsPlugin) {
expressionsPlugin.initExpressions(this);
}
// 初始化所有元素
this.renderer.initItems();
setTimeout(function () {
this.trigger('DOMLoaded');
}.bind(this), 0);
// 渲染第一帧
this.gotoFrame();
// 自动播放
if (this.autoplay) {
this.play();
}
}
};
在这个方法中将会初始化更多动画对象的属性,比如总帧数totalFrames
、帧率frameMult
等。然后加载一些其他资源,比如图像、字体等。
同时在waitForFontsLoaded
方法中等待静态资源加载完毕,加载完毕后便会调用SVG渲染器的initItems
方法(初始化所有图层),接着绘制动画。
在checkLoaded
方法中可以看到,通过initItems
初始化所有元素后,便通过gotoFrame
渲染第一帧,如果开发者在loadAnimation
配置了autoplay
为true
,则会直接调用play
方法播放动画。
整个流程如下图所示:
绘制动画初始图层
初始化图层方法initItems
主要调用buildAllItems
创建所有图层。buildItem
方法又会调用createItem
确定具体图层类型。在lottie-web
的player/js/renderers
文件夹的BaseRenderer.js
文件,找到buildAllItems
和createItem
对应的源码:
BaseRenderer.prototype.initItems = function () {
if (!this.globalData.progressiveLoad) {
// 初始化图层
this.buildAllItems();
}
};
BaseRenderer.prototype.buildAllItems = function () {
var i;
var len = this.layers.length;
for (i = 0; i < len; i += 1) {
this.buildItem(i);
}
this.checkPendingElements();
};
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);
default:
return this.createNull(layer);
}
};
在制作动画时,设计师操作的图层元素有很多种,比如图像、形状、文字、音频等。所以layers
中每个图层会有一个ty
字段。比如示例的JSON数据layer
中的0
和1
的ty
都是4
,对应就是“形状”:
如果用流程图来描述的话,大致如下图所示:
对于每种图层类型的渲染,可以在lottie-web
的player/js/elements
中找到:
比如图像类型:
// lottie-web/player/js/elements/ImageElement.js
function IImageElement(data, globalData, comp) {
this.assetData = globalData.getAssetData(data.refId);
this.initElement(data, globalData, comp);
this.sourceRect = {
top: 0,
left: 0,
width: this.assetData.w,
height: this.assetData.h,
};
}
extendPrototype([
BaseElement,
TransformElement,
SVGBaseElement,
HierarchyElement,
FrameElement,
RenderableDOMElement
], IImageElement);
IImageElement.prototype.createContent = function () {
var assetPath = this.globalData.getAssetsPath(this.assetData);
this.innerElem = createNS('image');
this.innerElem.setAttribute('width', this.assetData.w + 'px');
this.innerElem.setAttribute('height', this.assetData.h + 'px');
this.innerElem.setAttribute('preserveAspectRatio', this.assetData.pr || this.globalData.renderConfig.imagePreserveAspectRatio);
this.innerElem.setAttributeNS('http://www.w3.org/1999/xlink', 'href', assetPath);
this.layerElement.appendChild(this.innerElem);
};
IImageElement.prototype.sourceRectAtTime = function () {
return this.sourceRect;
};
Lottie Web动画播放
Lottie Web动画播放主要是使用AnimationItem
实例的play
方法。如果开发者在loadAnimation
配置了autoplay
为true
时会在所有初始化工作准备完毕后直接调用play
方法。否则由开发者主动调用play
方法播放。
// lottie-web/player/js/animation/AnimationItem.js
AnimationItem.prototype.play = function (name) {
if (name && this.name !== name) {
return;
}
if (this.isPaused === true) {
this.isPaused = false;
this.audioController.resume();
if (this._idle) {
this._idle = false;
this.trigger('_active');
}
}
};
play
方法主要触发了_active
事件,这个_active
事件在动画初始化时注册的:
// lottie-web/blob/master/player/js/animation/AnimationManager.js):
// 注册动画
function setupAnimation(animItem, element) {
// 事件监听
animItem.addEventListener('destroy', removeElement);
animItem.addEventListener('_active', addPlayingCount);
animItem.addEventListener('_idle', subtractPlayingCount);
// 注册动画
registeredAnimations.push({ elem: element, animation: animItem });
len += 1;
}
function addPlayingCount() {
playingAnimationsNum += 1;
activate();
}
function activate() {
if (!_isFrozen && playingAnimationsNum) {
if (_stopped) {
// 触发第一帧渲染
window.requestAnimationFrame(first);
_stopped = false;
}
}
}
触发后通过调用requestAnimationFrame
方法,不断的调用resume
方法来控制动画。
// lottie-web/player/js/animation/AnimationManager.js
function first(nowTime) {
initTime = nowTime;
// requestAnimationFrame 每次都进行计算修改 DOM
window.requestAnimationFrame(resume);
}
而resume
方法的主要逻辑如下:
// lottie-web/player/js/animation/AnimationManager.js
function resume(nowTime) {
// 两次 requestAnimationFrame 间隔时间
var elapsedTime = nowTime - initTime;
var i;
for (i = 0; i < len; i += 1) {
registeredAnimations[i].animation.advanceTime(elapsedTime);
}
initTime = nowTime;
if (playingAnimationsNum && !_isFrozen) {
window.requestAnimationFrame(resume);
} else {
_stopped = true;
}
}
// lottie-web/player/js/animation/AnimationItem.js
AnimationItem.prototype.setCurrentRawFrameValue = function (value) {
this.currentRawFrame = value;
this.gotoFrame();
};
AnimationItem.prototype.gotoFrame = function () {
this.currentFrame = this.isSubframeEnabled ? this.currentRawFrame : ~~this.currentRawFrame; // eslint-disable-line no-bitwise
if (this.timeCompleted !== this.totalFrames && this.currentFrame > this.timeCompleted) {
this.currentFrame = this.timeCompleted;
}
this.trigger('enterFrame');
this.renderFrame();
};
AnimationItem.prototype.renderFrame = function () {
if (this.isLoaded === false) {
return;
}
try {
this.renderer.renderFrame(this.currentFrame + this.firstFrame);
} catch (error) {
this.triggerRenderFrameError(error);
}
};
注意,在 Lottie Web 中,把 AE 设置的帧数作为一个计算单位,Lottie 并不是根据设计师设置的 30fps
(每隔 33.3ms
) 进行每一次变化,而是根据 requestAnimationFrame
的间隔(每隔 16.7ms
左右)计算了更细致的变化,保证动画的流畅运行。
小结
文章中简单地介绍了Lottie Web动画实现的基本原理,有关于Lottie Web相关的源代码可以在Github的仓库中查阅。由于自己也是初学Lottie Web相关的,如果文章介绍有误之处,还请路过的大神拍正。如果你在这方面有更多的经验或建议,欢迎在下面的评论中与我们一起共享。