Android•Lottie动画库填坑记

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 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 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动画文件设置播放动画所需要的资源》一样,改了一下代码:


Android•Lottie动画库填坑记_第1张图片
动画资源层级.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 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> it = bitmaps.entrySet().iterator();
    while (it.hasNext()) {
      Map.Entry 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;
    }
  • 如果Lottie播放素材采用服务器下发的方式,数据是不可信的,错误格式的数据可能在String转JsonObject时解析失败,导致应用crash,可以采用如下办法:
 1. 手动获取JsonObject对象,方法内部做容错处理
    JsonObject jsonObject = covertJsonObject(str)
 2. 做判空处理
    if ( jsonObject == null ){
       return;
    } 
 3. 播放动画
    LocalLottieAnimUtil.loadAnimationByJsonObj(animationJsonObj, new LocalLottieAnimUtil.OnLoadAnimationListener() {
                            @Override
                            public void onFinished(LottieComposition lottieComposition) {
                                if (lottieComposition == null) {
                                    return;
                                } 
                                 animView.setComposition(lottieComposition);
                                 animView.playAnimation();
                            }
                        });

5. 使用总结

  • 播放放置在Asset目录下的动画文件

设置播放文件: setAnimation("文件名")
如果动画文件带素材: setImageAssetsFolder("文件夹名")

  • 播放系统目录下的动画文件

异步获取Compostion对象: LottieComposition.Factory.fromInputStream()
设置播放的素材: setComposition(composition)
如果动画文件带素材: setImageAssetsFolder("文件夹名") + setImageAssetDelegate(imageAssetDelegate)

你可能感兴趣的:(Android•Lottie动画库填坑记)