Lottie框架在RecycleView中播放动画的建议

Lottie框架播放AE动画的确很好用且方便,不过官方给出的Demo比较简洁。

我们可能还会遇到以下一些问题:

  • 比如如何在RecycleView中播放网络上的AE动画(Json或Zip格式)
  • 可以在RecycleView中离线支持播放网络上的AE动画么?

作为一个趟过这些坑的过来人,在此总结一下,代码偏多,可根据需求相应修改!


在RecycleView中播放网络动画需要解决那些问题

  • 下载网络动画 zip 或 json 格式的文件
  • 离线缓存(内存加磁盘),支持离线播放
  • ViewHolder 防止复用,避免多次绑定
  • 避免滑动中因Bitmap 状态为 isRecycled 导致异常(崩溃、或者图片展示不全)

实现RecycleView中播放AE动画需要的步骤

  • 利用Retrofit结合RxJava下载动画文件
    • 优势:
      • 返回的ResponseBody对象可区分zip 或 json格式
      • 可随意切换线程进行异步缓存、解压等
  • 缓存AE动画(zip 或 json 格式文件)到本地(需考虑权限问题)
  • 解压zip文件,转化json格式成实体类缓存到内存待绑定
  • 提供读取File方法,预加载磁盘的数据到内存,方便离线播放时读取及绑定
  • 通过,两套缓存数据,动态异步缓存,解决Bitmap 状态为 isRecycled随时切换另一套,避免异常,加载Bitmap到内存需要优化下,减少占用内存(该解决方案不适用于多图情况)。
  • 通过url给view创建一个唯一的tag,防止滑动复用导致多次绑定

相关实现步骤的片段参考代码

  • 创建缓存AE动画相关数据的实体类
    1
    2
    3
    4
    5
    public class CompositionData{
    private String key;//缓存的文件标识
    private Map<String, Bitmap> images;//缓存zip的待用图片
    private LottieComposition composition;//缓存ae动画通过json文件转化成的序列化动画对象
    }

下载数据简易示例

  • DownloadService

    1
    2
    3
    4
    public interface DownloadService {
    @GET
    Observable<ResponseBody > downloadAEData(@Url String url);
    }
  • BaseObserver

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    public abstract class BaseObserver<T> implements Observer<T> {

    private Disposable mDisposable;

    protected abstract void onResponse(boolean isSuccess, T t);

    @Override
    public void onSubscribe(@NonNull Disposable d) {
    mDisposable = d;
    }

    @Override
    public void onNext(@NonNull T t) {
    onResponse(true, t);
    }

    @Override
    public void onError(@NonNull Throwable e) {
    e.printStackTrace();
    onResponse(false, null);
    }

    /**
    * 取消请求,取消数据发送
    */
    public void onCancel() {
    if (mDisposable != null && !mDisposable.isDisposed()) {
    mDisposable.dispose();
    }
    }

    /**
    * 如需该回调,可手动重写该方法
    */
    @Override
    public void onComplete() {
    }
    }
  • CompositionUtil提供的下载方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    //url 为需要下载的地址
    // key 为这个url对应的一个唯一标识,仅用于缓存数据
    public static void loadNetCompositioin(final LottieAnimationView img,String url, final String key) {
    new Retrofit.Builder()
    .client(new OkHttpClient().newBuilder()
    .writeTimeout(30, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .connectTimeout(60, TimeUnit.SECONDS))
    .baseUrl(baseUrl)
    .addConverterFactory(ScalarsConverterFactory.create())
    .addConverterFactory(GsonConverterFactory.create(gson))
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .build()
    .create(DownloadService.class)
    .downloadAEData(url)
    .subscribeOn(Schedulers.io())
    .map(new Function<ResponseBody, CompositionData>() {
    @Override
    public CompositionData apply(ResponseBody response) throws Exception {
    if (response != null) {
    if (MediaType.parse("application/zip").equals(response.contentType())) {
    return CompositionUtil.handleZipResponse(response, key);//存文件加缓存在内存
    }
    }
    return CompositionUtil.handleJsonResponse(response, key);//存文件加缓存数据在内存
    }
    })
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(new BaseObserver<CompositionData>() {
    @Override
    protected void onResponse(boolean isSuccess, final CompositionData compositionData) {
    if (isSuccess && compositionData != null) {
    HomeAdapter.ASSET_STRONG_REF_CACHE.put(key, compositionData);
    CompositionUtil.bindLottieAnimationView(img, key);
    }
    }
    });
    }

缓存数据在磁盘和内存

  • 存文件方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    public static boolean writeFileToSDCard(InputStream inputStream, String key) {
    try {
    File file = new File(getFileName(key));
    if (file.exists()) {
    file.delete();
    file.createNewFile();
    }
    OutputStream outputStream = null;
    try {
    byte[] fileReader = new byte[4096];
    // long fileSize = body.contentLength();
    // long fileSizeDownloaded = 0;
    outputStream = new FileOutputStream(file);
    while (true) {
    int read = inputStream.read(fileReader);
    if (read == -1) {
    break;
    }
    outputStream.write(fileReader, 0, read);
    // fileSizeDownloaded += read;
    }
    outputStream.flush();
    return true;
    } catch (IOException e) {
    return false;
    } finally {
    if (inputStream != null) {
    inputStream.close();
    }
    if (outputStream != null) {
    outputStream.close();
    }
    }
    } catch (IOException e) {
    return false;
    }
    }
  • 缓存zip格式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static CompositionData handleJsonResponse(ResponseBody body, String key) {
    InputStream inputStream = body.byteStream();
    // 存入文件
    boolean issave = writeFileToSDCard(inputStream, key);
    if (issave) {
    return getCompositionJsonData(key);
    }
    return null;
    }
  • 缓存json格式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static CompositionData handleJsonResponse(ResponseBody body, String key) {
    InputStream inputStream = body.byteStream();
    // 存入文件
    boolean issave = writeFileToSDCard(inputStream, key);
    if (issave) {
    return getCompositionJsonData(key);
    }
    return null;
    }
  • 转换File为CompositionData数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public static String getFileName(String key){
    return Environment.getExternalStorageDirectory().getPath() + File.separator + MD5Util.toMD5(key) + ".ae";
    }


    * 缓存json格式具体方法
    private static CompositionData getCompositionJsonData(String key) {
    CompositionData compositionData = new CompositionData();
    FileInputStream newInputStream;
    try {
    newInputStream = new FileInputStream(new File(getFileName(key)));
    if (newInputStream == null) {
    return null;
    }
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    return null;
    }
    compositionData.setKey(key);
    compositionData.setComposition(LottieComposition.Factory.fromInputStreamSync(newInputStream));
    //此处ASSET_STRONG_REF_CACHE为内存缓存CompositionData数据
    ASSET_STRONG_REF_CACHE.put(key, compositionData);
    return compositionData;
    }
  • 缓存zip格式具体方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    public static CompositionData getCompositionZipData(String key) {
    FileInputStream inputStream = null;
    try {
    File file = new File(getFileName(key));
    if (file != null && file.exists()) {
    inputStream = new FileInputStream(file);
    }
    if (inputStream == null) {
    return null;
    }
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    return null;
    }
    CompositionData compositionData = new CompositionData();
    compositionData.setKey(key);
    ZipInputStream zis = new ZipInputStream(inputStream);
    ZipEntry zipEntry;
    try {
    zipEntry = zis.getNextEntry();
    Map<String, Bitmap> images = new HashMap<>();
    while (zipEntry != null) {
    if (zipEntry.getName().contains("__MACOSX")) {
    zis.closeEntry();
    } else if (zipEntry.getName().contains(".json")) {
    compositionData.setComposition(LottieComposition.Factory.fromInputStreamSync(zis, false));
    } else if (zipEntry.getName().contains(".png")) {
    String[] name = zipEntry.getName().split("/");
    if (name != null && name.length >= 1) {
    BitmapFactory.Options options=new BitmapFactory.Options();
    options.inPreferredConfig = Bitmap.Config.RGB_565;
    options.inPurgeable = true;
    options.inInputShareable = true;
    images.put(name[name.length - 1], BitmapFactory.decodeStream(zis,null,options));
    }

    } else {
    zis.closeEntry();
    }
    zipEntry = zis.getNextEntry();
    }
    compositionData.setImages(images);
    zis.close();
    } catch (IOException e) {
    e.printStackTrace();
    }
    //此处ASSET_STRONG_REF_CACHE为内存缓存CompositionData数据
    ASSET_STRONG_REF_CACHE.put(key, compositionData);
    return compositionData;
    }

提供预加载File到内存的方法

1
2
3
4
5
6
7
public static void loadData(String key) {
getCompositionZipData(key);
CompositionData data = ASSET_STRONG_REF_CACHE.get(key);
if (data == null || data.getImage() == null || data.getImage().size() == 0) {//用于区分是zip格式与否,没图的情况下加载json格式
getCompositionJsonData(key);
}
}

提供绑定到LottieAnimationView控件的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void bindLottieAnimationView(LottieAnimationView img, final String tag) {
final CompositionData compositionData = ASSET_STRONG_REF_CACHE.get(tag);//从缓存中读取数据
if (compositionData != null) {
if(img!=null) {
img.setComposition(compositionData.getComposition());
img.setImageAssetDelegate(new ImageAssetDelegate() {
@Override
public Bitmap fetchBitmap(LottieImageAsset asset) {
return compositionData.getBitmapByName(asset.getFileName());
}
});
img.playAnimation();
img.setTag(tag);
}
}
}

避免bitmap为isRecycled提供的一种解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

private Map<String, Bitmap> images;
private Map<String, Bitmap> newImages;

public void setImages(Map<String, Bitmap> images) {
this.images = images;
}

public Bitmap getBitmapByName(String key){
Bitmap bitmap = null;
assert(images != null);
if(images != null){
Bitmap bt = images.get(key);
bitmap = bt.copy(bt.getConfig(),true);
}
return bitmap;
}

在ViewHolder中在使用

  • 支持直接展示图片、或者 播放json zip的动画都支持的写法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    LottieAnimationView img;

    if (TextUtils.isEmpty(url)) {//这里的url代表ae动画的下载地址,如果无,展示图片
    img.setScaleType(ImageView.ScaleType.FIT_XY);
    img.setAdjustViewBounds(true);
    img.setImageBitmap(bitmap);
    } else {//播放动画的逻辑
    final String key = "可以加上任意字段"+url;
    img.setRepeatCount(-1);
    img.setScaleType(ImageView.ScaleType.FIT_CENTER);
    if (ASSET_STRONG_REF_CACHE.containsKey(key)) {//如果内存中有,直接读取
    if (!key.equals(img.getTag())) {//如果复用了,绑定新的数据
    CompositionUtil.bindLottieAnimationView(img, key);
    }
    } else {
    File file = new File(CompositionUtil.getFileName(key));//文件存储
    if (file != null && file.exists()) {
    if(!key.equals(homesplit_img.getTag(R.id.tag_two))) {//避免执行多次本地加载绑定
    homesplit_img.setTag(R.id.tag_two, key);//类似于锁
    CompositionUtil.loadData(key);//直接转换并加载在内存
    CompositionUtil.bindLottieAnimationView(img, key));//绑定
    }
    } else {
    if(!key.equals(homesplit_img.getTag(R.id.tag_first))) {//避免执行多次网络加载绑定
    homesplit_img.setTag(R.id.tag_first, key);//类似于锁
    CompositionUtil.loadNetCompositioin(img,url, key);//网络下载并绑定
    }
    }
    }
    }
    以上代码需要在 res/values/ids.xml文件指定两id
    1
    2
    <item type="id" name="tag_first"></item>
    <item type="id" name="tag_two"></item>