Mar 23

iOS Lottie的imageWithContentsOfFile卡顿解决方案

Lrdcq , 2022/03/23 11:02 , 程序 , 閱讀(3670) , Via 本站原創
大量使用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流程
@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卡顿确实大大的下降了
大体的流程图如下:
點擊在新視窗中瀏覽此圖片
关键词:ios , lottie , 卡顿
logo