剖析 lottie-web 動畫實現原理

BodyMovin

圖片來源: https://aescripts.com/bodymovin/

本文做者:青舟javascript

前言

Lottie 是一個複雜幀動畫的解決方案,它提供了一套從設計師使用 AE(Adobe After Effects)到各端開發者實現動畫的工具流。在設計師經過 AE 完成動畫後,可使用 AE 的擴展程序 Bodymovin 導出一份 JSON 格式的動畫數據,而後開發同窗能夠經過 Lottie 將生成的 JSON 數據渲染成動畫。css

一、如何實現一個 Lottie 動畫

實現一個 Lottie 動畫流程

  1. 設計師使用 AE 製做動畫。
  2. 經過 Lottie 提供的 AE 插件 Bodymovin 把動畫導出 JSON 數據文件。
  3. 加載 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/html

二、解讀 JSON 文件數據格式

筆者本身製做了 Lottie Demo -> 點我預覽前端

  • 0s 至 3s,scale 屬性值從 100% 變到 50%。
  • 3s 至 6s,scale 屬性值從 50% 變到 100%,完成動畫。

動畫變化路徑

經過 Bodymovin 插件導出 JSON 數據結構以下圖所示:java

JSON 數據結構

詳細 JSON 信息能夠經過 Demo 查看,JSON 信息命名比較簡潔,第一次看可能難以理解。接下來結合筆者本身製做的 Demo 進行解讀。css3

2.1 全局信息

全局信息

左側爲使用 AE 新建動畫合成須要填入的信息,和右面第一層 JSON 信息對應以下:git

  • wh: 寬 200、高 200
  • v:Bodymovin 插件版本號 4.5.4
  • fr幀率 30fps
  • ipop:開始幀 0、結束幀 180
  • assets:靜態資源信息(如圖片)
  • layers:圖層信息(動畫中的每個圖層以及動做信息)
  • ddd:是否爲 3d
  • comps:合成圖層

其中 fripop 在 Lottie 動畫過程當中尤其重要,前面提到咱們的動畫 Demo 是 0 - 6s,可是 Lottie 是以幀率計算動畫時間的。Demo 中設置的幀率爲 30fps,那麼 0 - 6s 也就等同於 0 - 180 幀。github

2.2 圖層相關信息

理解 JSON 外層信息後,再來展開看下 JSON 中 layers 的具體信息,首先 demo 製做動畫細節以下:web

動畫細節

主要是 3 個區域:json

  • 內容區域,包含形狀圖層的大小、位置、圓度等信息。
  • 變化區域,包含 5 個變化屬性(錨點、位置、縮放、旋轉、不透明度)。
  • 縮放 3 幀(圖中綠色區域),在 0 幀、90 幀、180 幀對縮放屬性進行了修改,其中圖中所示爲第 90 幀,圖層縮放至 50%。

對應上圖動畫製做信息,即可以對應到 JSON 中的 layers 了。以下圖所示:

layers

2.3 屬性變化信息

接下來再看 ks(變化屬性) 中的 s 展開,也就是縮放信息。

ks信息

其中:

  • t 表明關鍵幀數
  • s 表明變化前(圖層爲二維,因此第 3 個值 固定爲 100)。
  • e 表明變化後(圖層爲二維,因此第 3 個值 固定爲 100)。

三、Lottie 如何把 JSON 數據動起來

前面簡單理解了 JSON 的數據意義,那麼 Lottie 是如何把 JSON 數據動起來的呢?接下來結合 Demo 的 Lottie 源碼閱讀,只會展現部分源碼,重點是理清思路便可,不要執着源代碼。

如下源碼介紹主要分爲 2 大部分:

  • 動畫初始化(3.1小節 - 3.3小節)
  • 動畫播放(3.4 小節)

3.1 初始化渲染器

Demo 所示,Lottie 經過 loadAnimation 方法來初始化動畫。渲染器初始化流程以下:

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 三個事件等待被觸發。因爲能夠多個動畫並行,所以定義了全局的變量 lenregisteredAnimations 等,用於判斷和緩存已註冊的動畫實例。
  • 接下來調用 animItem 實例的 setParams 方法初始化動畫參數,除了初始化 loopautoplay 等參數外,最重要的是選擇渲染器。以下:
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 渲染第一幀,若是開發者配置了 autoplaytrue,則會直接調用 play 方法播放。這裏有個印象就好,會在後面詳細講。接下來仍是先看 initItems 實現細節。

3.3 繪製動畫初始圖層

initItems 方法主要是調用 buildAllItems 建立全部圖層。buildItem 方法又會調用 createItem 肯定具體圖層類型,這裏的方法源碼中拆分較細,本文只保留了 createItem 方法,其餘感興趣能夠查看源碼細節。

initItems

在製做動畫時,設計師操做的圖層元素有不少種,好比圖片、形狀、文字等等。因此 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 方法。

其餘圖層類型的渲染邏輯,如 ImageTextAudio 等等,每一種元素的渲染邏輯都實如今源碼 player/js/elements/ 文件夾下,具體實現邏輯這裏就不進行展開了,感興趣的同窗自行查看。

接下來即是執行 createShape 方法,初始化元素相關屬性。

繪製Shape圖層

除了一些細節的初始化方法,其中值得注意的是 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()
    };
},

利用 TransformPropertyFactorytransform 初始化,結合 Demo 第 0 幀,對應以下:

動畫變化路徑

  • 不透明度 100%
  • 縮放 100%
transform: scale(1);
opacity: 1;

那麼爲何在初始化渲染圖層時,須要初始化 transformopacity 呢?這個問題會在 3.4 小節中進行回答。

3.4 Lottie 動畫播放

在分析 Lottie 源碼動畫播放前,先來回憶下。筆者 Demo 的動畫設置:

  • 0s 至 3s,scale 屬性值從 100% 變到 50%。
  • 3s 至 6s,scale 屬性值從 50% 變到 100%。

若是按照這個設置,3s 進行一次改變的話,那動畫就過於生硬了。所以設計師設置了幀率爲 30fps ,意味着每隔 33.3ms 進行一次變化,使得動畫不會過於僵硬。那麼如何實現這個變化,即是 3.3 小節提到的 transformopacity

在 2.2 小節中提到的 5 個變化屬性(錨點、位置、縮放、旋轉、不透明度)。其中不透明度經過 CSS 的 opacity 來控制,其餘 4 個(錨點、位置、縮放、旋轉)則經過 transformmatrix 來控制。筆者的 Demo 中實際上初始值以下:

transform: matrix(1, 0, 0, 1, 100, 100);
/* 上文的 transform: scale(1); 只是爲了方便理解*/
opacity: 1;

這是由於不管是旋轉仍是縮放等屬性,本質上都是應用 transformmatrix() 方法實現的,所以 Lottie 統一使用 matrix 處理。平時開發者使用的相似於 transform: scale 這種表現形式,只是由於更容易理解,記憶與上手。 matrix 相關知識點能夠學習張鑫旭老師的 理解CSS3 transform中的Matrix

矩陣

因此 Lottie 動畫播放流程可暫時小結爲:

  1. 渲染圖層,初始化全部圖層的 transformopacity
  2. 根據幀率 30fps,計算每一幀(每隔 33.3ms )對應的 transformopacity 並修改 DOM

然而 Lottie 如何控制 30fps 的時間間隔呢?若是設計師設置 20fps or 40fps 怎麼處理?能夠經過 setTimeoutsetInterval 實現嗎?帶着這個問題看看源碼是如何處理的,如何實現一個通用的解決方案。

Lottie 動畫播放主要是使用 AnimationItem 實例的 play 方法。若是開發者配置了 autoplaytrue,則會在全部初始化工做準備完畢後(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%

對應計算代碼在 TransformPropertyFactory 類中:

// 計算百分比
perc = fnc((frameNum - keyTime) / (nextKeyTime - keyTime ));
endValue = nextKeyData.s || keyData.e;
// 計算值
keyValue = keyData.s[i] + (endValue[i] - keyData.s[i]) * perc;

其中 fnc 爲計算函數,若是設置了貝塞爾運動曲線函數,那麼 fnc 也會相應修改計算規則。當前 Demo 爲了方便理解,採用的是線性變化。具體源碼感興趣的同窗能夠自行查看。

計算好當前 scale 的值後,再利用 TransformPropertyFactory 計算好當前對應的 transformmatrix 值,而後修改對應 DOM 元素上的 CSS 屬性。這樣經過 requestAnimationFrame 不停的計算幀數,再計算對應的 CSS 變化,在必定的時間內,便實現了動畫。播放流程以下:

播放流程

幀數計算這裏須要時刻記住,在 Lottie 中,把 AE 設置的幀數做爲一個計算單位,Lottie 並非根據設計師設置的 30fps(每隔 33.3ms) 進行每一次變化,而是根據 requestAnimationFrame 的間隔(每隔 16.7ms 左右)計算了更細緻的變化,保證動畫的流暢運行。

沒有經過 setTimeoutsetInterval 實現,是由於它們都有各自的缺點,這裏就不展開了,你們自行查閱資料。requestAnimationFrame 採用系統時間間隔,保持最佳繪製效率,讓動畫可以有一個統一的刷新機制,從而節省系統資源,提升系統性能,改善視覺效果。

四、總結

雖然咱們瞭解了 Lottie 的實現原理,可是在實際應用中也有一些優點和不足,要按照實際狀況進行取捨。

4.1 Lottie 的優點

  1. 設計師經過 AE 製做動畫,前端能夠直接還原,不會出現買家秀賣家秀的狀況。
  2. SVG 是可伸縮的,任何分辨率下不會失真。
  3. JSON 文件,能夠多端複用(Web、Android、iOS、React Native)。
  4. JSON 文件大小會比 GIF 以及 APNG 等文件小不少,性能也會更好。

4.2 Lottie 的不足

  1. Lottie-web 文件自己仍然比較大,未壓縮大小爲 513k,輕量版壓縮後也有 144k,通過 Gzip 後,大小爲39k。因此,須要注意 Lottie-web 的加載。
  2. 沒必要要的序列幀。Lottie 的主要動畫思想是繪製某一個圖層不斷的改變 CSS 屬性,若是設計師偷懶用了一些插件實現的動畫效果,可能會形成每一幀都是一張圖,以下圖所示,那就會形成這個 JSON 文件很是大,注意和設計師提早進行溝通。

沒必要要的序列幀

  1. 部分AE特效不支持。有少許的 AE 動畫效果,Lottie 沒法實現,有些是由於性能問題,有些是沒有作,注意和設計師提早溝通,點我查看

五、參考資料

本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!
相關文章
相關標籤/搜索