1. 程式人生 > >Lottie 動畫

Lottie 動畫

本文主要講解 Lottie 庫動態載入 SD 卡上帶圖片資源的動畫,並對各種機型做全屏適配。

Lottie 的優點:

  • 跨平臺,支援 Android、iOS、React Native 平臺

  • 支援實時渲染 After Effects 動畫,讓 app 載入動畫像載入圖片一樣簡單。

  • 資源動態下載,減小 APP 體積,上線新的動畫效果不需要發版

  • 更多優點等你發現

使用場景

  • 做直播軟體肯定少不了各種禮物的動畫效果,當上線新的禮物時,不僅 Android、ios 客戶端需要實現新的動畫效果,還很難相容老版本。

  • 平常過節日的時候,很多 APP 都會做各種活動,修改啟動頁的圖片,更改應用內的按鈕圖示,如果涉及到動畫,那麼肯定需要提前一兩個版本將節日的動畫實現程式碼預製到應用內

  • 更多應用場景等你探索

現在有了 Lottie,可以讓設計師使用 After Effects 進行動畫設計,通過 Bodymovin 外掛匯出 json 檔案,將動畫資源打包上傳到伺服器後,客戶端通過動態下載資原始檔來執行動畫。這樣上線新的禮物,只需要將資原始檔上傳,客戶端不需要發版完全可以執行新禮物的動畫效果。流程如下:

螢幕快照 2017-05-05 10.21.47.png

使用詳解

本文就以直播間播放動畫為例子來講解具體的實現方案,先看下動畫效果:

直播軟體的大禮物一般都是飛機、跑車、航母、花瓣雨等,這些物品不是簡單的線條、色塊所能繪製,所以使用圖片檔案來實現動畫效果。
匯出的動畫資源包括一個 json 檔案和一組圖片檔案:

將這些檔案打成壓縮包上傳到後臺,客戶端下載壓縮包進行解壓,使用 Lottie 載入本地資源執行動畫:

 
  1. File jsonFile = new File(giftDir, "79.json");
  2. File imagesDir = new File(giftDir, "images");
  3. FileInputStream fis = null;
  4. if (jsonFile.exists()) {
  5.     try {
  6.         fis = new FileInputStream(jsonFile);
  7.     } catch (FileNotFoundException e) {
  8.         e.printStackTrace();
  9.     }
  10. }
  11. if (fis == null || !imagesDir.exists()) {
  12.     showLocalAnimation(gift);
  13.     return;
  14. }
  15. final String absolutePath = imagesDir.getAbsolutePath();
  16. //提供一個代理介面從 SD 卡讀取 images 下的圖片
  17. mLottieAnimationView.setImageAssetDelegate(new ImageAssetDelegate() {
  18.     @Override
  19.     public Bitmap fetchBitmap(LottieImageAsset asset) {
  20.         BitmapFactory.Options opts = new BitmapFactory.Options();
  21.         opts.inScaled = true;
  22.         opts.inDensity = 160;
  23.         return BitmapFactory.decodeFile(absolutePath + File.separator +
  24.                 asset.getFileName(), opts);
  25.     }
  26. });
  27. //從檔案流中載入 json 資料
  28. LottieComposition.Factory.fromInputStream(this, fis, new OnCompositionLoadedListener() {
  29.     @Override
  30.     public void onCompositionLoaded(LottieComposition composition) {
  31.         mLottieAnimationView.setVisibility(View.VISIBLE);
  32.         mLottieAnimationView.setComposition(composition);
  33.         mLottieAnimationView.playAnimation();
  34.     }
  35. });

關鍵的程式碼就是設定圖片資源代理,去 SD 卡解析圖片檔案,那麼怎麼知道該解析哪一張圖片呢?咱們來看看 json 檔案裡面的內容:

assets 欄位是圖片資源的陣列,具體的解析的原始碼如下:

 
  1. private LottieImageAsset(int width, int height, String id, String fileName) {
  2.     this.width = width;
  3.     this.height = height;
  4.     this.id = id;
  5.     this.fileName = fileName;
  6. }
  7. static class Factory {
  8.     private Factory() {}
  9.     static LottieImageAsset newInstance(JSONObject imageJson) {
  10.         return new LottieImageAsset(
  11.                   imageJson.optInt("w"),     //width
  12.                   imageJson.optInt("h"),     //height
  13.                   imageJson.optString("id"), //id
  14.                   imageJson.optString("p")   //fileName
  15.                );
  16.     }
  17. }

直接根據 ImageAssetDelegate 代理類的 fetchBitmap(LottieImageAsset asset) 方法中的 LottieImageAsset 引數獲取當前需要解析的圖片檔名,去 images 資料夾下面解析對應的檔案就OK啦。

這幾行程式碼就實現了從SD卡動態載入動畫,那麼這樣就算完工了嗎?看看上面的動畫是不是感覺有什麼地方不對勁?好吧,作為一個 Android 軟體工程師,一定要記住2個字 適配 適配 適配

原始碼解析

動畫是全屏的效果,小幽靈也是從螢幕外飛進來的沒有問題,為什麼背景圖離螢幕兩邊有空隙呢?

再來看看這個動圖,為什麼隱藏虛擬按鍵就全屏了呢?

再來看看 json 檔案裡面的內容:

背景圖的寬高和畫布的寬高是一樣的,那麼為什麼有虛擬按鍵的時候背景圖就不全屏呢?原因其實很簡單,來看一下 Lottie 是怎麼解析 json 資料:

 
  1. static LottieComposition fromJsonSync(Resources res, JSONObject json) {
  2.       Rect bounds = null;
  3.       float scale = res.getDisplayMetrics().density;
  4.       int width = json.optInt("w", -1);
  5.       int height = json.optInt("h", -1);
  6.       if (width != -1 && height != -1) {
  7.         int scaledWidth = (int) (width * scale);
  8.         int scaledHeight = (int) (height * scale);
  9.         bounds = new Rect(0, 0, scaledWidth, scaledHeight);
  10.       }
  11.       long startFrame = json.optLong("ip", 0);
  12.       long endFrame = json.optLong("op", 0);
  13.       int frameRate = json.optInt("fr", 0);
  14.       LottieComposition composition =
  15.           new LottieComposition(bounds, startFrame, endFrame, frameRate, scale);
  16.       JSONArray assetsJson = json.optJSONArray("assets");
  17.       parseImages(assetsJson, composition);
  18.       parsePrecomps(assetsJson, composition);
  19.       parseLayers(json, composition);
  20.       return composition;
  21. }

解析出了動畫的寬高、幀率等資訊,這裡將解析出來的寬高乘上了螢幕的畫素密度,然後設定渲染區域的邊界,為了便於理解,本文將其稱為畫布。我這臺手機是 1080P 的解析度,density = 3,scaledWidth = 2250,scaledHeight = 4002,現在縮放後的畫布寬高比手機螢幕大了太多,如果動畫在這種尺寸下進行渲染肯定不行。所以 LottieAnimationView 載入 Composition 時判斷了畫布的寬高如果大於手機螢幕的寬高就進行等比例縮小:

 
  1. public void setComposition(@NonNull LottieComposition composition) {
  2.     if (L.DBG) {
  3.       Log.v(TAG, "Set Composition \n" + composition);
  4.     }
  5.     lottieDrawable.setCallback(this);
  6.     boolean isNewComposition = lottieDrawable.setComposition(composition);
  7.     if (!isNewComposition) {
  8.       // We can avoid re-setting the drawable, and invalidating the view, since the composition
  9.       // hasn't changed.
  10.       return;
  11.     }
  12.     //重點在這裡,根據螢幕寬高對畫布進行等比例縮放
  13.     int screenWidth = Utils.getScreenWidth(getContext());
  14.     int screenHeight = Utils.getScreenHeight(getContext());
  15.     int compWidth = composition.getBounds().width();
  16.     int compHeight = composition.getBounds().height();
  17.     //如果畫布的寬高大於螢幕寬高,計算縮放比
  18.     if (compWidth > screenWidth ||
  19.         compHeight > screenHeight) {
  20.       float xScale = screenWidth / (float) compWidth;
  21.       float yScale = screenHeight / (float) compHeight;
  22.       //按比例縮小
  23.       setScale(Math.min(xScale, yScale));
  24.       Log.w(L.TAG, String.format(
  25.           "Composition larger than the screen %dx%d vs %dx%d. Scaling down.",
  26.           compWidth, compHeight, screenWidth, screenHeight));
  27.     }
  28.     // If you set a different composition on the view, the bounds will not update unless
  29.     // the drawable is different than the original.
  30.     setImageDrawable(null);
  31.     setImageDrawable(lottieDrawable);
  32.     this.composition = composition;
  33.     requestLayout();
  34. }

計算出寬和高的縮放比後,為了讓畫布小於螢幕,所以取較小的一個比例,呼叫 setScale 方法將縮放比設定到 lottieDrawable 上:

 
  1. public void setScale(float scale) {
  2.     lottieDrawable.setScale(scale);
  3.     if (getDrawable() == lottieDrawable) {
  4.       setImageDrawable(null);
  5.       setImageDrawable(lottieDrawable);
  6.     }
  7. }

lottieDrawable 的 setScale 方法儲存了縮放比,並且更新了繪製的矩形範圍:

 
  1. public void setScale(float scale) {
  2.     this.scale = scale;
  3.     updateBounds();
  4. }

這裡可以看到矩形的範圍是根據畫布的寬高進行了等比例的縮放

 
  1. private void updateBounds() {
  2.     if (composition == null) {
  3.       return;
  4.     }
  5.     setBounds(0, 0, (int) (composition.getBounds().width() * scale),
  6.         (int) (composition.getBounds().height() * scale));
  7. }

現在畫布被縮放了,然而背景圖呢?來看一下 lottieDrawable 的繪製程式碼:

 
  1. public void draw(@NonNull Canvas canvas) {
  2.     if (compositionLayer == null) {
  3.       return;
  4.     }
  5.     matrix.reset();
  6.     matrix.preScale(scale, scale);
  7.     compositionLayer.draw(canvas, matrix, alpha);
  8.   }

lottieDrawable 在繪製的時候對 matrix 設定了縮放比,然後呼叫了 compositionLayer 去進行具體的繪製。這個 compositionLayer 就是所有圖層的一個組合,它有一個List<BaseLayer> layers屬性, 這個屬性就是 json 檔案裡面的layers節點解析出來的圖層列表,每個圖層中間還包含一些屬性動畫。compositionLayer.draw(canvas, matrix, alpha)方法中主要呼叫了 drawLayer 抽象方法由圖層的具體實現類執行繪製:

 
  1. void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
  2.     for (int i = layers.size() - 1; i >= 0 ; i--) {
  3.       layers.get(i).draw(canvas, parentMatrix, parentAlpha);
  4.     }
  5. }

可以看到 CompositionLayer 類的 drawLayer 方法遍歷 layers 集合進行迴圈繪製,這裡是使用圖片檔案做的動畫,對應的 Layer 實現類為 ImageLayer。 看下 ImageLayer 的繪製方法:

 
  1. public void drawLayer(@NonNull Canvas canvas, Matrix parentMatrix, int parentAlpha) {
  2.     Bitmap bitmap = getBitmap();
  3.     if (bitmap == null) {
  4.       return;
  5.     }
  6.     paint.setAlpha(parentAlpha);
  7.     canvas.save();
  8.     canvas.concat(parentMatrix);
  9.     src.set(0, 0, bitmap.getWidth(), bitmap.getHeight());
  10.     dst.set(0, 0, (int) (bitmap.getWidth() * density), (int) (bitmap.getHeight() * density));
  11.     canvas.drawBitmap(bitmap, src, dst , paint);
  12.     canvas.restore();
  13. }

getBitmap() 方法會呼叫到一開始設定的代理類 ImageAssetDelegate ,從 SD 卡載入圖片。

程式碼中呼叫了 canvas 的save、restore方法來進行圖層的疊加繪製,從 lottieDrawable 的draw方法傳遞下來的matrix用到了concat方法上,對 bitmap 進行了等比縮放。

整個流程跑下來,Lottie 庫的動畫渲染機制已經基本瞭解,背景圖沒有全屏展示的原因如下:

背景圖的長寬比是 16 : 9,手機螢幕的長寬比也是 16 : 9,但是因為底部的虛擬按鍵佔了一部分的高度,螢幕可用空間的長寬比大約為 3 : 2,所以導致背景圖不能鋪滿螢幕

全屏適配

動畫不能全屏有兩種情況,一種是手機長寬比和畫布的長寬比是相同的,但是狀態列、導航欄佔了螢幕一部分空間導致不能全屏,使用方案一可以解決問題。還有一種情況是手機螢幕長寬比和畫布的長寬比就是不一樣,畢竟 Android 機型這麼多,有幾臺奇葩手機很正常,那麼使用方案二可以實現全屏。

方案一

在執行動畫的介面隱藏虛擬按鍵,或者將虛擬按鍵設定為透明浮在佈局上面,這樣螢幕的長寬比和畫布的長寬比一樣就沒有問題。目前市面上的手機基本上都是 720P、1080P、2K 等解析度,這些解析度都是 16 : 9 的尺寸。

狀態列和虛擬按鍵透明懸浮在佈局上面,設定樣式:

 
  1. <style name="Theme">
  2.         <item name="android:windowTranslucentStatus">true</item>
  3.         <item name="android:windowTranslucentNavigation">true</item>
  4. </style>

隱藏虛擬按鍵通過程式碼設定:

 
  1. Window window = getWindow();
  2. int visibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
  3.                 View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
  4.                 View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
  5.                 View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
  6. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
  7.     window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
  8.     window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
  9.     window.setStatusBarColor(ContextCompat
  10.                     .getColor(getActivity(), android.R.color.transparent));
  11.     visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
  12. } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
  13.     window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
  14.     visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
  15. }
  16. window.getDecorView().setSystemUiVisibility(visibility);

使用程式碼隱藏虛擬按鍵需要注意一點:介面的切換會導致 setSystemUiVisibility() 的設定被清空,最好是在 onResume() 或者 onWindowFocusChanged() 方法中進行設定。

方案二

如果要適配其他長寬比的螢幕,咋辦呢?兩行程式碼解決問題,只不過圖片有一部分會被裁剪。設定控制元件的寬高為match_parent,設定android:scaleType為centerCrop

 
  1. <com.airbnb.lottie.LottieAnimationView
  2.         android:id="@+id/lottieAnimationView"
  3.         android:layout_width="match_parent"
  4.         android:layout_height="match_parent"
  5.         android:scaleType="centerCrop"/>

總結

Lottie 釋出才幾個月,很多功能還不夠完善,快取機制也比較弱,像這種從 SD 卡動態載入的方式,需要自己去實現快取邏輯。但是這點小瑕疵掩蓋不了牛逼的事實,就目前這個需求來說,已經大大的降低了開發成本。只不過設計師們需要好好練練 AE 了,動畫炫不炫就看設計師給不給力啦