- 调用示例
简单回顾一下 lottie 的使用,lottie 可以加载 assets 本地目录资源和在线资源。这里以在线资源举例。
kotlin
// 预加载动画资源
LottieCompositionFactory.fromUrl(context, url)
// 视图绑定动画资源
LottieAnimationView.setAnimationFromUrl(url)
// 播放动画
LottieAnimationView.playAnimation()
到这里动画能正常展示,并且因为有预加载,体验和本地 assets 基本无差异,既不用增加包体积,体验上也没有打折扣,完美,顺利上线。
- OOM 崩溃
需求上线后因为没有大规模触发此动画,一直也是相安无事。7.7号开始大规模触发后,两天线上累计统计到1000+次 OOM 崩溃。查看机型特征,全部集中在 7.0及以下机器,迅速拿出压箱底的老机器,三个动画轮番轰过去,华为老机器立即崩溃。Profile 抓了一份内存占用,一看吓一跳。
LottieCompositionCache
一个动画就占用 86M,三个动画岂不是 200M +?天都要塌了。
- 原因
上面内存分析平均一张图对应的 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/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 缓存
- 对动画的执行做队列处理,仅允许同时执行一个动画,避免同时申请内存
- 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);
}
- 疑问
分析内存时发现,同一个资源在 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...
- 结语
动画这种比较吃内存的操作还是得小心,不能盲目相信开源代码。正确的使用方式才能达到最完美的效果,源码面前没有秘密。