iOS 开源库源码分析之SDWebImage
此文基于SDWebImage-4.3.0
SDWebImage 是 Objective-C 最流行的图片下载框架,其作者是 Olivier Poitrey,是来自法国的一位开发者。
本文主要介绍 UIImageView 的图片加载流程,以及如何管理的图片下载,图片数据 decoder 的过程,cache 的原理,渐进加载等
官方给出了类图和时序图,https://github.com/SDWebImage/SDWebImage#architecture
UIImageView的图片加载流程
- UIView+WebCache 里面 sd_internalSetImageWithURL 首先设置 placeholder 图片,然后调用 SDWebImageManager 的 loadImageWithURL: 方法
- SDWebImageManager 中 loadImageWithURL 里面首先调用 SDImageCache 的 queryCacheOperationForKey 查询缓存(先查内存再查磁盘)
- SDWebImageManager 中查询缓存操作之后,根据策略来决定是否需要下载,包括是否只读缓存,没有缓存图片,需要更新缓存。
- 接下来调用 SDWebImageDownloader 的 downloadImageWithURL 方法进行下载,首先会查看是否有该 url 正在进行的 operation,有则不做操作,无则创建 operation 并且加入 queue。
- SDWebImageManager 收到下载成功回调后,调用 SDImageCache 的 storeImage 方法把图片写入内存和磁盘,然后把成功回调抛出
- UIView+WebCache 收到图片后,把图片设置给 View
SDWebImageDownloaderOperation
SDWebImageDownloaderOperation 的初始化需要传入要下载图片 url 构造的 NSURLRequest 和 NSURLSession。
方法 | 描述 |
---|---|
start | (必选)所有的并发Operation必需重写这个方法并且要实现这个方法的内容来代替原来的操作。手动执行一个操作,你可以调用start方法。因此,这个方法的实现是这个操作的始点,也是其他线程或者运行这你这个任务的起点。注意一下,在这里永远不要调用[super start]。 |
main | (可选)用于实现与操作对象相关联的任务。虽然可以在start中执行,但是这里实现让你的安装代码和任务代码分离的更干净。并且不能调用super |
isExecuting 和 isFinish | (必选)并发队列负责维持当前操作的环境和告诉外部调用者当前的运行状态。因此,一个并发队列必需维持保持一些状态信息以至于知道什么时候执行任务,什么时候完成任务。它必须通过这些方法告诉外部当前的状态。这种而且这些方法必须是线程安全,当状态发生改变的时候,你必须使用KVO通知监听这些状态的对象。 |
isConcurrent | (必选)定义一个并发操作,重写这个方法并且返回YES |
所以综上,
首先重写了 setExecuting 和 setFinished,在其中发起了 KVO 的改变,然后在 isConcurrent 返回 YES,不过 SDWebImage 在 done 中并没有保证线程安全,AFNetworking2 中 AFURLConnectionOperation 中使用的 NSRecursiveLock 来控制这些状态的 KVO 变化。
因为 dataTask 发起网络请求也是在 SDWebImageDownloaderOperation 是全局的,所以在 start 中创建网络请求也需要保证线程安全
由于 Operation 可以让多个对象接受回调,所以这里也处理回调的线程安全
SDWebImageDownloader
SDWebImageDownloader 是一个单例,是通过 downloadImageWithURL 完成下载图片的,步骤如下
对于所有的 Operation 使用一个字典来保存,key 为 url,对于相同的 url 会取出 Operation,当然 cancel 以及收到 completionBlock 之后 operation 会从字典移除。这样做确保同一张图片不会被重复下载
如果从未创建该 url 的 operation,这里会创建一个 Operation 并且加入 queue
给 operation 增加回调
在 Downloader 中,所有图片请求的唯一标识使用一个 SDWebImageDownloadToken 类表示,包括 operation,url 以及前面的回调 callback,callback 这里就是一个 cancelToken,通过判断这个 operation 的 callback 是否存在来表示是否取消。
关于 SDWebImageDownloaderOptions,
- SDWebImageDownloaderUseNSURLCache 是 NSURLRequestCachePolicy 为 NSURLRequestUseProtocolCachePolicy,其他 option 为 NSURLRequestReloadIgnoringLocalCacheData
- SDWebImageDownloaderHandleCookies 的 request 的 HTTPShouldHandleCookies 为 YES,表示 request 是否使用 cookie
- 通过 SDWebImageDownloaderHighPriority 和 SDWebImageDownloaderLowPriority 可以设置 operation 的 queuePriority 为 High 还是 Low 以决定在队列中的优先级
- SDWebImageDownloaderIgnoreCachedResponse 是在 SDWebImageDownloaderOperation 处理逻辑的,在发起请求前会从 URLCache 中取出 cacheData,然后在请求完成之后与其对比,相同则不返回数据
- SDWebImageDownloaderAllowInvalidSSLCertificates 表示 NSURLSessionAuthChallengePerformDefaultHandling 则采用默认处理忽略证书,其他的都会使用服务器证书 NSURLSessionAuthChallengeUseCredential
Decoder
Decoder 使用的是工厂方法模式,在使用的时候决定使用哪一种 decoder 实例。
见图
那么 DecoderManager 是如何决定使用哪一个具体的 decoder 呢?
这里通过的是 NSData+ImageContentType 这个 category 来完成,获取到 UIImage data 的第一个16进制字节,根据不同的开头表示不同的图片格式。
如
uint8_t c;
[data getBytes:&c length:1];
switch (c) {
case 0xFF:
return SDImageFormatJPEG;
case 0x89:
return SDImageFormatPNG;
case 0x47:
return SDImageFormatGIF;
}
具体的格式可以参考这个文档
对于编码 encodedDataWithImage 方法,在 SDImageCache 的 storeImage 中写入磁盘的 image 都会进行 encode。
以 SDWebImageImageIOCoder 的 encode 为例子,encode 前首先会根据图片是否含有 alpha channel 来看是 PNG 还是 JPEG,因为 JPEG 是不支持透明度的。
然后通过 image.imageOrientation 获取 exifOrientation,就是图片的方向,关于 Exif 这个是专门为数码相机的照片设定的,可以记录数码照片的属性信息和拍摄数据,全称 Exchangeable image file format。
然后通过 CGImageDestinationAddImage 方法把 image,以及方向,格式变成 data。
加载图片的流程
- 从磁盘或网络读取一张 UIImage
- 赋值给 UIImageView
- 把图片数据解码为未压缩的位图渲染到 UIImageView
其中解压缩是一个非常耗时的 CPU 操作而且是在主线程,JPEG、PNG 就是一种压缩的位图图形格式,所以必须要解压缩。
所以可以尝试把解压缩提前,这样就在渲染的时候不用解压缩了,并且把它放在子线程。
SDWebImage 对于解压缩图片,可以通过 SDWebImageDownloaderOperation 的 shouldDecompressImages 属性(默认YES)决定是否在获取到图片数据后压缩返回。
依然以 SDWebImageImageIOCoder 的 decompressedImageWithImage 为例
解压缩的原理就是对图片进行重新绘制,得到一张新的解压缩后的位图。其中,用到的最核心的函数是
CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(void * __nullable data, size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow, CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo)
这个是创建位图的上下文环境
位图其实就是一个像素数组,而像素格式则是用来描述每个像素的组成格式,它包括以下信息:
- Bits per component :一个像素中每个独立的颜色分量使用的 bit 数;
- Bits per pixel :一个像素使用的总 bit 数;
- Bytes per row :位图中的水平上每一行使用的字节数。
对于位图来说,像素格式并不是随意组合的,目前只支持以下有限的 17 种特定组合:
bitmapInfo参数是提供一些布局信息,
- CGImageAlphaInfo 表示的是否包含 alpha channel,以及 alpha channel 的在一个像素中的位置,比如 RGBA 还是 ARGB
- CGBitmapInfo 表示的字节顺序,包括小端模式还是大端模式,数据以16位还是32位为单位
SDImageCache
SDImageCache 的本地缓存策略主要包括使用 NSCache 的内存缓存和文件缓存。
作为内存缓存来说,需要把一大堆 UIImage 与其对应的 url 一一保存起来,可以 NSMutableDictionary,这里采用的是 NSCache,在使用上 NSCache 和 NSMutableDictionary 类似。但是 NSCache 相对于它来说是,线程安全且在其他程序需要内存的时候有自动清理的功能
关于 UIImage,这里有个小 tip 是 SDScaledImageForKey,网络下载下来的 UIImage 返回给使用方会对 scale 进行校验。
if (key.length >= 8) {
NSRange range = [key rangeOfString:@"@2x."];
if (range.location != NSNotFound) {
scale = 2.0;
}
range = [key rangeOfString:@"@3x."];
if (range.location != NSNotFound) {
scale = 3.0;
}
}
UIImage *scaledImage = [[UIImage alloc] initWithCGImage:image.CGImage scale:scale orientation:image.imageOrientation];
image = scaledImage;
比如url中含有 @2x.,得到的 size(100,100),会通过 initWithCGImage: 方法校验为 (50,50)
文件缓存的图片名字是 url 的 md5 结果
缓存文件图片可以设置是否被 iCloud 同步,默认是同步的
// disable iCloud backup
if (self.config.shouldDisableiCloud) {
[fileURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
SDImageCache 有几个通知,
接收 UIApplicationDidReceiveMemoryWarningNotification 通知之后会把 NSCache 清空
UIApplicationWillTerminateNotification,在 App 即将终止的时候会删除旧文件
在 SDImageCacheConfig 可以设置最大缓存时间 maxCacheAge,这里可以取出最后的图片文件改动时间来判断是否过期来决定删除
NSDictionary<NSString *, id> *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:&error];
NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
另外还会根据 maxCacheSize 来判断,如果发现现存文件已经超过最大 size,则会根据文件改动时间从远及近删除文件直到小于 maxCacheSize。
- UIApplicationDidEnterBackgroundNotification在后台的时候删除旧文件
__block UIBackgroundTaskIdentifier bgTask = [application beginBackgroundTaskWithExpirationHandler:^{
// Clean up any unfinished task business by marking where you
// stopped or ending the task outright.
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];
// Start the long-running task and return immediately.
[self deleteOldFilesWithCompletionBlock:^{
[application endBackgroundTask:bgTask];
bgTask = UIBackgroundTaskInvalid;
}];
正常程序退出后,会在几秒内停止工作,要想申请更长的时间,需要用到 beginBackgroundTaskWithExpirationHandler,hanlder 会在后台任务维持的极限时间 backgroundTimeRemaining 收到,这个时候需要 endBackgroundTask。
SDWebImage支持URL不变时更新图片内容
首先讲一下HTTP的缓存策略,当访问一个资源的时候,response 里面会有 Cache-Control 字段,表明缓存策略,Cache-Control 也是一个通用首部字段,request 和 response 都有
cache-control 字段的意义和选择,可以看 Google 的一张图
比如 Cache-Control:no-cache ,就表次需要每次请求并且校验资源。
关于是否知道资源的更新有 etag 和 last-modified 两个 response 字段
etag(response) —— if-none-match(request key)
last-modified (response)—— if-modified-since(request key,value为服务器回传到客户端的图片最后被修改的时间,对应的是response Last-Modified)
etag 是资源标识,last-modified 是资源最后的改动时间。一般来说 etag 精准度高一点,但也是依据对应服务端而言。
SDWebImageDownloader 有一个 headersFilter 属性,支持在图片的请求中插入 header,所以可以这里插入本地图片的 if-modified-since(本地图片的更新时间),或者本地图片的 etag,举例 if-modified-since,
SDWebImageDownloader *imageDownloader = SDWebImageManager.sharedManager.imageDownloader;
imageDownloader.headersFilter = ^NSDictionary *(NSURL *url, NSDictionary *headers) {
NSFileManager *fileManager = [[NSFileManager alloc] init];
NSString *imageKey = [SDWebImageManager.sharedManager cacheKeyForURL:url];
NSString *imagePath = [SDWebImageManager.sharedManager.imageCache defaultCachePathForKey:imageKey];
NSDate *lastModifiedDate;
NSDictionary *fileAttr = [fileManager attributesOfItemAtPath:imagePath error:nil];
if (fileAttr.count > 0) {
lastModifiedDate = (NSDate *)fileAttr[NSFileModificationDate];
}
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"GMT"];
formatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US"];
formatter.dateFormat = @"EEE, dd MMM yyyy HH:mm:ss z";
NSString *lastModifiedStr = [formatter stringFromDate:lastModifiedDate];
lastModifiedStr = lastModifiedStr.length > 0 ? lastModifiedStr : @"";
NSMutableDictionary *mutableHeaders = [NSMutableDictionary dictionaryWithDictionary:headers];
[mutableHeaders setValue:lastModifiedStr forKey:@"If-Modified-Since"];
return mutableHeaders;
};
然后在 setImageWithURL 时候选择 SDWebImageRefreshCached 即可
SDWebImagePrefetcher(预加载图片)
SDWebImagePrefetcher 是一个预加载图片的类,通过 prefetchURLs: 传入 URL 就可以。
实现大概是:同时下载图片,但是有最大并发数 maxConcurrentDownloads 的控制,每次请求发起会计数 requestedCount,达到并发数控制的时候就需要等待之前请求完成然后依次发起后面的请求。
渐进加载
图片渐进加载要取决于JPEG图片的保存格式了,主要有两种 Baseline JPEG 和 Progressive JPEG。
渐进加载实现方式:
- Progressive,从模糊到清晰
- Baseline,会一行一行显示出来
在 SDWebImage 中,设置为 SDWebImageDownloaderProgressiveDownload 就可以了。
实现代码主要是在 SDWebImageImageIOCoder 的 incrementallyDecodedImageWithData: 方法里面
首先通过 CGImageSourceCreateIncremental(NULL) 创建 soure,然后通过 CGImageSourceUpdateData() 把不断传入的 data 更新到 source,最后通过 CGImageSourceCreateImageAtIndex() 获取到 image
文章到这里已经结束了,SDWebImage 之所以能够流行得益于它通过 UIKit 的category 能够非常方便的设置网络图片,我觉得这也是写一个基础库的关键之所在,关于网络图片的获取,图片的缓存策略则是 SDWebImage 的实现精华所在,关于这些看完 SDWebImage 就够了。
本文作者coderyi