Android•Lottie動畫庫填坑記

1004145468發表於2018-09-01

1. 入坑背景

由於從事直播軟體開發的緣故,本猿在版本迭代過程中一期不落的接觸到各式各樣動畫效果。最早的時候,苦逼的用Android原生動畫做直播間全屏禮物,反覆的看著美工給的Flash效果圖,不斷的拼湊素材圖片,調整控制動畫播放的屬性值,各個動畫程式碼都很類似,但卻無法套用,一連兩三天下來,基本上腦海中除了動畫就一片空白...不過後來採用spine禮物框架以後,也就告別這樣的悲慘人生。然而就在上一版本中,產品因為...的原因,讓不同的使用者進入房間有不一樣的效果,其中就包括文字背景帶粒子效果,對於這樣的效果,Android原生動畫顯然無能為力,如果採用幀動畫,由於大量素材檔案的引入帶來最直接的不良影響就是安裝包體積過大。經過評估之後,決定使用三方動畫框架,從伺服器下載動畫資源,在特定時間對不同資原始檔進行播放,最終採用相對比較成熟的Lottie框架。

2. 踩坑準備

熟悉一個新的框架最快的方式就是檢視官方文件,因為官方文件中一般都會給出一個Demo,果不其然,Lottie也是!文件的閱讀量不是很大,通篇下來介紹了:

  • 播放本地Assets目錄下的Json動畫檔案
  • 通過Json資料播放動畫
  • 如何對動畫進行監聽以及動畫進度調節
  • Lottie動畫資料的預載入和快取
  • 為Assets目錄下的Json動畫檔案配置動畫所需要的素材

3. 開始入坑

然而,他介紹了這麼多,並沒有一款適合我的。因為伺服器下發不是簡單的Json資料,是一個動畫壓縮包,裡面包括了動畫檔案和播放動畫需要的素材檔案,而且解壓後的檔案也不在Asset目錄下。於是,只好跟蹤animationView.setAnimation("hello-world.json")原始碼,看看最終到底做了什麼事!

  public void setAnimation(String animationName) {
    setAnimation(animationName, defaultCacheStrategy);
  }
複製程式碼

一個引數呼叫兩個引數同名方法,只好接著往下看!

  public void setAnimation(final String animationName, final CacheStrategy cacheStrategy) {
    this.animationName = animationName;
    if (weakRefCache.containsKey(animationName)) {
      WeakReference<LottieComposition> compRef = weakRefCache.get(animationName);
      if (compRef.get() != null) {
        setComposition(compRef.get());
        return;
      }
    } else if (strongRefCache.containsKey(animationName)) {
      setComposition(strongRefCache.get(animationName));
      return;
    }

    this.animationName = animationName;
    lottieDrawable.cancelAnimation();
    cancelLoaderTask();
    compositionLoader = LottieComposition.Factory.fromAssetFileName(getContext(), animationName,
        new OnCompositionLoadedListener() {
          @Override
          public void onCompositionLoaded(LottieComposition composition) {
            if (cacheStrategy == CacheStrategy.Strong) {
              strongRefCache.put(animationName, composition);
            } else if (cacheStrategy == CacheStrategy.Weak) {
              weakRefCache.put(animationName, new WeakReference<>(composition));
            }

            setComposition(composition);
          }
        });
  }
複製程式碼

從這裡可以看到官方文件中說的快取,包括強引用快取,弱引用快取,和無快取模式,而且知道Json動畫檔案最終會轉化為Composition物件,而Compostion物件是通過LottieComposition.Factory.fromAssetFileName(...)的方法非同步獲取的,於是我們只好接著往下跟蹤。

 public static Cancellable fromAssetFileName(Context context, String fileName,
        OnCompositionLoadedListener loadedListener) {
      InputStream stream;
      try {
        stream = context.getAssets().open(fileName);
      } catch (IOException e) {
        throw new IllegalStateException("Unable to find file " + fileName, e);
      }
      return fromInputStream(context, stream, loadedListener);
    }
複製程式碼

看到這裡我們這就明白,當初傳入的檔名,最終還是通過getAssets().open(fileName)的方法,以流的方式進行處理了,於是我們可以這樣載入放在其他目錄下的Json動畫檔案。

 public static void loadAnimationByFile(File file, final OnLoadAnimationListener listener) {
        if (file == null || !file.exists()) {
            if (listener != null) {
                listener.onFinished(null);
            }
            return;
        }
        FileInputStream fins = null;
        try {
            fins = new FileInputStream(file);
            LottieComposition.Factory.fromInputStream(GlobalContext.getAppContext(), fins, new OnCompositionLoadedListener() {
                @Override
                public void onCompositionLoaded(LottieComposition composition) {
                    if (listener != null) {
                        listener.onFinished(composition);
                    }
                }
            });
        } catch (IOException e) {
            e.printStackTrace();
            if (listener != null) {
                listener.onFinished(null);
            }
            if (fins != null) {
                try {
                    fins.close();
                } catch (IOException e1) {
                    e1.printStackTrace();
                }
            }
        }
    }
複製程式碼

非同步的方式獲取Composition物件,因為不使用setAnimation(final String animationName, final CacheStrategy cacheStrategy)方法,所以我們沒法使用框架提供的快取,為了下次播放時不需要重新解析動畫檔案,使動畫的載入速度更快,我們也需要重新做一套緩衝處理,如下

 LocalLottieAnimUtil.loadAnimationByFile(animFile, new LocalLottieAnimUtil.OnLoadAnimationListener() {
     @Override
    public void onFinished(LottieComposition lottieComposition) {
           if (lottieComposition != null) {
                 mCenter.putLottieComposition(id, lottieComposition);  // 使用
            } else {
                GiftFileUtils.deleteFile(getAnimFolder(link));  //刪除動畫檔案目錄,省的下次載入依然失敗,而是重新去下載資源壓縮包
         }


public class EnterRoomResCenter {
    private SparseArray<LottieComposition> lottieCompositions = new SparseArray<>();  //快取Composition
 
    public void putLottieComposition(int id, LottieComposition composition) {
        lottieCompositions.put(id, composition);
    }

    public LottieComposition getAnimComposition(int id) {
        return mCenter.getLottieComposition(id);
    }
}
複製程式碼

完成了Json動畫檔案的載入,接下來就是播放動畫。正如原始碼方法中 setAnimation(final String animationName, final CacheStrategy cacheStrategy) 一樣,我們也需要對LottieAnimationView進行setComposition(composition)處理,然後呼叫LottieAnimationView.playAnimation()就可以進行動畫播放了,於是我這樣做了:

  public static void playAnimation(LottieAnimationView animationView,LottieComposition composition) {
        animationView.setComposition(composition);
        animationView.playAnimation();
    }
複製程式碼

想想這個需求馬上就要搞定,於是我抿抿嘴偷偷笑了,這也太輕鬆了吧!於是端起茶杯去接了杯水,並執行了專案,準備回來看到那絢麗的動畫。然而,事與願違,等待我的是一片血紅的“大姨媽”。

java.lang.IllegalStateException: 
You must set an images folder before loading an image. Set it with LottieComposition#setImagesFolder or LottieDrawable#setImagesFolder
複製程式碼

看到這個錯誤,想起官方文件上面有說,如何為動畫配置播放動畫所需要的素材,而且錯誤提示也特別的明顯,看了看給的資源包的目錄,似乎發現了什麼!於是我按照官方《為Assets目錄下的Json動畫檔案設定播放動畫所需要的資源》一樣,改了一下程式碼:

動畫資源層級.PNG

  public static void playAnimation(LottieAnimationView animationView,String imageFolder, LottieComposition composition) {
        animationView.setComposition(composition);
       animationView.setImageAssetsFolder(imageFolder);   // 新新增的
        animationView.playAnimation();
    }
複製程式碼

想著異常資訊都提示這麼明顯了,而且官方文件給的模板也是這樣寫的,我更加確定這次動畫播放絕對的沒有問題。然而,動畫最終還是沒有播放出來!沒辦法,只好繼續翻原始碼,既然Assets目錄下setImageAssetsFolder(String folder)能生效,那我們只好從這個方法切入,看看folder變數最終是怎麼樣被使用的。

  @SuppressWarnings("WeakerAccess") public void setImageAssetsFolder(String imageAssetsFolder) {
    lottieDrawable.setImagesAssetsFolder(imageAssetsFolder);
  }
複製程式碼

沒有什麼頭緒只好繼續往下看:

 @SuppressWarnings("WeakerAccess") public void setImagesAssetsFolder(@Nullable String imageAssetsFolder) {
    this.imageAssetsFolder = imageAssetsFolder;
  }
複製程式碼

這個變數被設定成類屬性了,那麼我們只需要在這個類下搜尋怎麼樣被使用就可以馬上定位出原因,發現有這麼一行:

 imageAssetBitmapManager = new ImageAssetBitmapManager(getCallback(),
          imageAssetsFolder, imageAssetDelegate, composition.getImages());
    }
複製程式碼

我擦,變數被傳遞到一個ImageAssetBitmapManager物件裡面去了,只好進這個類繼續跟蹤,最終定位到這樣一個方法:

Bitmap bitmapForId(String id) {
    Bitmap bitmap = bitmaps.get(id);
    if (bitmap == null) {
      LottieImageAsset imageAsset = imageAssets.get(id);
      if (imageAsset == null) {
        return null;
      }
      if (assetDelegate != null) {
        bitmap = assetDelegate.fetchBitmap(imageAsset);
        bitmaps.put(id, bitmap);
        return bitmap;
      }

      InputStream is;
      try {
        if (TextUtils.isEmpty(imagesFolder)) {
          throw new IllegalStateException("You must set an images folder before loading an image." +
              " Set it with LottieComposition#setImagesFolder or LottieDrawable#setImagesFolder");
        }
        is = context.getAssets().open(imagesFolder + imageAsset.getFileName());
      } catch (IOException e) {
        Log.w(L.TAG, "Unable to open asset.", e);
        return null;
      }
      BitmapFactory.Options opts = new BitmapFactory.Options();
      opts.inScaled = true;
      opts.inDensity = 160;
      bitmap = BitmapFactory.decodeStream(is, null, opts);
      bitmaps.put(id, bitmap);
    }
    return bitmap;
  }
複製程式碼

播放動畫所需要的圖片資源都通過這個方法獲取,傳入一個圖片檔名稱,然後通過流獲取Bitmap物件並返回。這裡需要介紹一下: 如果Json動畫檔案使用了圖片素材,裡面的Json資料必然會宣告該圖片檔名。在Composition.Factory進行解析為Composition時,裡面使用的圖片都以鍵值對的方式存放到Composition的 private final Map<String, LottieImageAsset> images = new HashMap<>()中,LottieAnimationView.setCompostion(Compostion)最終落實到LottieDrawable.setCompostion(Compostion),LottieDrawable為了獲取動畫裡面的bitmap物件,Lottie框架封裝了ImageAssetBitmapManager物件,在LottieDrawable中建立,將圖片的獲取轉移到imageAssetBitmapManager 中,並暴露public Bitmap bitmapForId(String id)的方法。

LottieImageAsset imageAsset = imageAssets.get(id);
複製程式碼

上面的 bitmapForId(String id) 方法體中有這麼一行程式碼,如上,之前Json動畫檔案解析的圖片都存放到imageAssets中,id是當前需要載入的圖片素材名,通過get獲取到對應的LottieImageAsset物件,其實裡面也就包裝了該id值,做這層包裝可能為了以後方便擴充套件吧!


      if (assetDelegate != null) {
        bitmap = assetDelegate.fetchBitmap(imageAsset);
        bitmaps.put(id, bitmap);
        return bitmap;
      }
     ...
      is = context.getAssets().open(imagesFolder + imageAsset.getFileName());
     bitmap = BitmapFactory.decodeStream(is, null, opts);
     return bitmap;
    ...
  
複製程式碼

同樣從 bitmapForId(String id) 方法體中提取出如上程式碼,從上面可以看出如果assetDelegate == null,它就會從Asset的imagesFolder目錄下找素材檔案。因為之前我們並沒有設定過assetDelegate,而且我們的素材並不是在Asset的imagesFolder目錄下,所以獲取不到bitmap物件,動畫無法播放也是情有可原的,不斷的反向追溯assetDelegate來源,找到LottieAnimationView.setImageAssetDelegate(ImageAssetDelegate assetDelegate)方法,所以調整之前的程式碼,如下:

public static ImageAssetDelegate imageAssetDelegate = new ImageAssetDelegate() {
        @Override
        public Bitmap fetchBitmap(LottieImageAsset asset) {
            String filePath = currentImgFolder + File.separator + asset.getFileName();
            return BitmapFactory.decodeFile(filePath, opts);
        }
    }
    public static void playAnimation(LottieAnimationView animationView, String imageFolder, ImageAssetDelegate imageAssetDelegate, LottieComposition composition) {
        if (animationView == null || composition == null) {
            return;
        }
        animationView.setComposition(composition);
        animationView.setImageAssetsFolder(imageFolder);
        animationView.setImageAssetDelegate(imageAssetDelegate);
        animationView.playAnimation();
    }
複製程式碼

到現在為此,這個動畫才能播放出來,這個地方有一點比較坑的就是ImageAssetDelegate的建立:

public static ImageAssetDelegate imageAssetDelegate = new ImageAssetDelegate() {
        @Override
        public Bitmap fetchBitmap(LottieImageAsset asset) {
            String filePath = currentImgFolder + File.separator + asset.getFileName();
            return BitmapFactory.decodeFile(filePath, opts);
        }
    }
複製程式碼

每次使用的時候,我們都需要有這樣一個currentImgFolder 變數,維護這個檔案所在的父目錄的位置,其實框架大可以在ImageAssetBitmapManager中這樣呼叫,將之前我們用setImageFolder(String folder)又重新的回撥回來。

if (assetDelegate != null) {
        bitmap = assetDelegate.fetchBitmap(imagesFolder, imageAsset);    // imagesFolder是新加
        bitmaps.put(id, bitmap);
        return bitmap;
      }
複製程式碼

4. Lottie坑點總結

  • 在動畫json檔案中,有如下類似的資料,其中W 和 H欄位宣告瞭整個動畫的輸出大小,你需要確保你使用的LottieAnimationVIew的寬高比和這個一致。
{"v":"4.9.0","fr":25,"ip":0,"op":50,"w":1242,"h":128,"nm":"WWW","ddd":0,"assets": ....
複製程式碼
  • 播放本地動畫檔案展示的動畫偏小或偏大

注意ImageAssetDelegate的fetBitmap()程式碼中indensity屬性的設定

    @Override
    public Bitmap fetchBitmap(LottieImageAsset asset) {
        String filePath = currentImgFolder + File.separator + asset.getFileName();
        BitmapFactory.Options opts = new BitmapFactory.Options();
        opts.inDensity = 110;                                                                 //請留意這個值的設定
        return BitmapFactory.decodeFile(filePath, opts);                                     //這裡還有坑,請往下接著看
    }
複製程式碼
  • Lottie庫回收素材圖片bitmap引發的空指標問題 (1) 先看看Lottie對素材圖片進行快取的方法:
Bitmap bitmapForId(String id) {
      ...
      if (assetDelegate != null) {
        bitmap = assetDelegate.fetchBitmap(imageAsset);
        bitmaps.put(id, bitmap);                       //將Bitmap進行儲存,可能Bitmap物件為null
        return bitmap;
      }
      ...
      BitmapFactory.Options opts = new BitmapFactory.Options();
      opts.inScaled = true;
      opts.inDensity = 160;
      bitmap = BitmapFactory.decodeStream(is, null, opts);
      bitmaps.put(id, bitmap);                         //將Bitmap進行儲存,可能Bitmap物件為null
    }
    return bitmap;
  }
複製程式碼

(2) 再看看Lottie對快取圖片的回收處理:

  void recycleBitmaps() {
    Iterator<Map.Entry<String, Bitmap>> it = bitmaps.entrySet().iterator();
    while (it.hasNext()) {
      Map.Entry<String, Bitmap> entry = it.next();
      entry.getValue().recycle();
      it.remove();
    }
  }
複製程式碼

(3) 結論: 前後對比,有沒有發現Lottie對快取的素材圖片bitmap物件並沒有做判空處理,就直接回收了(Version 1.5.3)。

解決辦法: 如果是載入本地素材圖片(非Assets目錄)可以採用如下辦法:

  public Bitmap fetchBitmap(LottieImageAsset asset) {
        String filePath = currentImgFolder + File.separator + asset.getFileName();
        Bitmap bitmap = BitmapFactory.decodeFile(filePath, opts);
        if (bitmap == null) {
            bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8);
        }
        return bitmap;
    }
複製程式碼

5. 使用總結

  • 播放放置在Asset目錄下的動畫檔案

設定播放檔案: setAnimation("檔名") 如果動畫檔案帶素材: setImageAssetsFolder("資料夾名")

  • 播放系統目錄下的動畫檔案

非同步獲取Compostion物件: LottieComposition.Factory.fromInputStream() 設定播放的素材: setComposition(composition) 如果動畫檔案帶素材: setImageAssetsFolder("資料夾名") + setImageAssetDelegate(imageAssetDelegate)

相關文章