Lottie原理與源碼解析

Lottie原理與源碼解析

來自專欄 Paradise - 前端視覺交互研究

在我的BLOG上觀看獲得最佳體驗。

Lottie(Bodymivin)是一個動畫庫,其和GSAP這類專註動畫曲線、插值等js動畫庫不同,它本質上是一套跨平台的平面動畫解決方案。其提供了一套完整得從AE到各個終端的工具流,通過AE的插件將設計師做的動畫導出成一套定義好的json文件,之後再通過渲染器進行渲染,它提供了「SVG」、「Canvas」和「HTML」三種渲染模式,最常用的是第一種和第二種。

特性

Lottie提供了如下特性,並給出了AE的功能支持度與限制:

airbnb.io/lottie/suppor

每個渲染器均有各自的實現,其複雜度也各有不同,但毫無例外的是圖形越多、參與運算的屬性越多、幀率越高,其對性能的消耗也就越高,這些要看實際的狀況。

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提供了很多這樣的靜態方法,比如setSpeedinBrowserinstallPlugin等,這些方法一般有兩個作用:其一是設置所有動畫對象的對應的屬性值,比如我通過Lottie.setSpeed(.5)修改了速度,那麼所有對象的速度就被修改為了的這個全局的速度;其二,靜態方法還可以在沒有拿到實際動畫對象的時候對其進行控制,比如Lottie.play(animation1)就可以播放註冊過的命名為animation1的對象,其等價於lottieAnimation1.play()

初始化動畫

lottie可以通過loadAnimationregisterAnimation來初始化動畫。後者需要預先插入一個Tag,這個Tag上有一個data-animation-path屬性,其的值需要指向你的動畫的json文件,這個一般在你有許多lottie動畫,預先對動畫進行了布局時很有用,但一般我們使用的是第一種做法:通過一個對象來初始化一個動畫對象。

可以在源碼中看到,loadAnimationregisterAnimation這兩個方法,以及其他大部分靜態方法實際上都被委託到了一個叫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;}

首先這個對象的閉包中定義了幾個全局的變數lenregisteredAnimations等,用於判斷和緩存已註冊的動畫元素。在調用這個方法時需要傳入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),參數中除了wrapperanimationData外還有pathautoplay這些參數,都是從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中有lottiebodymovin的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比較清晰。下面我列舉一下常用的方法。

常用的方法

  1. play(): 播放動畫。
  2. pause(): 暫停動畫。
  3. togglePause(): 切換播放和暫停的狀態。
  4. stop(): 停止動畫。
  5. goToAndStop(value: number, isFrame: boolean): 跳轉到某時間/幀並播放。
  6. goToAndPlay(value: number, isFrame: boolean): 跳轉到某時間/幀並停止。
  7. setSegment(init: number, end: number): 設置當前的segment區間。
  8. playSegments(range: [number, number], force: boolean): 設置並播放當前的segment區間,force為true的時候講立即播放,否則會等當前一循環播放完再切換。如果range[0] > range[1]則會設置方向為反播放。
  9. resetSegments(force: boolean): 重置當前的segment區間,force依然是是否立即生效的標誌。
  10. setSpeed(value: number): 設置播放速度,基準為1。
  11. 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提供了許多事件用於控制動畫的播放和初始化等,以下是暴露出來的一些事件和觸發時機:

  1. complete:在播放結束後被觸發(一次Loop結束不算)。
  2. loopComplete:在沒次Loop結束後觸發。
  3. enterFrame:在進入每一幀的時候觸發(注意stop的時候也會觸發一次)。
  4. segmentStart:當一段segment開始播放的時候觸發。
  5. config_ready:當所有的參數配置解析完畢後觸發。
  6. data_ready:在所有的segments被載入完畢後觸發。注意這個當你直接傳入animationData而非path的時候,解析是同步的,所以執行完loadAnimation的時候實際上已經被觸發過了,並且這個事件是超前與image這種資源的載入的,所以建議加上if (animation.loaded)的判斷或綁定DOMLoaded事件。
  7. data_failed:雖然文檔寫了但代碼里並沒有這個事件嗯。
  8. loaded_images:所有圖片資源載入完畢的時候會觸發,雖然代碼里寫了 if (!error)然而當你詳細去看的時候只會發現err永遠為null。
  9. DOMLoaded:當元素被添加到DOM的時候觸發,這個是比較可靠的可以替換data_ready的事件。
  10. destroy:當元素被釋放的時候觸發。

數據結構

接下來要講最大頭的渲染了,不過在這之前,我們需要先了解一下lottie所定義的數據結構,否則看到那些奇怪的ipopfr這些欄位不知道是啥意思就尷尬了。

其實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.rendererrenderFrame,將當前幀數傳給其進行渲染。

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(); } }}

這個渲染流程分為了三個步驟,分別是checkLayersprepareFramerenderFrame

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而言,所有元素本質上都被分成了兩種:shapetext,而這兩種元素有各自的渲染方法進行渲染。注意這兩種元素的實現也有一些共性,那就是對於動畫中的變換以及濾鏡等,他們的實現中都有類似這麼一句:

extendPrototype([BaseElement,TransformElement,SVGBaseElement,IShapeElement,HierarchyElement,FrameElement,RenderableDOMElement], SVGShapeElement);

前面這一大長串的列表就是指這個元素類繼承了多少種特性,這些特性中有來自於通用變換特性的像是TransformElementRenderableDOMElement(在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嘛。


推薦閱讀:

HTML5 為什麼不直接省略標準類型聲明 !Doctype ?
HTML5學習路線
突然發現一個問題,如果用touchstart替換了click 問題大了!?
SVG入門
What are the Differences between HTML5 and HTML 5.1?

TAG:HTML5 | 平面設計 | SVG |