【原始碼分析】Lottie 實現炫酷動畫背後的原理

程式亦非猿發表於2019-04-23

0. 前言

你好,我是程式亦非猿,阿里資深無線開發工程師一枚。

自我在內網釋出了一篇關於 Lottie 的原理分析的文章之後,就不斷有同事來找我詢問關於 Lottie 的各種東西,最近又有同事來問,就想著可能對大家也會有所幫助,就稍作處理後分享出來。

需要注意的是,這文章寫於兩年前,基本版本 2.0.0-beta3,雖然我看過最新版本,主要的類沒有什麼差別,不過可能還是會存在一些差異。

可以感受一下我兩年前的實力。:-D

1. Lottie 是什麼?

Render After Effects animations natively on Android and iOS

Lottie 是 airbnb 釋出的庫,它可以將 AE 製作的動畫 在 Android&iOS上以 native 程式碼渲染出來,目前還支援了 RN 平臺。

來看幾個官方給出的動畫效果案例:

【原始碼分析】Lottie 實現炫酷動畫背後的原理

【原始碼分析】Lottie 實現炫酷動畫背後的原理

有沒有很炫酷?

就拿第一個動畫 Jump-through 舉例,如果讓你來實現它,你能在多少時間內完成?三天?一個禮拜? google 的 Nick Butcher 剛好有一篇文章寫 Jump-through 的動畫實現,講述了整個實現過程,從文章裡可以看出實現這個動畫並不容易,有興趣的可以看看 Animation: Jump-through

但是現在有了 Lottie,只要設計師用 AE 設計動畫,利用 bodymovin 匯出 ,匯入到 assets, 再寫下面那麼點程式碼就可以實現了!

LottieAnimationView animationView = (LottieAnimationView) findViewById(R.id.animation_view);
animationView.setAnimation("PinJump.json");
animationView.loop(true);
animationView.playAnimation();
複製程式碼

不用寫自定義 View!不用畫 Path!不用去計算這個點那個點!

是不是超級方便?!!!

這麼方便的背後,原理是什麼呢?

2. TL;DR

bodymovin 將 AE 動畫匯出為 ,該  描述了該動畫,而 lottie-android 的原理就是將  描述的動畫用 native code 翻譯出來, 其核心原理是  canvas 繪製。對,lottie 的動畫是靠純 canvas 畫出來的!!!動起來則是靠的屬性動畫。(ValueAnimator.ofFloat(0f, 1f); )

說具體點就是 lottie 隨屬性動畫修改 progress,每一個 Layer 根據當前的 progress 繪製所對應的幀內容,progress 值變為1,動畫結束。(有點類似於幀動畫)

當然說說簡單,lottie其實做了非常多的工作,後續將詳細解析 lottie-android 的實現原理。

3. Lottie 關鍵類介紹

Lottie 提供了一個 LottieAnimationView 給使用者使用,而實際 Lottie 的核心是 LottieDrawable,它承載了所有的繪製工作,LottieAnimationView則是對LottieDrawable 的封裝,再附加了一些例如 解析  的功能。

  • LottieComposition 是  對應的 Model,承載  的所有資訊。
  • CompositionLayer 是 layer 的集合。
  • ImageAssetBitmapManager 負責管理動畫所需的圖片資源。

它們的關係:

【原始碼分析】Lottie 實現炫酷動畫背後的原理

4. 檔案的屬性含義

bodymovin  匯出的  包含了動畫的一切資訊, 動畫的關鍵幀資訊,動畫該怎麼做,做什麼都包含在 裡,Lottie 裡所有的 Model 的資料都來自於這個 ( 該 對應的 Model 是LottieComposition),所以要理解 Lottie 的原理,理解  的屬性是第一步。

屬性非常多,而且不同的動畫的  也有很大的差別,所以這裡只講解部分重要的屬性。

4.1 檔案最外部的結構

的最外層長這樣:

{
  "v": "4.5.9",
  "fr": 15,
  "ip": 0,
  "op": 75,
  "w": 500,
  "h": 500,
  "ddd": 0,
  "assets":[]
  "layers":[]
 }
複製程式碼

屬性的含義:

屬性 含義
v bodymovin的版本
fr 幀率
ip 起始關鍵幀
op 結束關鍵幀
w 動畫寬度
h 動畫高度
assets 動畫圖片資源資訊
layers 動畫圖層資訊

從這裡可以獲取 設計的動畫的寬高,幀相關的資訊,動畫所需要的圖片資源的資訊以及圖層資訊。

a) assets

圖片資源資訊, 相關類 LottieImageAsset、 ImageAssetBitmapManager。

"assets": [
    {
      "id": "image_0",
      "w": 500,
      "h": 500,
      "u": "images/",
      "p": "voice_thinking_image_0.png"
    }
  ]
複製程式碼

屬性的含義:

屬性 含義
id 圖片 id
w 圖片寬度
h 圖片高度
p 圖片名稱

b) layers

圖層資訊,相關類:Layer、BaseLayer以及 BaseLayer 的實現類。

{
    "ddd": 0,
    "ind": 0,
    "ty": 2,
    "nm": "btnSlice.png",
    "cl": "png",
    "refId": "image_0",
    "ks": {....},
    "ao": 0,
    "ip": 0,
    "op": 90.0000036657751,
    "st": 0,
    "bm": 0,
    "sr": 1
}
複製程式碼

屬性的含義:

屬性 含義
nm layerName 圖層資訊
refId 引用的資源 id,如果是 ImageLayer 那麼就是圖片的id
ty layertype 圖層型別
ip inFrame 該圖層起始關鍵幀
op outFrame 該圖層結束關鍵幀
st startFrame 開始
ind layer id  圖層 id

Layer 可以理解為圖層,跟 PS 等工具的概念相同,每個 Layer 負責繪製自己的內容。

在 Lottie 裡擁有不同的 Layer,目前有 PreComp,Solid,Image,Null,Shape,Text ,各個 Layer 擁有的屬性各不相同,這裡只指出共有的屬性。

下圖為 Layer 相關類圖:

【原始碼分析】Lottie 實現炫酷動畫背後的原理

5. Lottie 的適配原理

在開始使用 Lottie 的時候,我們團隊設計動畫走的跟設計圖片一樣的路子,想設計2x,3x 多份資源進行適配。但是,通過閱讀原始碼發現其實 Lottie本身在 Android 平臺已經做了適配工作,而且適配原理很簡單,解析  時,從  讀取寬高之後 會再乘以手機的密度。再在使用的時候判斷適配後的寬高是否超過螢幕的寬高,如果超過則再進行縮放。以此保障 Lottie 在 Android 平臺的顯示效果。

核心程式碼如下:

//LottieComposition.fromSync
  float scale = res.getDisplayMetrics().density;
  int width = .optInt("w", -1);
  int height = .optInt("h", -1);

  if (width != -1 && height != -1) {
    int scaledWidth = (int) (width * scale);
    int scaledHeight = (int) (height * scale);
    bounds = new Rect(0, 0, scaledWidth, scaledHeight);
  }
  
  //LottieAnimationView.setComposition 

    int screenWidth = Utils.getScreenWidth(getContext());
    int screenHeight = Utils.getScreenHeight(getContext());
    int compWidth = composition.getBounds().width();
    int compHeight = composition.getBounds().height();
    if (compWidth > screenWidth ||
        compHeight > screenHeight) {
      float xScale = screenWidth / (float) compWidth;
      float yScale = screenHeight / (float) compHeight;
      setScale(Math.min(xScale, yScale));
      Log.w(L.TAG, String.format(
          "Composition larger than the screen %dx%d vs %dx%d. Scaling down.",
          compWidth, compHeight, screenWidth, screenHeight));
    }
複製程式碼

這裡值得一提的是,設計師在設計動畫時要注意,需要設計的是1X 的動畫,而不是2X or 3X or 4X。

目前手淘用的方案是 按4X 來設計(1X看不清元素),然後再縮小為1X圖片資源是4X

6. Lottie的繪製原理

LottieAnimationView 本身是個 ImageView,所以它的繪製流程跟 ImageView 一樣,所有的繪製其實在 LottieDrawable 控制的。

接下去看看它的原始碼實現:

// LottieDrawable
@Override public void draw(@NonNull Canvas canvas) {
    if (compositionLayer == null) {
      return;
    }
    float scale = this.scale;
    if (compositionLayer.hasMatte()) {
      scale = Math.min(this.scale, getMaxScale(canvas));
    }

    matrix.reset();
    matrix.preScale(scale, scale);
    compositionLayer.draw(canvas, matrix, alpha);
  }
複製程式碼

可以看到在 draw方法裡呼叫了 compositionLayer.draw方法,由於 CompositionLayer 繼承了 BaseLayer,所以需要跟進 BaseLayer ,繼續跟蹤:

// BaseLayer.draw
  @Override
  public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
    if (!visible) {
      return;
    }
    buildParentLayerListIfNeeded();
    //矩陣變換處理
    //....
    if (!hasMatteOnThisLayer() && !hasMasksOnThisLayer()) {
      matrix.preConcat(transform.getMatrix());
      //繪製 layer
      drawLayer(canvas, matrix, alpha);
      return;
    }
	//draw matteLayer& maskLayer
	//...
    canvas.restore();
  }
複製程式碼

刪除了多餘程式碼,只保留核心程式碼,可以看到 draw 方法裡呼叫了抽象方法 drawLayer,在這裡的實現在 CompositionLayer ,一起來看看:

@Override void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
    //...
    for (int i = layers.size() - 1; i >= 0 ; i--) {
      boolean nonEmptyClip = true;
      if (!newClipRect.isEmpty()) {
        nonEmptyClip = canvas.clipRect(newClipRect);
      }
      if (nonEmptyClip) {
        layers.get(i).draw(canvas, parentMatrix, parentAlpha);
      }
    }
    //...
  }
複製程式碼

上面的程式碼中的 layers 是該動畫所包含的層,在 CompositionLayer 的 drawLayer 方法裡遍歷了動畫所有的層,並呼叫layers 的 draw 方法,這樣就完成了所有的繪製。

7. Lottie的動畫原理

上一小節講了 Lottie 的繪製原理,但是 Lottie 是用來做動畫的,光理解它的繪製原理是不夠的,對於動畫,更重要的是它怎麼動起來的。

接下來就分析一下 Lottie 的動畫原理。

Lottie 動畫起始於 LottieAnimationView.playAnimation,接著呼叫 LottieDrawable 的同名方法,與繪製相同,動畫也是 LottieDrawable 控制的,來看看程式碼:

//     animator 的申明
private final ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);

  private void playAnimation(boolean setStartTime) {
    if (compositionLayer == null) {
      playAnimationWhenCompositionAdded = true;
      reverseAnimationWhenCompositionAdded = false;
      return;
    }
    if (setStartTime) {
      animator.setCurrentPlayTime((long) (progress * animator.getDuration()));
    }
    animator.start();
  }
複製程式碼

playAnimation 方法其實只是開啟了一個屬性動畫,那麼後續動畫是怎麼動起來的呢?這就必須要看動畫的監聽了:

animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override public void onAnimationUpdate(ValueAnimator animation) {
        if (systemAnimationsAreDisabled) {
          animator.cancel();
          setProgress(1f);
        } else {
          setProgress((float) animation.getAnimatedValue());
        }
      }
    });
複製程式碼

在 animator 進行的過程中回去呼叫 setProgress方法,下面跟蹤一下程式碼:

//LottieDrawable
  public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
    this.progress = progress;
    if (compositionLayer != null) {
      compositionLayer.setProgress(progress);
    }
  }
  
  //CompositionLayer
  @Override public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
    super.setProgress(progress);
    progress -= layerModel.getStartProgress();
    for (int i = layers.size() - 1; i >= 0; i--) {
      layers.get(i).setProgress(progress);
    }
  }

//BaseLayer
  void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
    //...
    for (int i = 0; i < animations.size(); i++) {
      animations.get(i).setProgress(progress);
    }
  }

//BaseKeyframeAnimation
  void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
    if (progress < getStartDelayProgress()) {
      progress = 0f;
    } else if (progress > getEndProgress()) {
      progress = 1f;
    }

    if (progress == this.progress) {
      return;
    }
    this.progress = progress;

    for (int i = 0; i < listeners.size(); i++) {
      listeners.get(i).onValueChanged();
    }
  }

//BaseLayer
  @Override public void onValueChanged() {
    invalidateSelf();
  }

//BaseLayer
  private void invalidateSelf() {
    lottieDrawable.invalidateSelf();
  }
複製程式碼

上面列出了後續流程的主要程式碼,可以看到,setProgress 的最後觸發了每個 layer 的 invalidateSelf,這都會讓 lottieDrawable 重新繪製,然後重走一遍繪製流程,這樣隨著 animator 動畫的進行,lottieDrawable 不斷的繪製,就展現出了一個完整的動畫。

PS: 動畫過程中的一些變數比如 scale,都是由BaseKeyframeAnimation控制,但這個偏於細節,這裡就不講了。

動畫原理流程稍微有點長,也稍微有些複雜,我繪製了一張圖梳理了一下整體的流程,方便理解:

【原始碼分析】Lottie 實現炫酷動畫背後的原理

BaseKeyframeAnimation 類圖:

【原始碼分析】Lottie 實現炫酷動畫背後的原理

8. 總結

個人覺得 Lottie 是個非常非常棒的專案,甚至可以說是個偉大的專案。

Lottie 極大的縮減了動畫的開發成本,給 APP 增加非常強力的動畫能力,不需要各個端再自己去實現,而且目前 Lottie 已經支援了非常多的 AE 動畫效果,通過 Lottie 可以輕鬆實現很多酷炫的效果,所以現在做動效考驗的是設計同學的設計能力了,哈哈。

本文只針對重點原理進行分析,歡迎留言討論交流。

公眾號:程式亦非猿

9. 資料

  1. lottie-android : github.com/airbnb/lott…
  2. Introducing Lottie: medium.com/airbnb-engi…
  3. design-lottie: airbnb.design/lottie/
  4. bodymovin: github.com/bodymovin/b…
  5. Animation: Jump-through: medium.com/google-deve…

相關文章