从一例 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. 结语

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

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui