从一例 Lottie OOM 线上事故读源码

  1. 调用示例

简单回顾一下 lottie 的使用,lottie 可以加载 assets 本地目录资源和在线资源。这里以在线资源举例。

kotlin 复制代码
// 预加载动画资源
LottieCompositionFactory.fromUrl(context, url)
// 视图绑定动画资源
LottieAnimationView.setAnimationFromUrl(url)
// 播放动画
LottieAnimationView.playAnimation()

到这里动画能正常展示,并且因为有预加载,体验和本地 assets 基本无差异,既不用增加包体积,体验上也没有打折扣,完美,顺利上线。

  1. OOM 崩溃

需求上线后因为没有大规模触发此动画,一直也是相安无事。7.7号开始大规模触发后,两天线上累计统计到1000+次 OOM 崩溃。查看机型特征,全部集中在 7.0及以下机器,迅速拿出压箱底的老机器,三个动画轮番轰过去,华为老机器立即崩溃。Profile 抓了一份内存占用,一看吓一跳。

LottieCompositionCache一个动画就占用 86M,三个动画岂不是 200M +?天都要塌了。

  1. 原因

上面内存分析平均一张图对应的 bitmap 占用 4M 左右,解压动画资源包,每张图宽高 1000* 1000,每个动画大概有 30+ 张图片。Android 系统默认用 ARGB_8888表示像素,一个像素点占用 4个字节,1000 * 1000 * 4 刚好 4M。 上面很直接想到是图片资源太大了,手动裁剪图片到 500*500,profile 分析单张 bitmap 占用 2M 左右,为什么不是除 4?查看源码后发现这段代码

less 复制代码
解压 zip 包根据后缀解析对应文件
} else if (entryName.contains(".png") || entryName.contains(".webp") || entryName.contains(".jpg") || entryName.contains(".jpeg")) {
  String[] splitName = entryName.split("/");
  String name = splitName[splitName.length - 1];
  images.put(name, BitmapFactory.decodeStream(inputStream));
less 复制代码
// 对上面解析完的图片按 json 文件描述的宽高进行裁剪
for (Map.Entry<String, Bitmap> e : images.entrySet()) {
  LottieImageAsset imageAsset = findImageAssetForFileName(composition, e.getKey());
  if (imageAsset != null) {
    imageAsset.setBitmap(Utils.resizeBitmapIfNeeded(e.getValue(), imageAsset.getWidth(), imageAsset.getHeight()));
  }
}

所以不仅仅只是裁剪图片资源,还要同步修改 json 描述文件里描述图片资源的宽高。同步修改后,500* 500 的图片占用 1M 左右,并且减少了图片数量,从 30+ 减少到 20 以内,动画效果差距不大。

  1. 解决方案

裁剪完动画资源后,单个动画资源的内存占用减少到原来的 1/4,测试下来三个动画依次执行仍然有一定概率 OOM。从崩溃特征看全部都是 8.0 以下机器,怀疑是低版本的 jvm 内存回收机制不够优秀。 查看源码,发现

typescript 复制代码
/**
 * If set to true, all future compositions that are set will be cached so that they don't need to be parsed
 * next time they are loaded. This won't apply to compositions that have already been loaded.
 * <p>
 * Defaults to true.
 * <p>
 * {@link R.attr#lottie_cacheComposition}
 */
public void setCacheComposition(boolean cacheComposition) {
  this.cacheComposition = cacheComposition;
}

可以通过调用 LottineAnimationView.setCacheComposition(false)强制禁用内存缓存。于是针对 8.0 以下机器,调用 setCacheComposition(false)禁用缓存。 同时为了保险起见,对8.0及以上在系统 onTrimMemory()回调里主动调用LottieCompositionFactory.clearCache(context) 清理缓存。

kotlin 复制代码
private val componentCallback = object: ComponentCallbacks2 {
    override fun onConfigurationChanged(newConfig: Configuration) {
    }

    override fun onLowMemory() {
        V5Logger.e("ComponentCallbacks2 onLowMemory")
    }

    override fun onTrimMemory(level: Int) {
        if (level >= TRIM_MEMORY_COMPLETE) {
            V5Logger.e("Memory critical, cleaning resources")
            LottieCompositionFactory.clearCache(this)
        }
    }
}

总结一下解决方案

  • 裁剪过大的动画资源,减少内存占用。(收益最大)
  • 去掉过早的预加载机制,避免 Lottie 缓存常驻内存
  • 针对8.0以下机型强制禁用 Lottie 的缓存策略
  • 在系统 onTrimMemory 回调主动释放 Lottie 缓存
  • 对动画的执行做队列处理,仅允许同时执行一个动画,避免同时申请内存
  1. Lottie 加载部分的源码跟踪 LottieAnimationView setAnimation 的几个重载函数
less 复制代码
public void setAnimation(InputStream stream, @Nullable String cacheKey)

public void setAnimation(ZipInputStream stream, @Nullable String cacheKey)

public void setAnimation(final String assetName)

public void setAnimationFromUrl(String url)

内部实现转到 LottieCompositionFactory内部的多个 fromXXX 函数

java 复制代码
/**
 * Parse an animation from src/main/assets. It is recommended to use {@link #fromRawRes(Context, int)} instead.
 * The asset file name will be used as a cache key so future usages won't have to parse the json again.
 * However, if your animation has images, you may package the json and images as a single flattened zip file in assets.
 * <p>
 * Pass null as the cache key to skip the cache.
 *
 * @see #fromZipStream(ZipInputStream, String)
 */
public static LottieTask<LottieComposition> fromAsset(Context context, final String fileName, @Nullable final String cacheKey) {
  // Prevent accidentally leaking an Activity.
  final Context appContext = context.getApplicationContext();
  return cache(cacheKey, () -> fromAssetSync(appContext, fileName, cacheKey), null);
}
less 复制代码
/**
 * Fetch an animation from an http url. Once it is downloaded once, Lottie will cache the file to disk for
 * future use. Because of this, you may call `fromUrl` ahead of time to warm the cache if you think you
 * might need an animation in the future.
 */
public static LottieTask<LottieComposition> fromUrl(final Context context, final String url, @Nullable final String cacheKey) {
  return cache(cacheKey, () -> {
    LottieResult<LottieComposition> result = L.networkFetcher(context).fetchSync(context, url, cacheKey);
    if (cacheKey != null && result.getValue() != null) {
      LottieCompositionCache.getInstance().put(cacheKey, result.getValue());
    }
    return result;
  }, null);
}

cache 函数

less 复制代码
private static LottieTask<LottieComposition> cache(@Nullable final String cacheKey, Callable<LottieResult<LottieComposition>> callable,
    @Nullable Runnable onCached) {
  // task 代表一个加载解压任务,LottieComposition 存储解析后的数据
  LottieTask<LottieComposition> task = null;
  // 从 LottieCompositionCache 内部的 cache map 获取是否有现成的 LottieTask
  final LottieComposition cachedComposition = cacheKey == null ? null : LottieCompositionCache.getInstance().get(cacheKey);
  // 查找到 composition 构建一个 LottieTask 直接返回
  if (cachedComposition != null) {
    task = new LottieTask<>(cachedComposition);
    return task;
  }
  // 创建 LottieTask 的同时开启线程执行 callable
  task = new LottieTask<>(callable);
  // 省略设置监听的逻辑
  return task;
}

LottieTask

java 复制代码
// 线程池
public static Executor EXECUTOR = Executors.newCachedThreadPool(new LottieThreadFactory());

public LottieTask(Callable<LottieResult<T>> runnable) {
  this(runnable, false);
}

构造函数内部添加到线程池执行

java 复制代码
/**
 * runNow is only used for testing.
 */
@RestrictTo(RestrictTo.Scope.LIBRARY) LottieTask(Callable<LottieResult<T>> runnable, boolean runNow) {
  EXECUTOR.execute(new LottieFutureTask<T>(this, runnable));
}

再回到 LottieCompositionFactory.fromUrl()这里的 callable

scss 复制代码
// 以 HttpConnection 发起 GET请求获取资源
LottieResult<LottieComposition> result = L.networkFetcher(context).fetchSync(context, url, cacheKey);
if (cacheKey != null && result.getValue() != null) {
  LottieCompositionCache.getInstance().put(cacheKey, result.getValue());
}
less 复制代码
@NonNull
@WorkerThread
public LottieResult<LottieComposition> fetchSync(Context context, @NonNull String url, @Nullable String cacheKey) {
  // 先以 cacheKey 检索缓存
  LottieComposition result = fetchFromCache(context, url, cacheKey);
  if (result != null) {
    return new LottieResult<>(result);
  }

  Logger.debug("Animation for " + url + " not found in cache. Fetching from network.");
  // 发起 Get 请求
  return fetchFromNetwork(context, url, cacheKey);
}

无论是走 cache 还是走 http,最终走到 LottieCompositionFactory.fromZipStreamSync,区别在于一个是文件流,一个是 http response 的流。

less 复制代码
private static LottieResult<LottieComposition> fromZipStreamSyncInternal(@Nullable Context context, ZipInputStream inputStream,
    @Nullable String cacheKey) {
  LottieComposition composition = null;
  // 存储解析的图片和字体
  Map<String, Bitmap> images = new HashMap<>();
  Map<String, Typeface> fonts = new HashMap<>();

  try {
    final LottieComposition cachedComposition = cacheKey == null ? null : LottieCompositionCache.getInstance().get(cacheKey);
    if (cachedComposition != null) {
      return new LottieResult<>(cachedComposition);
    }
    // 解析 zip 节点
    ZipEntry entry = inputStream.getNextEntry();
    while (entry != null) {
      final String entryName = entry.getName();
      if (entryName.contains("__MACOSX")) {
        inputStream.closeEntry();
      } else if (entry.getName().equalsIgnoreCase("manifest.json")) { //ignore .lottie manifest
        inputStream.closeEntry();
        // 解析 json 并创建 composition
      } else if (entry.getName().contains(".json")) {
        JsonReader reader = JsonReader.of(buffer(source(inputStream)));
        composition = LottieCompositionFactory.fromJsonReaderSyncInternal(reader, null, false).getValue();
        // 解析图片,创建 bitmap 对象
      } else if (entryName.contains(".png") || entryName.contains(".webp") || entryName.contains(".jpg") || entryName.contains(".jpeg")) {
        String[] splitName = entryName.split("/");
        String name = splitName[splitName.length - 1];
        images.put(name, BitmapFactory.decodeStream(inputStream));
      } else if (entryName.contains(".ttf") || entryName.contains(".otf")) {
      // 省略解析字体
    }
  } catch (IOException e) {
    return new LottieResult<>(e);
  }


  if (composition == null) {
    return new LottieResult<>(new IllegalArgumentException("Unable to parse composition"));
  }
  // 上面提到的按 json 里的宽高对 bitmap 进行缩放
  for (Map.Entry<String, Bitmap> e : images.entrySet()) {
    LottieImageAsset imageAsset = findImageAssetForFileName(composition, e.getKey());
    if (imageAsset != null) {
      imageAsset.setBitmap(Utils.resizeBitmapIfNeeded(e.getValue(), imageAsset.getWidth(), imageAsset.getHeight()));
    }
  }

  // 省略解析 base64 编码的图片
  // 缓存解析完成的 composition
  if (cacheKey != null) {
    LottieCompositionCache.getInstance().put(cacheKey, composition);
  }
  return new LottieResult<>(composition);
}
  1. 疑问

分析内存时发现,同一个资源在 LottieCompositionCache.cache的 map 里存在两个 key。两个不同的key,但是 value 是同一个 composition 对象,所以内存倒也没有多次占用。但是还是挺让人疑惑的。 继续分析 NetworkFetcher

less 复制代码
private LottieResult<LottieComposition> fromZipStream(Context context, @NonNull String url, @NonNull InputStream inputStream, @Nullable String cacheKey)
    throws IOException {
  if (cacheKey == null || networkCache == null) {
    return LottieCompositionFactory.fromZipStreamSync(context, new ZipInputStream(inputStream), null);
  }
  File file = networkCache.writeTempCacheFile(url, inputStream, FileExtension.ZIP);
  // 注意这里以 url 作为 cacheKey
  return LottieCompositionFactory.fromZipStreamSync(context, new ZipInputStream(new FileInputStream(file)), url);
}

等网络请求解析完成后,回到上边提到的 callable

java 复制代码
public static LottieTask<LottieComposition> fromUrl(final Context context, final String url, @Nullable final String cacheKey) {
  return cache(cacheKey, () -> {
    LottieResult<LottieComposition> result = L.networkFetcher(context).fetchSync(context, url, cacheKey);
    // 在线请求完以 cacheKey("url_$url") 为key 缓存
    if (cacheKey != null && result.getValue() != null) {
      LottieCompositionCache.getInstance().put(cacheKey, result.getValue());
    }
    return result;
  }, null);
}

到此,真相大白,源码内部分别以 "url" 和 "url_$url" 为 cacheKey,导致 LottieCompositionCache.cache同一份资源有两份缓存。 最新版本也存在这个问题,提了个issue: github.com/airbnb/lott...

  1. 结语

动画这种比较吃内存的操作还是得小心,不能盲目相信开源代码。正确的使用方式才能达到最完美的效果,源码面前没有秘密。

相关推荐
爬虫程序猿1 小时前
利用爬虫按关键字搜索淘宝商品实战指南
android·爬虫
顾北川_野2 小时前
Android ttyS2无法打开该如何配置 + ttyS0和ttyS1可以
android·fpga开发
wzj_what_why_how5 小时前
Android网络层架构:统一错误处理的问题分析到解决方案与设计实现
android·架构
千里马学框架5 小时前
User手机上如何抓取界面的布局uiautomatorviewer
android·智能手机·aosp·uiautomator·布局抓取·user版本
阿巴~阿巴~6 小时前
操作系统核心技术剖析:从Android驱动模型到鸿蒙微内核的国产化实践
android·华为·harmonyos
hsx6666 小时前
使用 MaterialShapeDrawable 自定义各种形状的 View
android
用户2018792831677 小时前
滑动城堡的奇妙管家 ——ViewPager故事
android
用户2018792831677 小时前
📜 童话:魔法卷轴与 ScrollView 的奥秘
android
??? Meggie8 小时前
【SQL】使用UPDATE修改表字段的时候,遇到1054 或者1064的问题怎么办?
android·数据库·sql
用户2018792831679 小时前
代码共享法宝之maven-publish
android