紧接着上一篇文章,在这篇文章里面,我会先从
SDWebImageManager
中的 loadImageWithURL 这个方法入手来理解在SDWebImageManager
这一步里面到底做了什么事情。让咱们对整个图片加载流程有个更加详细的理解,随后再分析这个类中其它的一些方法。当然也会顺带把SDImageCache
的作用详细分析一遍。
SDWebImageManager
SDWebImageManager 同时管理 SDImageCache 和 SDWebImageDownloader两个类,它是这一层的中枢。在加载图片任务开始的时候,SDWebImageManager 首先访问 SDImageCache 来查询是否存在缓存,如果有缓存,直接返回缓存的图片。如果没有缓存,就命令SDWebImageDownloader来下载图片,下载成功后,存入缓存,显示图片。以上是SDWebImageManager大致的工作流程。
先介绍一下这个类中比较重要的属性:
//负责缓存相关的类
@property (strong, nonatomic, readwrite, nonnull) SDImageCache *imageCache;
//负责下载相关的类
@property (strong, nonatomic, readwrite, nonnull) SDWebImageDownloader *imageDownloader;
//记录加载失败的图片URL
@property (strong, nonatomic, nonnull) NSMutableSet<NSURL *> *failedURLs;
//记录当前正在进行的任务,可以看到这个任务是 SDWebImageCombinedOperation,下面介绍
@property (strong, nonatomic, nonnull) NSMutableArray<SDWebImageCombinedOperation *> *runningOperations;
1.SDWebImageCombinedOperation
可以看到这个类其实就是实现了上篇文章咱们提到的 SDWebImageOperation 协议,而这个协议也仅仅只有一个cancle方法,在最开始看这里的时候我心里也有疑问,这里 SDWebImageCombinedOperation 也有个cancleBlock,协议也有个cancle方法,这两者到底有什么关系呢???
这里先介绍结论,后面上代码:协议里面的 cancle 方法做的事情是将查询缓存任务和下载任务一并取消掉,而这里面的 cancelBlock 所做的事情其实是去取消下载任务,换句话说,其实就是协议里面的 cancle 方法会调用这个cancleBlock和额外的取消查询缓存。而额外的取消查询缓存的操作也是因为这个 CombinedOperation中包含了查询缓存的cacheOperation,所以起名叫CombinedOperation,包含了下载任务和缓存任务。
@interface SDWebImageCombinedOperation : NSObject <SDWebImageOperation>
@property (assign, nonatomic, getter = isCancelled) BOOL cancelled;//取消查缓存和下载操作
@property (copy, nonatomic, nullable) SDWebImageNoParamsBlock cancelBlock;
@property (strong, nonatomic, nullable) NSOperation *cacheOperation;
@end
2.SDWebImageManagerDelegate:
@protocol SDWebImageManagerDelegate <NSObject>
@optional
//决定当缓存中没有找到图片时是否需要下载,返回 NO 的话代表即使在缓存中没有找到图片也不去下载
- (BOOL)imageManager:(nonnull SDWebImageManager *)imageManager shouldDownloadImageForURL:(nullable NSURL *)imageURL;
//允许图片在刚下载回来并且在还没放入缓存之前对图片进行 transform ,返回变换后的图片
- (nullable UIImage *)imageManager:(nonnull SDWebImageManager *)imageManager transformDownloadedImage:(nullable UIImage *)image withURL:(nullable NSURL *)imageURL;
@end
接下来进入这个非常重要的 loadImageWithURL
:
- (id <SDWebImageOperation>)loadImageWithURL:(nullable NSURL *)url
options:(SDWebImageOptions)options
progress:(nullable SDWebImageDownloaderProgressBlock)progressBlock
completed:(nullable SDInternalCompletionBlock)completedBlock {
...
//保护措施,如果一不小心传了一个string类型过来,直接将它转为NSURL
if ([url isKindOfClass:NSString.class]) {
url = [NSURL URLWithString:(NSString *)url];
}
...
//创建一个任务,并同时创建一个weak引用
__block SDWebImageCombinedOperation *operation = [SDWebImageCombinedOperation new];
__weak SDWebImageCombinedOperation *weakOperation = operation;
//这个failedURLs其实就是黑名单,判断当前传进来的URL是不是已经在黑名单里面了,后面有用
BOOL isFailedUrl = NO;
if (url) {
@synchronized (self.failedURLs) {
isFailedUrl = [self.failedURLs containsObject:url];
}
}
// url的长度为0 || (下载失败后进入黑名单 && 确实在黑名单中)
if (url.absoluteString.length == 0 || (!(options & SDWebImageRetryFailed) && isFailedUrl)) {
// 直接回调完成block
[self callCompletionBlockForOperation:operation completion:completedBlock error:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorFileDoesNotExist userInfo:nil] url:url];
return operation;
}
//没有上述问题,那就直接把创建的任务加载到当前任务表中吧
@synchronized (self.runningOperations) {
[self.runningOperations addObject:operation];
}
//根据URL获取缓存的key
NSString *key = [self cacheKeyForURL:url];
//调用 imageCache 去查询缓存,并返回该任务
operation.cacheOperation = [self.imageCache queryCacheOperationForKey:key done:^(UIImage *cachedImage, NSData *cachedData, SDImageCacheType cacheType) {
if (operation.isCancelled) {
[self safelyRemoveOperationFromRunning:operation];
return;
}
//(没有缓存图片) || (即使有缓存图片,也需要更新缓存图片) || (代理没有响应imageManager:shouldDownloadImageForURL:消息,默认返回yes,需要下载图片)|| (imageManager:shouldDownloadImageForURL:返回yes,需要下载图片)
if ((!cachedImage || options & SDWebImageRefreshCached) && (![self.delegate respondsToSelector:@selector(imageManager:shouldDownloadImageForURL:)] || [self.delegate imageManager:self shouldDownloadImageForURL:url])) {
//1. 存在缓存图片 && 即使有缓存图片也要下载更新图片
if (cachedImage && options & SDWebImageRefreshCached) {
[self callCompletionBlockForOperation:weakOperation completion:completedBlock image:cachedImage data:cachedData error:nil cacheType:cacheType finished:YES url:url];
}
// 2. 如果不存在缓存图片,或者即使有缓存图片也要下载更新图片的情况,直接去下载
.....
//开启下载器下载,subOperationToken 用来标记当前的下载任务,便于被取消
SDWebImageDownloadToken *subOperationToken = [self.imageDownloader downloadImageWithURL:url options:downloaderOptions progress:progressBlock completed:^(UIImage *downloadedImage, NSData *downloadedData, NSError *error, BOOL finished) {
__strong __typeof(weakOperation) strongOperation = weakOperation;
if (!strongOperation || strongOperation.isCancelled) {
// 1. 如果任务被取消,则什么都不做,避免和其他的completedBlock重复
} else if (error) {
//2. 如果有错误
//2.1 在completedBlock里传入error
[self callCompletionBlockForOperation:strongOperation completion:completedBlock error:error url:url];
//2.2 在错误url名单中添加当前的url
if ( error.code != NSURLErrorNotConnectedToInternet
&& error.code != NSURLErrorCancelled
&& error.code != NSURLErrorTimedOut
&& error.code != NSURLErrorInternationalRoamingOff
&& error.code != NSURLErrorDataNotAllowed
&& error.code != NSURLErrorCannotFindHost
&& error.code != NSURLErrorCannotConnectToHost
&& error.code != NSURLErrorNetworkConnectionLost) {
@synchronized (self.failedURLs) {
[self.failedURLs addObject:url];
}
}
}
else {
//3.1 下载成功,如果需要下载失败后重新下载,则将当前url从失败url名单里移除
if ((options & SDWebImageRetryFailed)) {
@synchronized (self.failedURLs) {
[self.failedURLs removeObject:url];
}
}
//是否可以缓存到磁盘
BOOL cacheOnDisk = !(options & SDWebImageCacheMemoryOnly);
//(即使缓存存在,也要刷新图片) && 缓存图片 && 不存在下载后的图片:不做操作
if (options & SDWebImageRefreshCached && cachedImage && !downloadedImage) {
// Image refresh hit the NSURLCache cache, do not call the completion block
}
//(下载图片成功 && (不是动图||处理动图) && (下载之后,在缓存之前先处理图片)
else if (downloadedImage && (!downloadedImage.images || (options & SDWebImageTransformAnimatedImage)) && [self.delegate respondsToSelector:@selector(imageManager:transformDownloadedImage:withURL:)]) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
UIImage *transformedImage = [self.delegate imageManager:self transformDownloadedImage:downloadedImage withURL:url];
if (transformedImage && finished) {
BOOL imageWasTransformed = ![transformedImage isEqual:downloadedImage];
// pass nil if the image was transformed, so we can recalculate the data from the image
//缓存图片
[self.imageCache storeImage:transformedImage imageData:(imageWasTransformed ? nil : downloadedData) forKey:key toDisk:cacheOnDisk completion:nil];
}
//回调
[self callCompletionBlockForOperation:strongOperation completion:completedBlock image:transformedImage data:downloadedData error:nil cacheType:SDImageCacheTypeNone finished:finished url:url];
});
} else {
//(图片下载成功并结束) 动图
if (downloadedImage && finished) {
[self.imageCache storeImage:downloadedImage imageData:downloadedData forKey:key toDisk:cacheOnDisk completion:nil];
}
[self callCompletionBlockForOperation:strongOperation completion:completedBlock image:downloadedImage data:downloadedData error:nil cacheType:SDImageCacheTypeNone finished:finished url:url];
}
}
//把当前Operation从 表示正在执行的Operation中移除
if (finished) {
[self safelyRemoveOperationFromRunning:strongOperation];
}
}];
//指定SDWebImageCombinedOperation的取消事件,这里可以看出cancleBlock仅仅是用来cancle下载任务的
operation.cancelBlock = ^{
[self.imageDownloader cancel:subOperationToken];
__strong __typeof(weakOperation) strongOperation = weakOperation;
[self safelyRemoveOperationFromRunning:strongOperation];
};
}
else if (cachedImage) {//有缓存图片
__strong __typeof(weakOperation) strongOperation = weakOperation;
[self callCompletionBlockForOperation:strongOperation completion:completedBlock image:cachedImage data:cachedData error:nil cacheType:cacheType finished:YES url:url];
[self safelyRemoveOperationFromRunning:operation];
} else {
// 没有缓存图片,下载也被代理给终止了
__strong __typeof(weakOperation) strongOperation = weakOperation;
[self callCompletionBlockForOperation:strongOperation completion:completedBlock image:nil data:nil error:nil cacheType:SDImageCacheTypeNone finished:YES url:url];
[self safelyRemoveOperationFromRunning:operation];
}
}];
return operation;
}
至此,这个最复杂的函数,也算是一层一层理清楚了,主要是判断有点多,耐心一点还是没有问题的,接下来我们再把这个类里面的一些别的函数简单看一下 ,然后再进入到SDWebImageCache这个很重要的类中去。
这里面的方法大多数都是对 SDWebImageCache
中的方法进行了一层封装,间接的调用 SDWebImageCache
中的方法实现功能
//将图片和他对应的URL存入缓存,url是拿来当key的
- (void)saveImageToCache:(nullable UIImage *)image forURL:(nullable NSURL *)url;
//取消正在加载图片的所有任务
- (void)cancelAll;
//判断当前UIImageView或者button是否有正在加载图片的任务
- (BOOL)isRunning;
//根据URL去异步检查它对应的图片是否被缓存了,不论是磁盘还是内存都算,回调总是在主队列
- (void)cachedImageExistsForURL:(nullable NSURL *)url
completion:(nullable SDWebImageCheckCacheCompletionBlock)completionBlock;
//根据URL区异步仅仅检查图片是否被缓存在了磁盘上
- (void)diskImageExistsForURL:(nullable NSURL *)url
completion:(nullable SDWebImageCheckCacheCompletionBlock)completionBlock;
//根据URL返回缓存的key
- (nullable NSString *)cacheKeyForURL:(nullable NSURL *)url;
SDImageCache
从 SDWebImageManager 中调用的很重要的这查询缓存入手:
先介绍一下SDImageCacheTypeNone所对应的 SDImageCacheType,可以理解为缓存所在位置:
typedef NS_ENUM(NSInteger, SDImageCacheType) {
//没有缓存
SDImageCacheTypeNone,
//缓存在磁盘
SDImageCacheTypeDisk,
//缓存在内存
SDImageCacheTypeMemory
};
- (nullable NSOperation *)queryCacheOperationForKey:(nullable NSString *)key done:(nullable SDCacheQueryCompletedBlock)doneBlock {
//先判断有没有key,如果没有的话,直接走doneBlock
if (!key) {
if (doneBlock) {
doneBlock(nil, nil, SDImageCacheTypeNone);
}
return nil;
}
// 首先先查询内存缓存,内存缓存其实是NSCache,虽然这里封装了一层但其实就是调用objectForKey那个方法
UIImage *image = [self imageFromMemoryCacheForKey:key];
if (image) {
NSData *diskData = nil;
//如果是gif,就拿到data,后面要传到doneBlock里。不是gif就传nil
if ([image isGIF]) {
diskData = [self diskImageDataBySearchingAllPathsForKey:key];
}
if (doneBlock) {
doneBlock(image, diskData, SDImageCacheTypeMemory);
}
//因为图片有缓存可供直接使用,所以不用实例化NSOperation来进行耗时的磁盘查询,直接范围nil
return nil;
}
//如果内存中没找到缓存,那肯定只有查磁盘了,接下来进行磁盘缓存查询,实例化NSOperation
NSOperation *operation = [NSOperation new];
dispatch_async(self.ioQueue, ^{
//self.ioQueue作者自己创建了一个查询队列,在进行异步查询的时候,因为函数下面的同步return operation
//操作可能先返回出去,于是就有可能先返回出去的operation被cancle掉,因此作者在这里加了一层判断,看是否被取消掉了,这层考虑非常严谨
if (operation.isCancelled) {
// 如果被cancle掉了,那么什么都不用做了
return;
}
//放到autoreleasepool中去查磁盘缓存,降低内存峰值
@autoreleasepool {
NSData *diskData = [self diskImageDataBySearchingAllPathsForKey:key];
UIImage *diskImage = [self diskImageForKey:key];
//在磁盘中找到了图片 && 要缓存到内存中
if (diskImage && self.config.shouldCacheImagesInMemory) {
NSUInteger cost = SDCacheCostForImage(diskImage);
//缓存到内存中
[self.memCache setObject:diskImage forKey:key cost:cost];
}
//完成回调
if (doneBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
doneBlock(diskImage, diskData, SDImageCacheTypeDisk);
});
}
}
});
return operation;
}
磁盘清理缓存逻辑概述:先清除过期的文件;然后判断此时的缓存文件大小是否小于设置的最大大小。若大于最大大小,则进行第二轮的清扫,清扫到缓存文件大小为设置的最大大小的一半。
dispatch_async(self.ioQueue, ^{
NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES]; // 缓存文件的路径
// 将要获取文件的3个属性(URL是否为目录;内容最后更新日期;文件总的分配大小)
NSArray *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey,NSURLTotalFileAllocatedSizeKey];
// 使用目录枚举器获取缓存文件有用的属性
NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
includingPropertiesForKeys:resourceKeys
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:NULL];
NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
NSMutableDictionary *cacheFiles = [NSMutableDictionary dictionary];
NSUInteger currentCacheSize = 0;
// 枚举缓存目录的所有文件,此循环有两个目的:
// 1.清除超过过期日期的文件;
// 2.为下面根据大小清理磁盘做准备
NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
for (NSURL *fileURL in fileEnumerator) {
NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:NULL]; // 传入想获得的该URL路径文件的属性数组,得到这些属性字典。
// 若该URL是目录,则跳过。
if ([resourceValues[NSURLIsDirectoryKey] boolValue]) {
continue;
}
// 清除过期文件
NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
[urlsToDelete addObject:fileURL]; // 把过期的文件url暂时先置于urlsToDelete数组中
continue;
}
// 计算文件总的大小并保存保留下来的文件的引用。
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize += [totalAllocatedSize unsignedIntegerValue];
[cacheFiles setObject:resourceValues forKey:fileURL];
}
for (NSURL *fileURL in urlsToDelete) {
[_fileManager removeItemAtURL:fileURL error:nil];
}
// 如果剩下的磁盘缓存文件仍然大于我们设置的最大大小,则要执行以大小为基础的第二轮清除
if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
// 此轮清理的目标是最大缓存的一半
const NSUInteger desiredCacheSize = self.maxCacheSize / 2;
// 以剩下的文件最后更新时间排序(最老的最先被清除)
NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
usingComparator:^NSComparisonResult(id obj1, id obj2) {
return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
}
// 删除已排好序的文件,直到达到最大缓存限制的一半
for (NSURL *fileURL in sortedFiles) {
if ([_fileManager removeItemAtURL:fileURL error:nil]) {
NSDictionary *resourceValues = cacheFiles[fileURL];
NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
currentCacheSize -= [totalAllocatedSize unsignedIntegerValue];
if (currentCacheSize < desiredCacheSize) {
break;
}
}
}
}
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
介绍完了 SDImageCache 在整个流程中的主要任务之后,咱们再来详细看看SDImageCache这个类的其他的一些东西:
属性
//内存缓存对应的NSCache
@property (strong, nonatomic, nonnull) NSCache *memCache;
//磁盘缓存路径
@property (strong, nonatomic, nonnull) NSString *diskCachePath;
//自定义的队列,查询磁盘缓存的操作就放在这个队列里
@property (SDDispatchQueueSetterSementics, nonatomic, nullable) dispatch_queue_t ioQueue;
//可以理解为cache的配置信息,下面再讲
@property (nonatomic, nonnull, readonly) SDImageCacheConfig *config;
//支持缓存的最大空间,不过据说设置这个也不是绝对的,详情看NSCache就知道了
@property (assign, nonatomic) NSUInteger maxMemoryCost;
//支持缓存的最大对象数,默认不限制
@property (assign, nonatomic) NSUInteger maxMemoryCountLimit;
/* ================ SDImageCacheConfig =====================*/
//是否解压下载的图片,拿空间换时间,默认yes
@property (assign, nonatomic) BOOL shouldDecompressImages;
//静止iCloud备份,默认yes
@property (assign, nonatomic) BOOL shouldDisableiCloud;
//允许使用内存来缓存图片,默认yes
@property (assign, nonatomic) BOOL shouldCacheImagesInMemory;
//缓存的最长时间 默认一周
@property (assign, nonatomic) NSInteger maxCacheAge;
//缓存的最大值,单位byte
@property (assign, nonatomic) NSUInteger maxCacheSize;
方法:
//磁盘缓存路径,默认是在沙盒Cache下面的 com.hackemist.SDWebImageCache文件夹
- (nullable NSString *)makeDiskCachePath:(nonnull NSString*)fullNamespace;
//异步缓存图片到内存与磁盘
- (void)storeImage:(nullable UIImage *)image
imageData:(nullable NSData *)imageData
forKey:(nullable NSString *)key
toDisk:(BOOL)toDisk
completion:(nullable SDWebImageNoParamsBlock)completionBlock;
//异步检查磁盘中是否有缓存
- (void)diskImageExistsWithKey:(nullable NSString *)key completion:(nullable SDWebImageCheckCacheCompletionBlock)completionBlock;
//查询缓存,就是Manager中使用的那个
- (nullable NSOperation *)queryCacheOperationForKey:(nullable NSString *)key done:(nullable SDCacheQueryCompletedBlock)doneBlock;
....
....
....
其余的很多方法都在源码中都有很详细的注释且理解比较容易,这里就不做过多的介绍,读者可以自行阅读
问题:
1.在缓存图片的时候如何计算图片的占用空间的?
//SDImageCache.m
FOUNDATION_STATIC_INLINE NSUInteger SDCacheCostForImage(UIImage *image) {
#if SD_MAC
return image.size.height * image.size.width;
#elif SD_UIKIT || SD_WATCH
return image.size.height * image.size.width * image.scale * image.scale;
#endif
}
很明显在iOS上是用图片的 长 * 宽 * 图片比例 * 图片比例
2.什么时候自动清理缓存呢?(手动调用清理接口的另当别论)
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(clearMemory)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(deleteOldFiles)
name:UIApplicationWillTerminateNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(backgroundDeleteOldFiles)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
很明显可以看到,在初始化 SDImageCache 的时候注册了这么几个通知,所以结论如下:
-
当收到内存警告时,会清理所有内存缓存
-
程序终止或者进入后台时,删除磁盘上过期的缓存
2.内存的key是什么,存在磁盘上的文件名是什么?
内存缓存的key直接就是图片的URL,而存磁盘上的缓存文件名是根据URL生成的MD5
3.在存磁盘的时候如何区分图片的文件格式?
==============================< NSData+ImageContentType.h >==================
+ (SDImageFormat)sd_imageFormatForImageData:(nullable NSData *)data {
if (!data) {
return SDImageFormatUndefined;
}
uint8_t c;
[data getBytes:&c length:1];
switch (c) {
case 0xFF:
return SDImageFormatJPEG;
case 0x89:
return SDImageFormatPNG;
case 0x47:
return SDImageFormatGIF;
case 0x49:
case 0x4D:
return SDImageFormatTIFF;
case 0x52:
// R as RIFF for WEBP
if (data.length < 12) {
return SDImageFormatUndefined;
}
NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
return SDImageFormatWebP;
}
}
return SDImageFormatUndefined;
}
很明显可以看到是取服务端返回的数据的第一个字节来做判断
4.哪些事情是放在这个自定义的 ioQueue 上做的?
- 清理磁盘缓存
- 计算磁盘缓存的大小的时候
- 查询磁盘缓存的时候
- 初始化 NSFileManager 的时候
5.初始化NSFileManager的时候为什么要放在自己的串行队列,然后同步执行呢?为什么要自己去 new 一个Manager实例而不是使用 NSFileManager 的defaultManager呢?
解释一下:同步放在串行队列中的任务其实目的就是为了线程同步,相当于为block中的代码进行加同步锁的操作;在defaultManager文档中也有提到,如果你想使用 NSFileManagerDelegate 来回调一些相关操作通知的话,组好是自己创建和初始化一个 NSFileManager 的实例,可是SD貌似很明显没用到相关delegate;通过多方查找:终于找到一篇文章(此处多谢鹏大神和我一起探究),其实问题就是 NSFileManager 的 defaultManager并不是线程安全的,所以作者才会自己实例化对象,并把它放在同步线程中;
NSFileManager至于为什么要通过这种方式来进行线程同步,大家其实可以参考 《Effective Objective - C 2.0》中的第41条,说白了其实就是 @synchronized()的效率没有这个高啦。
总结,到这里就把 SDWebImageManager 和 SDImageCache 都大致理了一遍,在下篇文章咱们主要去分析下载模块即SDWebImageDownloader涉及到的一些东西
网友评论