SDWebImage源码分析 - 加载大量图片崩溃

5 阅读12分钟

SDWebImage加载大量图片崩溃

场景

最近在UITableViewUICollectionView中分别碰到了用sd_setImage加载大量图片导致内存泄漏而崩溃的问题,尝试过如下方法

  1. 让服务端将webp图片的大小改小,基本已经保持每张图片在100k上下

  2. 设置最大并发下载数


SDWebImageDownloader.shared.config.maxConcurrentDownloads = 10

  1. 在cell的prepareForReuse方法中取消下载


override func prepareForReuse() {

    super.prepareForReuse()

    imageView.sd_cancelCurrentImageLoad()

}

崩溃频率情况有所改善,但是在非常快速的滑动和请求下还是招架不住内存激增而崩溃。

在这种情况下应该就不是图片本身有什么问题了,毕竟一张图才70k,就算屏幕上有30张图片也才3M左右,不可能导致内存泄漏,这个时候有两个考虑的方向

考虑方向

  1. 缓存的频繁读写

  2. SDWebImage在图片下载完成后是不是对图片进行了其他解析处理

带着这两个问题我们来扒一扒源码。

SDWebImage版本:5.18.6

以下源码分析部分为了便于理解,只保留关键部分

源码分析

sd_setImage方法使用url、占位符、自定义选项和上下文为imageView设置image。下载是异步缓存的。

参数说明

  • url:图片的url。

  • placeholder:最初要设置的图像,直到图像请求完成。

  • options:下载图像时使用的选项。是一个枚举,下面会详细说明

  • context :context包含不同的选项来执行指定的更改或进程,请参阅SDWebImageContextOption。它容纳了options枚举不能容纳的额外对象。


typedef NSDictionary<SDWebImageContextOption, id> SDWebImageContext;

  • progressBlock:下载图像时调用的块

    - 注意进度块在后台队列中执行

    


    typedef void(^SDImageLoaderProgressBlock)(NSInteger receivedSize,

                                              NSInteger expectedSize,

                                              NSURL * _Nullable targetURL);

    

  • completedBlock:当操作完成时调用的块。这个块没有返回值,它以请求的UIImage作为第一个参数。如果发生错误,image参数为nil,第二个参数可能包含NSError。第三个参数是一个布尔值,表示图像是从本地缓存还是从网络中检索的。第四个参数是原始图片的url。


typedef void(^SDExternalCompletionBlock)(UIImage * _Nullable image,
                                         NSError * _Nullable error,
                                         SDImageCacheType cacheType,
                                         NSURL * _Nullable imageURL);

optinos - SDWebImageOptions

  • retryFailed: 图片加载失败后,允许后续重新尝试加载。

  • lowPriority: 图片下载应该在 UI 交互期间延后进行,减少对主线程的影响。

  • progressiveLoad: 图片下载的过程中逐步显示图片,类似于浏览器的行为。

  • refreshCached: 如果图片已经被缓存,使用这个选项将强制重新从网络下载图片并刷新缓存。

  • continueInBackground: 允许应用在进入后台后继续下载图片。

  • handleCookies: 在请求图片时处理和发送存储在 NSHTTPCookieStore 中的 cookies。

  • allowInvalidSSLCertificates: 允许下载器使用无效的 SSL 证书。在生产环境中应该谨慎使用。

  • highPriority: 图片下载任务应立即开始,而不是在队列中等待。

  • delayPlaceholder: 在图片下载完成之前,不要显示占位符。

  • transformAnimatedImage: 允许转换类库来处理和缓存动画图片。

  • avoidAutoSetImage: 下载完成后,不要自动将下载的图片设置到 UIImageView 上。你可能想要在完成回调中手动设置图片。

  • scaleDownLargeImages: 如果原始图片的尺寸大于设备的尺寸,自动缩小图片尺寸以节省内存。

  • queryDiskSync: 同步从磁盘加载图片。默认情况下,磁盘加载是异步的。

  • queryMemoryData: 同步查询内存缓存,异步查询磁盘缓存。

  • queryMemoryDataSync: 同步查询内存缓存和磁盘缓存。

  • fromLoaderOnly: 只从下载器加载图片,直接跳过缓存查询。

  • fromCacheOnly: 只从缓存加载图片,不进行网络请求。

  • forceTransition: 总是为图片加载强制执行过渡动画,即使图片已经缓存在内存中。

  • matchAnimatedImageClass :用来指示缓存系统应该返回与请求的图像类类型匹配的对象。具体来说,如果请求的是一个动画图像类(例如 SDAnimatedImage),设置这个选项会让缓存系统尝试返回一个动画图像类实例,而不是基础的 UIImage 对象。

context - SDWebImageContextOption

SDWebImageContext 是一个键值字典,其中的键定义在 SDWebImage 框架里,通常以 SDWebImageContext 为前缀。下面是一些常用的键和它们对应含义的例子:

  • storeCacheType: 指定图片应该存储在哪个缓存类型中(例如,只存储到磁盘、只存储到内存等)。

  • customManager: 允许为当前加载操作指定一个自定义的 SDWebImageManager

  • setImageOperationKey: 用于支持自定义的加载操作,可以为特定的加载操作设置一个唯一的键。

  • imageScaleFactor: 图片加载完成后,调整图片的缩放因子。

  • imageThumbnailPixelSize: 加载图片时生成缩略图的目标像素大小。

  • imageTransformer: 指定一个 SDImageTransformer 对象,用于在图片解码后进行转换(例如,裁剪、缩放等)。

  • cacheKeyFilter: 一个 SDWebImageCacheKeyFilter 对象,用于转换图像的缓存键。

  • imageCache:一个实现了SDImageCache协议的对象(若未实现则用默认的SDImageCache单例对象)

调用流程

sd_internalSetImageWithURL

在所有的封装方法中,最终调用的是在UIView+WebCache.m文件中sd_internalSetImageWithURL方法


// 1. 从context中获取SDWebImageContextSetImageOperationKey对应的值

// 若为空则设置为NSStringFromClass([self class]);用于跟踪操作或图像类

NSString *validOperationKey = context[SDWebImageContextSetImageOperationKey];

// 将该key赋值给UIView的关联对象sd_latestOperationKey

self.sd_latestOperationKey = validOperationKey;


// 若options中 不 包含avoidAutoSetImage,取消上次加载

···

[self sd_cancelImageLoadOperationWithKey:validOperationKey];

// 2. 创建SDWebImageLoadState对象,存入当前进度(0)和url

[self sd_setImageLoadState:loadState forKey:validOperationKey];

// 3. 从context中获取SDWebImageManager,若为空则用SDWebImageManager单例

···

manager = [SDWebImageManager sharedManager];

··· // progress处理

// 4. 创建SDWebImageCombinedOperation对象 并执行,存入SDOperationsDictionary字典中

可以看到主要调用SDWebImageManegerloadImageWithURL 

SDWebImageManager - loadImageWithURL

  1. 前置判断和预处理


// 常规判断,比如OC的类型检查不如swift严格,如果传入的是NSString,内部会封装成NSURL

···

// 创建SDWebImageCombinedOperation对象

// 若context和option中包含一些预处理的话,执行预处理。

// 比如imageTransformer,cacheKeyFilter, cacheSerializer

SDWebImageOptionsResult *result = 

[self processedResultForURL:url options:options context:context];

  1. 下面就开始条目从缓存中加载图像

[self callCacheProcessForOperation:operation
                               url:url
                           options:result.options
                           context:result.context
                          progress:progressBlock
                         completed:completedBlock];

最长的步骤如下

  • 不使用转换器的步骤:

    1. 从缓存中查询图像

    2. 下载数据和图像

    3. 将图像存储到缓存中

  • 使用转换器的步骤:

    1. 查询转换图像从缓存,若miss

    2. 从缓存中查询原始图像,若miss

    3. 下载数据和图像

    4. 在CPU中做变换

    5. 将原始图像存储到缓存中

    6. 存储转换后的图像到缓存

以下为SDImageCachequeryImageForKey流程

SDWebImageManeger - callCacheProcessForOperation

optinons不包含 fromLoaderOnly,则先执行缓存查询

  1. 调用imageCache(默认单例对象/自定义)的queryImageForKey方法获取缓存,以下为默认处理SDImageCache - queryImageForKey


SDImageCacheOptions cacheOptions = 0;

if (options & SDWebImageQueryMemoryData) 
    cacheOptions |= SDImageCacheQueryMemoryData;

if (options & SDWebImageQueryMemoryDataSync) 
    cacheOptions |= SDImageCacheQueryMemoryDataSync;

if (options & SDWebImageQueryDiskDataSync) 
    cacheOptions |= SDImageCacheQueryDiskDataSync;

if (options & SDWebImageScaleDownLargeImages) 
    cacheOptions |= SDImageCacheScaleDownLargeImages;

if (options & SDWebImageAvoidDecodeImage) 
    cacheOptions |= SDImageCacheAvoidDecodeImage;

if (options & SDWebImageDecodeFirstFrameOnly) 
    cacheOptions |= SDImageCacheDecodeFirstFrameOnly;

if (options & SDWebImagePreloadAllFrames) 
    cacheOptions |= SDImageCachePreloadAllFrames;

if (options & SDWebImageMatchAnimatedImageClass) 
    cacheOptions |= SDImageCacheMatchAnimatedImageClass;


return [self queryCacheOperationForKey:key options:cacheOptions context:context cacheType:cacheType done:completionBlock];

可以看到会先判断option中是否包含缓存相关的配置,用与或处理得出cacheOptions,调用queryCacheOperationForKey方法找图,并执行解析等操作。分为查询memoryCachediskCache两部分。

以下为先查询memoryCache部分


// 1. 若不请求缓存,则返回空

if (queryCacheType == SDImageCacheTypeNone) {

    if (doneBlock) {

        doneBlock(nil, nil, SDImageCacheTypeNone);

    }

    return nil;

}

// 2. 若不为磁盘缓存,则查询内存缓存。即在SDImageCache的memoryCache对象中根据key查找。

UIImage *image;

if (queryCacheType != SDImageCacheTypeDisk) {

    image = [self imageFromMemoryCacheForKey:key];

}

// 若从内存中找到图片

// 2.1 若option包含decodeFirstFrameOnly,且桢数大于1(如gif动图),获取首桢图片

image = [[UIImage alloc] initWithCGImage:image.CGImage

                                   scale:image.scale

                             orientation:image.imageOrientation];

// 2.2 若option包含matchAnimatedImageClass,且context中若包含的animatedImageClass(表示期望的类)与实际获取的image类型不匹配,则将image置空

if (options & SDImageCacheMatchAnimatedImageClass) {

    // Check image class matching

    Class animatedImageClass = image.class;

    Class desiredImageClass = context[SDWebImageContextAnimatedImageClass];

    if (desiredImageClass && ![animatedImageClass isSubclassOfClass:desiredImageClass]) {

        image = nil;

    }

}

  


// 3. 若配置中表示只查询memory,则将当前获取流程完成的image返回

BOOL shouldQueryMemoryOnly = (queryCacheType == SDImageCacheTypeMemory) || (image && !(options & SDImageCacheQueryMemoryData));
if (shouldQueryMemoryOnly) {
    if (doneBlock) {
        doneBlock(image, nil, SDImageCacheTypeMemory);
    }
    return nil;
}


也就是说,如果只查询memoryCache,只会返回Image对象,不会返回data。

以下为查询diskCache部分

若image不为空且指定queryMemoryDataSync,或image为空且指定queryDiskDataSync,则表示应该同步查询磁盘缓存,否则异步。

同步与异步执行流程相同。


// 1. 从磁盘获取data数据

NSData *diskData = [self.diskCache dataForKey:key];

若上面memoryCache中获取到的image非空,则配合data一起返回(不管diskData有没有)

若从磁盘中获取的diskData非空

  1. 若contetx中包含storeCacheType,值为cacheTypeAll/cacheTypeMemory,打标shouldCacheToMomery为true

获取context中的imageThumbnailPixelSize(缩略图尺寸)

若缩略图尺寸的width和height都大于0,则shouldCacheToMomery置为false。

**因为最终返回的是缩略图,不应该写到full-size图片的key的缓存中!!! **

  • 根据上面对storeCacheType和imageThumbnailPixelSize的处理,加上笔者偶然给context加上imageThumbnailPixelSize之后就不崩溃了,可以猜测是因为磁盘的频繁读写处理不当导致内存激增而崩溃
  1. 特殊情况:如果用户在list中查询相同URL的图像,为了避免多次解码并将相同的图像对象写入磁盘缓存,会再次查询并检查内存缓存。(没看太懂)


// Special case: If user query image in list for the same URL, to avoid decode and write same image object into disk cache multiple times, we query and check memory cache here again.

if (shouldCacheToMomery && self.config.shouldCacheImagesInMemory) {

    diskImage = [self.memoryCache objectForKey:key];

}

  1. 若上面获取的diskImage为空,将diskData解析为UIImage对象并解档


UIImage *image = SDImageCacheDecodeImageData(data,

                                             key,

                                             [[self class] imageOptionsFromCacheOptions:options],

                                             context);

[self _unarchiveObjectWithImage:image forKey:key];

  


// 存入内存

if (shouldCacheToMomery && diskImage && self.config.shouldCacheImagesInMemory) {

    NSUInteger cost = diskImage.sd_memoryCost;

    [self.memoryCache setObject:diskImage forKey:key cost:cost];

}

  


然后将解析的图片配合data返回。

拿到上述queryImageForKey流程的结果后

  1. 若被取消,抛出错误;

  2. 若未命中缓存,调用context中的cacheKeyFilter,获取到查询键(也就是自定义的查询键与SDWebImage自己生成的查询键不同的话)并调用callOriginalCacheProcessForOperation查询原始缓存


NSString *originKey = [self originalCacheKeyForURL:url context:context];

BOOL mayInOriginalCache = ![key isEqualToString:originKey];

  


if (mayInOriginalCache) {

    [self callOriginalCacheProcessForOperation:operation

                                           url:url

                                       options:options

                                       context:context

                                      progress:progressBlock

                                     completed:completedBlock];

    return;

}

  


  1. 否则不管前面获取的cachedImage是否为空,都走到callDownloadProcessForOperation(为什么?)
  • 提问:为什么命中了缓存还是要执行到下载流程

SDWebImageManeger - callDownloadProcessForOperation

综上所述该方法调用有两个时机

  • optinons包含 fromLoaderOnly

  • 在缓存查询流程中未获取cachedImage

  1. context中获取imageLoader,若无则为默认单例SDWebImageDownloader

  2. 一系列判断,检查是否需要从网络下载图片

  3. 若不需要下载,且cachedImage为空,直接返回

  4. 若不需要下载,且cachedImage不为空,将图片返回

  5. 以下为需要下载的流程(此时不关心cachedImage是否为空)


// 1. 若cachedImage不为空,且options中包含refreshCached,表示需要刷新缓存

if (cachedImage && options & SDWebImageRefreshCached) {

// 先将获取到的cachedImage抛出,然后继续执行下载流程。

// 将缓存的图像传递给loader。loader应该检查远程图像是否与缓存图像相等。(确保速度)

···

}

具体执行的方法为SDWebImageDownloaderrequestImageWithURL

获取到SDWebImageDownloaderrequestImageWithURL的结果后

  1. 若被取消,返回错误

  2. 若cachedImage不为空,且指定要刷新缓存,但远端指定不修改缓存的图像

例如HTTP响应304代码,不处理(不处理???那不是回调出不去了)


if (cachedImage &&

    options & SDWebImageRefreshCached

    && [error.domain isEqualToString:SDWebImageErrorDomain]

    && error.code == SDWebImageErrorCacheNotModified) {}

  1. 若请求被取消或报错,抛出错误(不抛出cachedImage)

  2. 执行转换流程


  


[self callTransformProcessForOperation:operation

                                   url:url

                               options:options

                               context:context

                         originalImage:downloadedImage

                          originalData:downloadedData

                             cacheType:SDImageCacheTypeNone

                              finished:finished

                             completed:completedBlock];

  


SDWebImageDownloader - callTransformProcessForOperation

什么情况下需要转换呢,看源码可知:

  1. downloadedImage不为空

  2. context包含imageTransformer

  3. downloadImage不是动图,也不是矢量图

判断条件如下所示


id<SDImageTransformer> transformer = context[SDWebImageContextImageTransformer];

if ([transformer isEqual:NSNull.null]) {

    transformer = nil;

}

// transformer check

BOOL shouldTransformImage = originalImage && transformer;

shouldTransformImage = shouldTransformImage && (!originalImage.sd_isAnimated || (options & SDWebImageTransformAnimatedImage));

shouldTransformImage = shouldTransformImage && (!originalImage.sd_isVector || (options & SDWebImageTransformVectorImage));

下一步要继续判断,下载下来的图是否是缩略图,若为缩略图也不存储


// thumbnail check

BOOL isThumbnail = originalImage.sd_isThumbnail;

NSData *cacheData = originalData;

UIImage *cacheImage = originalImage;

if (isThumbnail) {

    cacheData = nil; // thumbnail don't store full size data

    originalImage = nil; // thumbnail don't have full size image

}

  


若需要转换,调用transformertransformedImageWithImage方法,执行存储流程

若不需要转换,则走到存储流程,将下载到的原始图片存储

SDWebImageDownloader - callStoreOriginCacheProcessForOperation


// 1. 若context中包含originalStoreCacheType表示存储类型,则为该类型。否则默认磁盘存储

SDImageCacheType originalStoreCacheType = SDImageCacheTypeDisk;

if (context[SDWebImageContextOriginalStoreCacheType]) {

    originalStoreCacheType = [context[SDWebImageContextOriginalStoreCacheType] integerValue];

}

// 如果originalStoreCacheType是disk,因为我们不需要再次存储原始数据,从originalStoreCacheType中剥离disk

if (cacheType == SDImageCacheTypeDisk) {

    if (originalStoreCacheType == SDImageCacheTypeDisk)  // 若为磁盘,置为None

        originalStoreCacheType = SDImageCacheTypeNone;

    if (originalStoreCacheType == SDImageCacheTypeAll) // 若为All,置为Memory

        originalStoreCacheType = SDImageCacheTypeMemory;

}

  


// 2. 从context中获取cacheSerializer,使用自定义序列化者,否则不进行额外序列化处理

id<SDWebImageCacheSerializer> cacheSerializer = context[SDWebImageContextCacheSerializer];

  


// 3. 执行存储

// 3.1 encodedDataWithImage

NSData *encodedData = 

[[SDImageCodersManager sharedManager] encodedDataWithImage:image

                                                    format:format

                                                   options:context[SDWebImageContextImageEncodeOptions]];

  


// 3.2 memoryCache setObject + _storeImageDataToDisk

[self.memoryCache setObject:image forKey:key cost:cost];

[self _storeImageDataToDisk:encodedData forKey:key];

  


// 3.3 归档

[self _archivedDataWithImage:image

                      forKey:key];

至此所有流程都已结束,将结果抛出。

解决方案

  1. 传入图片质量参数,在原图url后面拼上webP的参数,降低原图大小

  2. 在sd_setImage的context参数中加上imageThumbnailPixelSize,传入图片当前在屏幕中的尺寸scale4倍(保证显示清晰)


// quality: (0, 100]

func _setWebPImage(_ originUrl: URL?, imageSize: CGSize?, quality: MyWebPImageQuality = .origin) {

    

    var holder = UIImage(named: "loading_placeholder")

    if let imageSize = imageSize {

        holder = holder?.placeImage(in: imageSize)

    }

    

    guard let originUrl = originUrl else {

        image = holder

        return

    }

    var finalQuality = max(1, quality.rawValue)

    finalQuality = min(100, quality.rawValue)

    var urlString = originUrl.absoluteString

    // 原始质量 不对url处理

    guard finalQuality < 100 else {

        sd_setImage(with: originUrl, placeholderImage: holder)

        return

    }

    

    let formatPrefix = "?x-oss-process=image/format,webp/quality,q_"

    if let range = urlString.range(of: formatPrefix) {

        urlString = String(urlString[urlString.startIndex..<range.lowerBound])

    }

    

    var context: [SDWebImageContextOption: Any]?

    if quality.rawValue <= 10,

       let imageSize = imageSize {

        let imageThumbnailPixelSize = CGSizeMake(imageSize.width * 8, imageSize.height * 8)

        context = [.imageThumbnailPixelSize : imageThumbnailPixelSize]

    }

    

    urlString.append("\(formatPrefix)\(finalQuality)")

    sd_setImage(with: URL(string: urlString),

                placeholderImage: holder,

                context: context)
}


上面查看源码我们知道如果设置了imageThumbnailPixelSize,不会触发缓存的读&写。

待研究

  1. shouldUseWeakMemoryCache

  2. SDImageCacheDecodeImageData

  3. SDWebImageCombinedOperation封装的作用