Mar
23
大量使用lottie的iOS应用会注意到,线上会存在大量的imageWithContentsOfFile卡顿。lottie使用imageWithContentsOfFile的地方很显然,即LOTLayerContainer的_setImageForAsset方法。这个方法在做啥?即如果当前layer是位图layer,从磁盘或者别的地方加载位图。而这里的卡顿,很显然:
1. 每个lottie从文件加载时,都会在此处从磁盘imageWithContentsOfFile加载图片,在主线程IO卡顿和图片解码产生卡顿风险。
2. 因为lottieView每次创建都是从文件创建的,即lottie资源本身没有实例机制,因此每一个重复的lottie创建都会重复imageWithContentsOfFile过程,加剧卡顿。
因此imageWithContentsOfFile卡顿解决方案,要解决的是:
1. imageWithContentsOfFile过程与图片解码过程可能的话放到异步线程中执行,即普通SDWebImage所做的那种加载优化,实际也最好直接利用SD的方法来完成。
2. 图片资源建立缓存机制,避免上述过程。这个也可以直接利用SDImageCache的内存缓存完成。
阻碍
说起来容易,但是有一个很麻烦的问题是:
1. 要跨线程图片加载/解码,显然要将lottie原本的图片加载过程同步改异步。但是实际发现由于资源递归遍历的缘故,改动风险较大。
2. lottie的图片资源加载类型与方式多种多样,缓存逻辑怎么去覆盖。
第一个的问题的答案是:做不到。我们不太可能真的为lottie加入异步的资源加载/管理机制,这个入侵的复杂度基本上重写一半的lottie资源管理代码与大部分lottie操作代码了(操作异步化),否则lottie就无法进行迁移。因此合理的做法时,如果lottie遇到的资源我们没有完成预加载,还是正常的同步去完成。
但是我们可以在之前建立常用的lottie资源预缓存机制,通过一定LRU策略建立资源预加载列表,并且在使用lottie前竞态异步加载。
第二个问题,我们review一下LOTLayerContainer中实际的代码,lottie的资源加载分三种形式:
a. base64图片,在主线程base64解码与图片解码,优化价值中。
b. 从特定path load图片并解码,优化价值高。这个方法中提供了LOTImageCache让我们可以为加载过程提供缓存。
c. 从特定bundle中加载图片资源,优化价值高。
那实际上我们程序里的lottiejson是怎么运行的呢?一般来说,直接打包放到app中的资源,即通过name加载的资源,如果有图片即会走c流程。而通过服务下载的资源,通过filepath加载,则会走b流程。我们的应用中c场景还蛮多的,因此如果要利用LOTImageCache机制,则需要将a与c的图片资源均转发到b流程中。
顺带,如果要做缓存,缓存的唯一key是什么?由于iOS的沙盒机制,路径不唯一,因此最合理的file key是沙盒的相对目录。
代码实现
1. 资源加载逻辑引流,将所有资源加载引流到b流程
- 这个问题只能用方法交换解决。
- base64的图片资源转发只能用proxy对象解决,imageName只读无法修改。尽量少用DWarpLOTAsset,毕竟避免频繁创建多余对象。
2. LOTImageCache的缓存与同步解码逻辑
- 缓存key用作关键信息储存,并且做快速名字,目前缓存key有以下形式:
- "data:base64.....":快速匹配base64图片资源。
- "bundle:com.xxx.xxx/aaaa.png":描述一个bundle中加载的资源
- "/xxx/yyy/aaaa.png":描述一个沙盒目录中的资源
- 利用SDImageCache的内存缓存做实际缓存,本来就是从磁盘读取的图片就没必要再来一次磁盘缓存了。利用SD的好处是,他本身会收听lowmemery事件进行合理的清除逻辑,同时整个app的内存图像也统一管理可度量了。
- 这段代码收口了全部的lottie资源加载,因此可以在这段逻辑中统计到 a. 资源加载异常情况,上报错误。b.本次做的缓存命中情况与命中率。 c. 资源兜底(如果有必要)。
3. LRU的本地后台线程预加载
1. 整个逻辑在启动后后台线程执行,和主线程使用lottie的地方相互独立存在竞争。也就是说有可能这边准备好了图片,主线程刚好从sd中读取到。也有可能主线程自己解码了图片放在sd中,这里不需要再解码了。也有可能重复工作了。不过整体看无所谓。
2. lru的逻辑是最早最常用的放在最前。这是因为刚才竞态的缘故,最早用的图肯定要最早提前解码咯。同时常用也是另一关键因素,不过因为有内存缓存,所以时间最重要。
3. 资源的信息都储存在key里了,因此可以直接恢复。这也是串通整个方案的关键。
4. 实际上我们将这个逻辑添加在了app主线程第一次不卡顿后,后台线程预加载对首页的资源帮助不大,但是二三级常用页面如果有大量lottie,就很管用了。如果有必要可以给缓存做更细致化的初始化时间分级。
结果上看,完成了这些工作之后,缓存命中率90%左右,问题还是首屏资源缓存无法奇效,整体看imageWithContentsOfFile卡顿确实大大的下降了。
大体的流程图如下:
1. 每个lottie从文件加载时,都会在此处从磁盘imageWithContentsOfFile加载图片,在主线程IO卡顿和图片解码产生卡顿风险。
2. 因为lottieView每次创建都是从文件创建的,即lottie资源本身没有实例机制,因此每一个重复的lottie创建都会重复imageWithContentsOfFile过程,加剧卡顿。
因此imageWithContentsOfFile卡顿解决方案,要解决的是:
1. imageWithContentsOfFile过程与图片解码过程可能的话放到异步线程中执行,即普通SDWebImage所做的那种加载优化,实际也最好直接利用SD的方法来完成。
2. 图片资源建立缓存机制,避免上述过程。这个也可以直接利用SDImageCache的内存缓存完成。
阻碍
说起来容易,但是有一个很麻烦的问题是:
1. 要跨线程图片加载/解码,显然要将lottie原本的图片加载过程同步改异步。但是实际发现由于资源递归遍历的缘故,改动风险较大。
2. lottie的图片资源加载类型与方式多种多样,缓存逻辑怎么去覆盖。
第一个的问题的答案是:做不到。我们不太可能真的为lottie加入异步的资源加载/管理机制,这个入侵的复杂度基本上重写一半的lottie资源管理代码与大部分lottie操作代码了(操作异步化),否则lottie就无法进行迁移。因此合理的做法时,如果lottie遇到的资源我们没有完成预加载,还是正常的同步去完成。
但是我们可以在之前建立常用的lottie资源预缓存机制,通过一定LRU策略建立资源预加载列表,并且在使用lottie前竞态异步加载。
第二个问题,我们review一下LOTLayerContainer中实际的代码,lottie的资源加载分三种形式:
a. base64图片,在主线程base64解码与图片解码,优化价值中。
b. 从特定path load图片并解码,优化价值高。这个方法中提供了LOTImageCache让我们可以为加载过程提供缓存。
c. 从特定bundle中加载图片资源,优化价值高。
那实际上我们程序里的lottiejson是怎么运行的呢?一般来说,直接打包放到app中的资源,即通过name加载的资源,如果有图片即会走c流程。而通过服务下载的资源,通过filepath加载,则会走b流程。我们的应用中c场景还蛮多的,因此如果要利用LOTImageCache机制,则需要将a与c的图片资源均转发到b流程中。
顺带,如果要做缓存,缓存的唯一key是什么?由于iOS的沙盒机制,路径不唯一,因此最合理的file key是沙盒的相对目录。
代码实现
1. 资源加载逻辑引流,将所有资源加载引流到b流程
@implementation LOTLayerContainer (LottieManager)
+ (void)lottieManager_setup {
//这里只能方法交换,对LOTLayerContainer的_setImageForAsset流程做前置处理
[self swizzleInstanceMethod:@selector(_setImageForAsset:) with:@selector(_d_setImageForAsset:)];
}
- (void)_d_setImageForAsset:(LOTAsset *)asset { // LOTAsset
//NSUInteger assetType = 0;
if ([asset.imageName hasPrefix:@"data:"]) {
// 将base64资源包裹起来形成一个proxy结构,来应对正式流程中对imageName hasPrefix:@"data:"的判断。消费后即恢复正常imageName以避免别的问题
asset = [DWarpLOTAsset asset:asset];
asset.imageNameTemp = @"temp";
} else if (asset.rootDirectory.length > 0) {
// 复合缓存预期的类型
} else if (!asset.ignoreBundleResource) {
// 将bundle资源转换为路径资源的表示(type3 -> type2),以便于进行cache。构成rootDirectory以进入流程2
NSString *imagePath = [asset.assetBundle pathForResource:asset.imageName ofType:nil];
asset.rootDirectory = [imagePath stringByAppendingFormat:@"?key=bundle:%@", asset.assetBundle.bundleIdentifier];
}
[self _d_setImageForAsset:asset];
}
@end
- 这个问题只能用方法交换解决。
- base64的图片资源转发只能用proxy对象解决,imageName只读无法修改。尽量少用DWarpLOTAsset,毕竟避免频繁创建多余对象。
2. LOTImageCache的缓存与同步解码逻辑
[LOTCacheProvider setImageCache:[DCDLottieImageCache new]];
@implementation LottieImageCache
- (LOTImage *)imageForKey:(NSString *)imagePath {
// LOTImageCache的入参是imagepath,利用携带参数计算缓存key。缓存key预期是base64或者相对file路径,或者前面串的bundle表示
NSString *key = imagePath;
NSRange search = [imagePath rangeOfString:@"key="];
if (search.location != NSNotFound) {
key = [imagePath substringFromIndex:search.location + 4];
} else if (![imagePath hasPrefix:@"data:"]) {
key = [imagePath relativePath];// 下载库的工具方法
}
// 从SD内存加载图片
UIImage *image = [[SDImageCache sharedImageCache] imageFromMemoryCacheForKey:key];
if (image) {
// 命中缓存后直接快速返回
return image;
} else {
//没命中缓存
}
// 没命中缓存直接同步加载走起
if ([imagePath hasPrefix:@"data:"]) {
image = [Utils imageFromBase64:imagePath];
} else {
NSRange search = [imagePath rangeOfString:@"?"];
if (search.location != NSNotFound) {
image = [UIImage imageWithContentsOfFile:[imagePath substringToIndex:search.location]];
}
}
// 如果找不到图像
if (!image) {
// 报个错吧,要不要兜底图
} else {
// 同步加载出图像后往cache同步放一份
[self setImage:image forKey:key];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
// 异步的本地预加载缓存逻辑,稍后讲
});
}
return image;
}
- (void)setImage:(LOTImage *)image forKey:(NSString *)key {
// 直接往SD放一份就好
[[SDImageCache sharedImageCache] setImage:image imageData:nil forKey:key withType:BDImageCacheTypeMemory];
}
@end
- 缓存key用作关键信息储存,并且做快速名字,目前缓存key有以下形式:
- "data:base64.....":快速匹配base64图片资源。
- "bundle:com.xxx.xxx/aaaa.png":描述一个bundle中加载的资源
- "/xxx/yyy/aaaa.png":描述一个沙盒目录中的资源
- 利用SDImageCache的内存缓存做实际缓存,本来就是从磁盘读取的图片就没必要再来一次磁盘缓存了。利用SD的好处是,他本身会收听lowmemery事件进行合理的清除逻辑,同时整个app的内存图像也统一管理可度量了。
- 这段代码收口了全部的lottie资源加载,因此可以在这段逻辑中统计到 a. 资源加载异常情况,上报错误。b.本次做的缓存命中情况与命中率。 c. 资源兜底(如果有必要)。
3. LRU的本地后台线程预加载
// mmkv实例
_mmkvStore = [MMKV mmkvWithID:@"com.motor.LottieImageCache"];
// 上面图片加载成功时的逻辑
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
// 异步的本地预加载缓存逻辑
[_mmkvStore lru_setString:@"" forKey:key cost:1];
// 这里利用mmkv的特定的lru辅助方法储存
});
// 应用启动时的预载逻辑
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
NSArray *keys = [_mmkvStore allKeys];
[keys enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSString *key = (NSString *)obj;
UIImage *img = nil;
if ([[SDImageCache sharedImageCache] imageFromMemoryCacheForKey:key]) {// 图像已经在缓存中了(可能已经被同步加载),不需要处理
return;
}
if ([key hasPrefix:@"bundle:"]) {
NSBundle *bundle = [NSBundle bundleWithIdentifier:[key.pathComponents[0] substringFromIndex:7]];
NSString *imagePath = [bundle pathForResource:(NSString *)obj ofType:nil];
img = [UIImage imageWithContentsOfFile:imagePath];
} else if ([key hasPrefix:@"data:"]) {
img = [Utils imageFromBase64:key];
} else {
img = [UIImage imageWithContentsOfFile:[key fullPath]];//相对路径转化为全路径
}
img = [img decodedImageWithImage];// 这是sd的解码方法
if (!img) {
// 缓存失效了,移除
[_mmkvStore removeValueForKey:key];
} else {
// 缓存有效,往SD内存缓存里放
[self setImage:img forKey:key];
}
}];
});
1. 整个逻辑在启动后后台线程执行,和主线程使用lottie的地方相互独立存在竞争。也就是说有可能这边准备好了图片,主线程刚好从sd中读取到。也有可能主线程自己解码了图片放在sd中,这里不需要再解码了。也有可能重复工作了。不过整体看无所谓。
2. lru的逻辑是最早最常用的放在最前。这是因为刚才竞态的缘故,最早用的图肯定要最早提前解码咯。同时常用也是另一关键因素,不过因为有内存缓存,所以时间最重要。
3. 资源的信息都储存在key里了,因此可以直接恢复。这也是串通整个方案的关键。
4. 实际上我们将这个逻辑添加在了app主线程第一次不卡顿后,后台线程预加载对首页的资源帮助不大,但是二三级常用页面如果有大量lottie,就很管用了。如果有必要可以给缓存做更细致化的初始化时间分级。
结果上看,完成了这些工作之后,缓存命中率90%左右,问题还是首屏资源缓存无法奇效,整体看imageWithContentsOfFile卡顿确实大大的下降了。
大体的流程图如下: