Lottie原理与源码解析
世界Skill
Lottie(Bodymivin)是一个动画库,其和GSAP这类专注动画曲线、插值等js动画库不同,它本质上是一套跨平台的平面动画解决方案。其提供了一套完整得从AE到各个终端的工具流,通过AE的插件将设计师做的动画导出成一套定义好的json文件,之后再通过渲染器进行渲染,它提供了“SVG”、“Canvas”和“HTML”三种渲染模式,最常用的是第一种和第二种。
特性
Lottie提供了如下特性,并给出了AE的功能支持度与限制:
http://airbnb.io/lottie/supported-features.html
每个渲染器均有各自的实现,其复杂度也各有不同,但毫无例外的是图形越多、参与运算的属性越多、帧率越高,其对性能的消耗也就越高,这些要看实际的状况。
lottie-web优势
以上列表可以看出在web上使用lottie的巨大优势:支持特性最多,可以做出最丰富的效果。而且得意于Web的特性,lottie-web较为灵活,迭代快,修改和添加功能也较为容易。
目前问题
没有发现官方或第三方对sprites map(如果有请务必告诉我!),而lottie动画的每个图片又相对较小,这个对于图片资源较多的情况下会有碎片化问题,对于移动端web在线资源是比较难接受的。所以需要想个办法让它支持,要么加一个前置Loader,要么进行修改源码,前者好处是不动源码,作为插件比较灵活,但有可能的性能问题,后者需要改动的可能较多(有可能要修改AE插件)但可能性能好一点,这个可以随着使用逐步研究起来。
代码分析
从lottie-web的Git源Clone代码,其中player/js目录下的即为播放器代码,其中module.js即为入口,让我们从这个文件开始分析,以一个文件被加载到播放以及各种其他操作为例,看看lottie到底做了什么工作:
Lottie模块
module.js中是标准的各种模式下模块的导出流程,其中模块在factory
函数中被初始化并导出,在这个函数中,它定义了一个lottiejs
,这个就是实际挂载模块的对象。
lottiejs.play = animationManager.play;
lottiejs.pause = animationManager.pause;
lottiejs.setLocationHref = setLocationHref;
lottiejs.togglePause = animationManager.togglePause;
lottiejs.setSpeed = animationManager.setSpeed;
lottiejs.setDirection = animationManager.setDirection;
......
静态方法
静态方法是指直接挂载在lottiejs
上的方法,我们可以在导入lottie后直接调用,比如最基础的loadAnimation
方法,其用法是:
import Lottie from 'lottie-web';
Lottie.loadAnimation({......});
lottie提供了很多这样的静态方法,比如setSpeed
、inBrowser
、installPlugin
等,这些方法一般有两个作用:其一是设置所有动画对象的对应的属性值,比如我通过Lottie.setSpeed(.5)
修改了速度,那么所有对象的速度就被修改为了的这个全局的速度;其二,静态方法还可以在没有拿到实际动画对象的时候对其进行控制,比如Lottie.play('animation1')
就可以播放注册过的命名为'animation1'
的对象,其等价于lottieAnimation1.play()
。
初始化动画
lottie可以通过loadAnimation
或registerAnimation
来初始化动画。后者需要预先插入一个Tag,这个Tag上有一个data-animation-path
属性,其的值需要指向你的动画的json文件,这个一般在你有许多lottie动画,预先对动画进行了布局时很有用,但一般我们使用的是第一种做法:通过一个对象来初始化一个动画对象。
可以在源码中看到,loadAnimation
或registerAnimation
这两个方法,以及其他大部分静态方法实际上都被委托到了一个叫animationManager
的对象中同名的方法实现,那么这个对象在哪呢?在animation/AnimationManager中。
可见,这个对象可以说是实际上的模块顶层,大多重要方法都在其中。让我们找到上述两个初始化方法,看看它们都干了啥。首先是registerAnimation
。
registerAnimation
function registerAnimation(element, animationData){
if(!element){
return null;
}
var i=0;
while(i<len){
if(registeredAnimations[i].elem == element && registeredAnimations[i].elem !== null ){
return registeredAnimations[i].animation;
}
i+=1;
}
var animItem = new AnimationItem();
setupAnimation(animItem, element);
animItem.setData(element, animationData);
return animItem;
}
首先这个对象的闭包中定义了几个全局的变量len
、registeredAnimations
等,用于判断和缓存已注册的动画元素。在调用这个方法时需要传入DOM对象element
和动画数据animationData
,先判断缓存中有没有对应对象,有的话直接返回,否则new一个AnimationItem
类为animItem
对象,AnimationItem
这个类是动画容器的基类,之后会详细说到。新建对象后,lottie又将其和通过参数传入的DOM元素作为参数传入setupAnimation
方法,这个方法中会进行一些自定义事件的绑定。在之后新建的animItem
将会调用setData
方法将animationData
传入为对象设置动画数据,最后将对象返回:
setData
AnimationItem.prototype.setData = function (wrapper, animationData) {
var params = {
wrapper: wrapper,
animationData: animationData ? (typeof animationData === "object") ? animationData : JSON.parse(animationData) : null
};
var wrapperAttributes = wrapper.attributes;
params.path = wrapperAttributes.getNamedItem('data-animation-path') ? wrapperAttributes.getNamedItem('data-animation-path').value : wrapperAttributes.getNamedItem('data-bm-path') ? wrapperAttributes.getNamedItem('data-bm-path').value : wrapperAttributes.getNamedItem('bm-path') ? wrapperAttributes.getNamedItem('bm-path').value : '';
......
this.setParams(params);
};
这个方法其实就是初始化animItem的参数params
(通过this.setParams
),参数中除了wrapper
和animationData
外还有path
、autoplay
这些参数,都是从wrapper
、也就是传给registerAnimation
的那个DOM元素的属性中获取的。让我们看看setParams
干了啥:
setParams
AnimationItem.prototype.setParams = function(params) {
......
var animType = params.animType ? params.animType : params.renderer ? params.renderer : 'svg';
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;
}
......
if(params.animationData){
this.configAnimation(params.animationData);
}else if(params.path){
if(params.path.substr(-4) != 'json'){
if (params.path.substr(-1, 1) != '/') {
params.path += '/';
}
params.path += 'data.json';
}
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));
}
};
不太重要的地方就略去了,此方法中最重要的其实就两点——一是确定渲染方式并创建响应的渲染器,而是通过是否有animationData
参数来确定数据来源并初始化数据,如果有则直接用这个数据,如果没有则去寻找path
参数,之后调用assetLoader.load
来初始化数据,最终数据都会被传给configAnimation
方法。
Note: 这里要注意如果路径后缀不是
json
将会自动加上data.json
!
configAnimation
AnimationItem.prototype.configAnimation = function (animData) {
if(!this.renderer){
return;
}
this.animationData = animData;
this.totalFrames = Math.floor(this.animationData.op - this.animationData.ip);
this.renderer.configAnimation(animData);
if(!animData.assets){
animData.assets = [];
}
this.renderer.searchExtraCompositions(animData.assets);
this.assets = this.animationData.assets;
this.frameRate = this.animationData.fr;
this.firstFrame = Math.round(this.animationData.ip);
this.frameMult = this.animationData.fr / 1000;
this.trigger('config_ready');
this.preloadImages();
this.loadSegments();
this.updaFrameModifier();
this.waitForFontsLoaded();
};
在这个方法中将会初始化更多动画对象的属性,比如totalFrames等,此外最重要的是去加载一些其他资源,比如图像、字体等,也会去加载预先定义的片段segments
(默认有一个),片段是lottie提供的一种动画控制手段,可以将多个动画统一在一个动画对象中,其本质上是不断调用loadNextSegments
方法,获取片段路径然后不断调用assetsLoader.load
方法去加载新的片段。
片段的路径被写死为了
${basePath}/${filename}_${segementsPos}.json
。
注意在加载片段后includeLayers
方法被调用,其中主要涉及到一些静态资源的追加合成,以及利用utils/dataManager.completeData
进行的animationData
的检查和标准化(在别的阶段也会被调用,比如waitForFontsLoaded
),还会调用expressionsPlugin.initExpressions
(如果有的话)方法对数据中的表达式进行初始化。
一切都结束后一个data_ready
事件将会被触发,至此,registerAnimation
方式的动画Load便结束。
loadAnimation
loadAnimation
的大部分流程和registerAnimation
是一样的,不同的是它直接接受一个包含了初始化参数的animationData
对象:
function loadAnimation(params){
var animItem = new AnimationItem();
setupAnimation(animItem, null);
animItem.setParams(params);
return animItem;
}
把setupAnimation
的第二个参数直接置为null,去掉了setData
阶段(因为不需要),直接跳到setParams
阶段,剩下的都一样。
全局搜索初始化
除了以上两种方式初始化动画对象,还有个方法searchAnimations
用于初始化所有dom元素定义的动画。它默认会搜索所有classList中有lottie
或bodymovin
的dom元素,将它们全部用registerAnimation
进行初始化。
动画参数
Lottie通过一系列参数完成对动画对象的初始化,然而可悲的是他们被分散在源码的各个地方也没注释,好在官网还有一些例子加上拼凑我总结了一下定义:
{
// 要挂载动画的DOM对象,默认为空,新建一个。
container: HTMLElement;
// 渲染模式,默认为canvas。
// 对应DOM属性 data-anim-type | data-bm-type | bm-type | data-anim-renderer
renderer: 'svg' | 'canvas' | 'html';
// 循环参数,ture/false以开关量控制,若为数字,则0~max为循环次数,负数为无限循环。默认为true
// 对应DOM属性 data-anim-loop | data-bm-loop | bm-loop
loop: boolean | number;
// 自动播放,默认为true。
// 对应DOM属性 data-anim-autoplay | data-bm-autoplay | bm-autoplay
autoplay: boolean;
// 动画数据,需要符合数据格式(见下),优先级高于path参数,默认为null。
animationData: AnimationData;
// 动画json文件路径,如果末尾不是.json则会自动加上/data.json,默认为null。
// 对应DOM属性 data-animation-path | data-bm-path | bm-path
path: string;
// 渲染设置。
rendererSettings: {
// 指定canvasContext
context: canvasContext;
// 是否先清除canvas画布,canvas模式独占,默认false。
clearCanvas: boolean;
// 是否开启渐进式加载,只有在需要的时候才加载dom元素,在有大量动画的时候会提升初始化性能,但动画显示可能有一些延迟,svg模式独占,默认为false。
progressiveLoad: boolean;
// 当元素opacity为0时隐藏元素,svg模式独占,默认为true。
hideOnTransparent: boolean;
// 容器追加class,默认为''
className: string;
}
播放控制
Lottie提供了一系列API来控制播放流程,其控制的基准都分为两个层次——时间(time)和帧(frame)。一般而言像是goToAndPlay
这种方法除了第一个value
参数之外,还谁有一个isFrame
参数,其表明前面这个值是time
还是frame
,一般而言默认都是时间,但我强烈建议用帧来控制,对应AE比较清晰。下面我列举一下常用的方法。
常用的方法
play()
: 播放动画。pause()
: 暂停动画。togglePause()
: 切换播放和暂停的状态。stop()
: 停止动画。goToAndStop(value: number, isFrame: boolean)
: 跳转到某时间/帧并播放。goToAndPlay(value: number, isFrame: boolean)
: 跳转到某时间/帧并停止。setSegment(init: number, end: number)
: 设置当前的segment区间。playSegments(range: [number, number], force: boolean)
: 设置并播放当前的segment区间,force
为true的时候讲立即播放,否则会等当前一循环播放完再切换。如果range[0] > range[1]
则会设置方向为反播放。resetSegments(force: boolean)
: 重置当前的segment区间,force
依然是是否立即生效的标志。setSpeed(value: number)
: 设置播放速度,基准为1。setDirection(value: -1 | 1)
: 设置播放正反,1为正,-1为反。
play
让我们从animationItem.play
方法开始吧,了解一下整个播放流程的细节:
if(this.isPaused === true){
this.isPaused = false;
if(this._idle){
this._idle = false;
this.trigger('_active');
}
}
除去前置的各种flag判断,这个方法中最重要的操作就是触发了_active
事件,让我们看看这个事件在哪里被监听了:
addPlayingCount -> activate -> first -> resume
_avtive
事件只在一处被监听,其为AnimationManager.addPlayingCount
,之后再传递到active
方法中,之后再first(nowTime)
方法中初始化播放时间,设置起点,然后就到了resume
方法:
function resume(nowTime) {
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;
}
}
这个方法中首先会计算当前时间和初始化时间的一个diffelapsedTime
,之后将elapsedTime
依次传入所有动画对象的advanceTime
方法中,之后更新初始时间并根据状态看是否要进行下一次循环。可见,这里在首帧后lottie做的事情其实就是不断和上一次RAF的时间算diff,并不断利用这个diff去进行下一步操作。
advanceTime -> setCurrentRawFrameValue -> gotoFrame
advanceTime
是animItem的一个方法,其主要工作是根据上一个方法传来的diff以及一些flag进行一些中间控制,比如判断是否播放结束,播放结束是否要Loop,同时在结束的时候触发响应的事件通知开发者。除此之外,其最重要的作用是算出nextValue = this.currentRawFrame + value * this.frameModifier;
,即下一帧的帧数,然后传给animItem.setCurrentRawFrameValue
方法,这个方法也只是记录下当前rawFrame(原始Frame)的值,然后直接跳到goToFrame
方法。
在goToFrame
方法中,lottie计算出了事实上的currentFrame
,也就是实际用于渲染的frame值,触发进入frame的事件后,就调用动画对象自身的renderFrame
方法进行渲染。
事件
以上无数次提到了lottie的事件机制,lottie提供了许多事件用于控制动画的播放和初始化等,以下是暴露出来的一些事件和触发时机:
- complete:在播放结束后被触发(一次Loop结束不算)。
- loopComplete:在没次Loop结束后触发。
- enterFrame:在进入每一帧的时候触发(注意stop的时候也会触发一次)。
- segmentStart:当一段segment开始播放的时候触发。
- config_ready:当所有的参数配置解析完毕后触发。
- data_ready:在所有的segments被加载完毕后触发。注意这个当你直接传入animationData而非path的时候,解析是同步的,所以执行完loadAnimation的时候实际上已经被触发过了,并且这个事件是超前与image这种资源的加载的,所以建议加上if (animation.loaded)的判断或绑定DOMLoaded事件。
- data_failed:虽然文档写了但代码里并没有这个事件嗯。
- loaded_images:所有图片资源加载完毕的时候会触发,虽然代码里写了
if (!error)
然而当你详细去看的时候只会发现err永远为null。 - DOMLoaded:当元素被添加到DOM的时候触发,这个是比较可靠的可以替换
data_ready
的事件。 - destroy:当元素被释放的时候触发。
数据结构
接下来要讲最大头的渲染了,不过在这之前,我们需要先了解一下lottie所定义的数据结构,否则看到那些奇怪的ip
、op
、fr
这些字段不知道是啥意思就尴尬了。
其实Lottie官方是给出了详细的定义的,在项目根目录下的docs/json内,随便打开一个animation.json可以看到:
"layers": {
"title": "Layers",
"description": "List of Composition Layers",
"items": {
"oneOf": [
{
"$ref": "#/layers/shape"
},
{
"$ref": "#/layers/solid"
},
{
"$ref": "#/layers/comp"
},
{
"$ref": "#/layers/image"
},
{
"$ref": "#/layers/null"
},
{
"$ref": "#/layers/text"
}
],
"type": "object"
},
"type": "array"
}
key为在实际的json文件中对应的Key,title
为标题,description
为说明,type
为其在程序中的数据类型,items
则是其作为array类型的元素,oneOf
以及其下的数组表示其元素为以下几种之一,$ref
表示这种元素对应的文档地址。
通过这个,我们便可以很方便得查找每个字段的含义,这样也方便与理解渲染。
渲染
承接播放控制一节的最后,渲染从动画对象的renderFrame
开始:
AnimationItem.prototype.renderFrame = function () {
if(this.isLoaded === false){
return;
}
this.renderer.renderFrame(this.currentFrame + this.firstFrame);
};
可见其首先做了下防卫,防止没有加载完成的动画的播放,然后就调用通过参数生成绑定的渲染器this.renderer
的renderFrame
,将当前帧数传给其进行渲染。
configAnimation -> includeLayers
让我们回顾一下动画初始化时的setParams
阶段的configAnimation
方法,其中有一句是:
this.renderer.configAnimation(animData);
这一句就是通过相应的renderer去通过动画数据初始化渲染的容器,比如对于svg渲染模式,就是去生成初始DOM节点,对于canvas,就是去生成初始化的画布。
在之后的初始化过程中,animItem.includeLayers
方法还会调用到renderer.includeLayers
方法,这个方法是在所有renderer继承的baseRenderer中的,它负责将所有动画数据中的图层保存到renderer中备用。
renderFrame
对于每个render,最核心的方法就是renderFrame
,它最重要的代码如下:
this.globalData._mdf = false;
var i, len = this.layers.length;
if(!this.completeLayers){
this.checkLayers(num);
}
for (i = len - 1; i >= 0; i--) {
if(this.completeLayers || this.elements[i]){
this.elements[i].prepareFrame(num - this.layers[i].st);
}
}
if(this.globalData._mdf) {
for (i = 0; i < len; i += 1) {
if(this.completeLayers || this.elements[i]){
this.elements[i].renderFrame();
}
}
}
这个渲染流程分为了三个步骤,分别是checkLayers
、prepareFrame
和renderFrame
。
在checkLayers
中,实际上做的事情是检查图层是否被建立完毕,如果没有则去使用图层原信息通过renderer.buildItem
方法初始化真正的渲染数据,也可以认为是生成对应的虚拟渲染元素树,这些元素的实现都在elements目录下,对于这些元素之后会详细说道。注意对于svg渲染器,这个也会递归地去初始化DOM树,填充图形元素。
在prepareFrame
中,实际上是调用每个初始化过的元素的prepareFrame
,这个其实就是为渲染做准备,检查元素的一些状态然后决定globalData._mdf
或是isInRange
这些状态,为之后的渲染做准备。要注意的是每种元素(像是图形、文本等)的准备过程各有不同,而且官方这部分明显过度设计多继承加上没有注释也没有ts导致不太好阅读,所以读的时候要格外注意。
通过上一步得到的globalData._mdf
属性,我们便可以判断是否要进行下一步的渲染,如果没有修改就不渲染,节省性能,其实不仅仅是全局的_mdf
,每个子节点元素也自己会维护一个,来防止可以避免的渲染发生,这么来看,其实prepareFrame
算是类似于React的前置Diff吧。
渲染阶段即调用每个节点元素的renderFrame
实现,这个实现同样在每个渲染器也各有不同。细节太过丰富这里就不在细化解析了,只说下每一种的大致架构:
SVGRenderer
SVGRenderer的实际渲染实际上是委托给每个elements/svgElements下的每个元素进行的,其中最基础的是SVGBaselement
,其他的几种Element都继承自它和其他的几种通用Element(比如elements/BaseElement)。对于SVGRenderer而言,所有元素本质上都被分成了两种:shape
和text,而这两种元素有各自的渲染方法进行渲染。注意这两种元素的实现也有一些共性,那就是对于动画中的变换以及滤镜等,他们的实现中都有类似这么一句:
extendPrototype([BaseElement,TransformElement,SVGBaseElement,IShapeElement,HierarchyElement,FrameElement,RenderableDOMElement], SVGShapeElement);
前面这一大长串的列表就是指这个元素类继承了多少种特性,这些特性中有来自于通用变换特性的像是TransformElement
、RenderableDOMElement
(在elements/helpers中),也有SVG的基础类SVGBaseElement
,还有像是Shape元素的专用接口IShapeElement
。而在实际的动画中,元素就是靠将具体的变化派发到这些基类的方法中去实现的。
比如我在AE做了一个shape的transform动画,那么最终这个动画会被派发给SVGShapeElement
渲染逻辑,之后利用TransformElement
中的计算逻辑计算每一帧对应的transform矩阵,然后在通过设置给具体的DOM元素上的属性或样式来完成修改。
而对于滤镜(主要是颜色效果),SVGElement则会将其派发给elements/svgElements/SVGEffect进行管理,而SVGEffect
又会通过类型创建对应的effects下的对象,从而真正派发给这些对象进行渲染,其本质上其实也是生成色彩矩阵,归一化成矩阵进行管理。
CanvasRenderer
相较于SVGRenderer,CanvasRenderer受限于其能力,所以要简洁许多,当然这也造成了一些效果它不支持,比如滤镜效果(可以看到CanvasEffects.prototype.renderFrame = function(){}
)。
CanvasRenderer本质上就是根据动画数据将每一帧的对象不断重绘出来,没有特别好说的。像是tranform变换这些和SVG的归一化处理基本一致。
HTMLRenderer
HTMLRenderer就没什么好说的了,受限于其功能,支持的特性最少,只能做一些很简单的图形或者文字,也不支持滤镜效果。其生成变换的原理倒是和SVG有些类似,毕竟都是DOM嘛。