码迷,mamicode.com
首页 > Web开发 > 详细

【原】SDWebImage源码阅读(五)

时间:2015-12-27 16:19:01      阅读:257      评论:0      收藏:0      [点我收藏+]

标签:

【原】SDWebImage源码阅读(五)

本文转载请注明出处 —— polobymulberry-博客园

1. 前言


前面的代码并没有特意去讲SDWebImage的缓存机制,主要是想单独开一章节专门讲解缓存。之前我们也遇到一些缓存的属性和方法,比如storeImage、queryDiskCacheForKey、memCache等等。

SDWebImage的缓存分为两个部分,一个内存缓存,使用NSCache实现,另一个就是硬盘缓存(disk),使用NSFileManager实现。

不过这么多函数,我们先从哪看起呢?就从给我印象最深的queryDiskCacheForKey看起。主要是因为这个函数返回的是一个NSOperation。和SDWebImageManager关系紧密,尤其是和SDWebImageCombinedOperation的cacheOperation,直接就是作为其返回值。

2. queryDiskCacheForKey


之前简单的介绍了一下queryDiskCacheForKey函数实现。具体的细节并没有介绍。尤其是对queryDiskCacheForKey中的done block中有关cache的部分没有细说。这里doneBlock先不讨论,先讨论queryDiskCacheForKey中的cache部分。

最先看到的关于cache的部分:

// 首先根据key(一般指的是图片的url)去内存缓存获取image
UIImage *image = [self imageFromMemoryCacheForKey:key];

这个imageFromMemoryCacheForKey的具体实现:

// 简单的封装了NSCache的objectForKey方法
- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key {
    return [self.memCache objectForKey:key];
}

这里,很自然就会想到,objectForKey的对应方法是setObject:forKey:。所以我们搜一下看看SDWebImage在哪里使用了setObject:forKey:

总共有三处地方:

  1. queryDiskCacheForKey:done:
  2. imageFromDiskCacheForKey:key
  3. storeImage:recalculateFromImage:imageData:forKey:toDisk

2.1 queryDiskCacheForKey:done:

// 获取到disk上缓存的image
UIImage *diskImage = [self diskImageForKey:key];
// 如果diskImage存在,并且需要使用memory cache
// 就将diskImage缓存到memory cache中
if (diskImage && self.shouldCacheImagesInMemory) {
    // cost 被用来计算缓存中所有对象的代价。当内存受限或者所有缓存对象的总代价超过了最大允许的值时,缓存会移除其中的一些对象。
    // 通常,精确的 cost 应该是对象占用的字节数。
    NSUInteger cost = SDCacheCostForImage(diskImage);
    [self.memCache setObject:diskImage forKey:key cost:cost];
}

这里只有SDCacheCostForImage函数需要细看一下,该函数本质就是计算diskImage所要占用的字节数:

// C语言函数
// FOUNDATION_STATIC_INLINE表示static __inline__,属于runtime范畴
FOUNDATION_STATIC_INLINE NSUInteger SDCacheCostForImage(UIImage *image) {
    // 这里我觉得这样写不是很好,如果这样写就更直观了
    // return (height * scale) * (width * scale)
    return image.size.height * image.size.width * image.scale * image.scale;
}

2.2 imageFromDiskCacheForKey:key

用途和上面一样,就是从disk中获取的image,还需更新到内存缓存中。

2.3 storeImage:recalculateFromImage:imageData:forKey:toDisk

其实也是和上面一样,都是为了更新到内存缓存中:

// 如果可以使用内存缓存
if (self.shouldCacheImagesInMemory) {
    NSUInteger cost = SDCacheCostForImage(image);
    [self.memCache setObject:image forKey:key cost:cost];
}

这里比较关键的其实是storeImage这个函数的使用场景:

  • 在SDWebImageManager中的downloadImageWithURL中,成功下载到图片downloadedImage后(或者进行了transform)使用该函数对image进行缓存

我们来具体分析下这个函数。函数中的前两个if语句比较好理解,也解释过了。主要集中在if(toDisk)这个语句中,而toDisk为YES表示应该是要往disk memory中存储。

整个语句块是放在一个ioQueue的dispatch_queue_t中的:

dispatch_async(self.ioQueue, ^{
    // ......
});

这个ioQueue,我们从字面上理解,就是一个磁盘io的dispatch_queue_t。说简单点,就是每个下载来的图片,需要进行磁盘io的过程都放在ioQueue中执行。

剩下的部分主要做了两件事:

  • 1.根据imageData和image生成待存储的data
  • 2.利用NSFileManager将待存储的data存储起来
// 构建一个data,用来存储到disk中,默认值为imageData
NSData *data = imageData;

// 如果image存在,但是需要重新计算(recalculate)或者data为空
// 那就要根据image重新生成新的data
// 不过要是连image也为空的话,那就别存了
if (image && (recalculate || !data)) {
#if TARGET_OS_IPHONE
    // 我们需要判断image是PNG还是JPEG
    // PNG的图片很容易检测出来,因为它们有一个特定的标示 (http://www.w3.org/TR/PNG-Structure.html)
    // PNG图片的前8个字节不许符合下面这些值(十进制表示)
    // 137 80 78 71 13 10 26 10
    
    // 如果imageData为空l (举个例子,比如image在下载后需要transform,那么就imageData就会为空)
    // 并且image有一个alpha通道, 我们将该image看做PNG以避免透明度(alpha)的丢失(因为JPEG没有透明色)
    int alphaInfo = CGImageGetAlphaInfo(image.CGImage); // 获取image中的透明信息
    // 该image中确实有透明信息,就认为image为PNG
    BOOL hasAlpha = !(alphaInfo == kCGImageAlphaNone ||
                      alphaInfo == kCGImageAlphaNoneSkipFirst ||
                      alphaInfo == kCGImageAlphaNoneSkipLast);
    BOOL imageIsPng = hasAlpha;
    
    // 但是如果我们已经有了imageData,我们就可以直接根据data中前几个字节判断是不是PNG
    if ([imageData length] >= [kPNGSignatureData length]) {
        // ImageDataHasPNGPreffix就是为了判断imageData前8个字节是不是符合PNG标志
        imageIsPng = ImageDataHasPNGPreffix(imageData);
    }
    // 如果image是PNG格式,就是用UIImagePNGRepresentation将其转化为NSData,否则按照JPEG格式转化,并且压缩质量为1,即无压缩
    if (imageIsPng) {
        data = UIImagePNGRepresentation(image);
    }
    else {
        data = UIImageJPEGRepresentation(image, (CGFloat)1.0);
    }
#else
    // 当然,如果不是在iPhone平台上,就使用下面这个方法。不过不在我们研究范围之内
    data = [NSBitmapImageRep representationOfImageRepsInArray:image.representations usingType: NSJPEGFileType properties:nil];
#endif
}

// 获取到需要存储的data后,下面就要用fileManager进行存储了
if (data) {
    // 首先判断disk cache的文件路径是否存在,不存在的话就创建一个
    // disk cache的文件路径是存储在_diskCachePath中的
    if (![_fileManager fileExistsAtPath:_diskCachePath]) {
        [_fileManager createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:NULL];
    }
    
    // 根据image的key(一般情况下理解为image的url)组合成最终的文件路径
    // 上面那个生成的文件路径只是一个文件目录,就跟/cache/images/img1.png和cache/images/的区别一样
    // defaultCachePathForKey后面会详解
    NSString *cachePathForKey = [self defaultCachePathForKey:key];
    // 这个url可不是网络端的url,而是file在系统路径下的url
    // 比如/foo/bar/baz --------> file:///foo/bar/baz
    NSURL *fileURL = [NSURL fileURLWithPath:cachePathForKey];
    // 根据存储的路径(cachePathForKey)和存储的数据(data)将其存放到iOS的文件系统
    [_fileManager createFileAtPath:cachePathForKey contents:data attributes:nil];
    
    // 如果不使用iCloud进行备份,就使用NSURLIsExcludedFromBackupKey
    if (self.shouldDisableiCloud) {
        [fileURL setResourceValue:[NSNumber numberWithBool:YES] forKey:NSURLIsExcludedFromBackupKey error:nil];
    }
}

2.3.1 defaultCachePathForKey

// 简单封装了cachePathForKey:inPath
- (NSString *)defaultCachePathForKey:(NSString *)key {
    return [self cachePathForKey:key inPath:self.diskCachePath];
}

// cachePathForKey:inPath
- (NSString *)cachePathForKey:(NSString *)key inPath:(NSString *)path {
    // 根据传入的key创建最终要存储时的文件名
    NSString *filename = [self cachedFileNameForKey:key];
    // 将存储的文件路径和文件名绑定在一起,作为最终的存储路径
    return [path stringByAppendingPathComponent:filename];
}

// cachedFileNameForKey:
- (NSString *)cachedFileNameForKey:(NSString *)key {
    const char *str = [key UTF8String];
    if (str == NULL) {
        str = "";
    }
    // 使用了MD5进行加密处理
    // 开辟一个16字节(128位:md5加密出来就是128bit)的空间
    unsigned char r[CC_MD5_DIGEST_LENGTH];
    // 官方封装好的加密方法
    // 把str字符串转换成了32位的16进制数列(这个过程不可逆转) 存储到了r这个空间中
    CC_MD5(str, (CC_LONG)strlen(str), r);
    // 最终生成的文件名就是 "md5码"+".文件类型"
    NSString *filename = [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%@",
                          r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10],
                          r[11], r[12], r[13], r[14], r[15], [[key pathExtension] isEqualToString:@""] ? @"" : [NSString stringWithFormat:@".%@", [key pathExtension]]];

    return filename;
}

2.4 小结

上面的几个分析基本上已经把内存缓存的存储和磁盘缓存的存储讲了一下。

  • 内存缓存的存储主要就是使用NSCache自带的setObject:forKey:及其衍生方法
  • 硬盘缓存的存储主要是使用NSFileManager进行存储

已经讲完存储了,那么就不得不提及clear缓存。

3. clear缓存


我们简单看一下clear的方式,发现以下几个函数需要注意:

  1. removeImageForKeyfromDisk:withCompletion: // 异步地将image从缓存(内存缓存以及可选的磁盘缓存)中移除
  2. clearMemory // 清楚内存缓存上的所有image
  3. clearDisk // 清除磁盘缓存上的所有image
  4. cleanDisk // 清除磁盘缓存上过期的image

3.1 removeImageForKeyfromDisk:withCompletion:

这个函数其实是removeImageForKey:等一系列函数的基础,类似sd_setImageWithURL:placeholderImage:options:progress:completed:函数。

该函数主要是根据key来删除对应缓存image:

- (void)removeImageForKey:(NSString *)key fromDisk:(BOOL)fromDisk withCompletion:(SDWebImageNoParamsBlock)completion {
    
    if (key == nil) {
        return;
    }

    // shouldCacheImagesInMemory为YES表示该图片会缓存到了内存
    // 既然缓存到了内存,就要先将内存缓存中的image移除
    // 使用的是NSCache的removeObjectForKey:
    if (self.shouldCacheImagesInMemory) {
        [self.memCache removeObjectForKey:key];
    }

    // 如果要删除磁盘缓存中的image
    if (fromDisk) {
        // 有关io的部分,都要放在ioQueue中
        dispatch_async(self.ioQueue, ^{
            // 磁盘缓存移除使用的是NSFileManager的removeItemAtPath:error
            [_fileManager removeItemAtPath:[self defaultCachePathForKey:key] error:nil];
            // 如果用户实现了completion了,就在主线程调用completion()
            if (completion) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    completion();
                });
            }
        });
    } else if (completion){ // 如果用户实现了completion了,就在主线程调用completion()
        completion();
    }
    
}

3.2 clearMemory

简单地调用NSCache的removeAllObjects。

3.3 clearDisk

封装了clearDiskOnCompletion:函数:

- (void)clearDisk {
    [self clearDiskOnCompletion:nil];
}

clearDiskOnCompletion:

- (void)clearDiskOnCompletion:(SDWebImageNoParamsBlock)completion
{
    dispatch_async(self.ioQueue, ^{
        // 先将存储在diskCachePath中缓存全部移除,然后新建一个空的diskCachePath
        [_fileManager removeItemAtPath:self.diskCachePath error:nil];
        [_fileManager createDirectoryAtPath:self.diskCachePath
                withIntermediateDirectories:YES
                                 attributes:nil
                                      error:NULL];
        // 如果实现了completion,就在主线程中调用
        if (completion) {
            dispatch_async(dispatch_get_main_queue(), ^{
                completion();
            });
        }
    });
}

3.4 cleanDisk

简单封装了cleanDiskWithCompletionBlock:

- (void)cleanDisk {
    [self cleanDiskWithCompletionBlock:nil];
}

cleanDiskWithCompletionBlock:

// 实现了一个简单的缓存清除策略:清除修改时间最早的file
- (void)cleanDiskWithCompletionBlock:(SDWebImageNoParamsBlock)completionBlock {
    dispatch_async(self.ioQueue, ^{
        // 这两个变量主要是为了下面生成NSDirectoryEnumerator准备的
        // 一个是记录遍历的文件目录,一个是记录遍历需要预先获取文件的哪些属性
        NSURL *diskCacheURL = [NSURL fileURLWithPath:self.diskCachePath isDirectory:YES];
        NSArray *resourceKeys = @[NSURLIsDirectoryKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey];

        // 递归地遍历diskCachePath这个文件夹中的所有目录,此处不是直接使用diskCachePath,而是使用其生成的NSURL
        // 此处使用includingPropertiesForKeys:resourceKeys,这样每个file的resourceKeys对应的属性也会在遍历时预先获取到
        // NSDirectoryEnumerationSkipsHiddenFiles表示不遍历隐藏文件
        NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtURL:diskCacheURL
                                                   includingPropertiesForKeys:resourceKeys
                                                                      options:NSDirectoryEnumerationSkipsHiddenFiles
                                                                 errorHandler:NULL];
        // 获取文件的过期时间,SDWebImage中默认是一个星期
        // 不过这里虽然称*expirationDate为过期时间,但是实质上并不是这样。
        // 其实是这样的,比如在2015/12/12/00:00:00最后一次修改文件,对应的过期时间应该是
        // 2015/12/19/00:00:00,不过现在时间是2015/12/27/00:00:00,我先将当前时间减去1个星期,得到
        // 2015/12/20/00:00:00,这个时间才是我们函数中的expirationDate。
        // 用这个expirationDate和最后一次修改时间modificationDate比较看谁更晚就行。
        NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:-self.maxCacheAge];
        // 用来存储对应文件的一些属性,比如文件所需磁盘空间
        NSMutableDictionary *cacheFiles = [NSMutableDictionary dictionary];
        // 记录当前已经使用的磁盘缓存大小
        NSUInteger currentCacheSize = 0;

        // 在缓存的目录开始遍历文件.  此次遍历有两个目的:
        //
        //  1. 移除过期的文件
        //  2. 同时存储每个文件的属性(比如该file是否是文件夹、该file所需磁盘大小,修改时间)
        NSMutableArray *urlsToDelete = [[NSMutableArray alloc] init];
        for (NSURL *fileURL in fileEnumerator) {
            NSDictionary *resourceValues = [fileURL resourceValuesForKeys:resourceKeys error:NULL];

            // 当前扫描的是目录,就跳过
            if ([resourceValues[NSURLIsDirectoryKey] boolValue]) {
                continue;
            }

            // 移除过期文件
            // 这里判断过期的方式:对比文件的最后一次修改日期和expirationDate谁更晚,如果expirationDate更晚,就认为该文件已经过期,具体解释见上面
            NSDate *modificationDate = resourceValues[NSURLContentModificationDateKey];
            if ([[modificationDate laterDate:expirationDate] isEqualToDate:expirationDate]) {
                [urlsToDelete addObject:fileURL];
                continue;
            }

            // 计算当前已经使用的cache大小,
            // 并将对应file的属性存到cacheFiles中
            NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
            currentCacheSize += [totalAllocatedSize unsignedIntegerValue];
            [cacheFiles setObject:resourceValues forKey:fileURL];
        }
        
        for (NSURL *fileURL in urlsToDelete) {
            // 根据需要移除文件的url来移除对应file
            [_fileManager removeItemAtURL:fileURL error:nil];
        }

        // 如果我们当前cache的大小已经超过了允许配置的缓存大小,那就删除已经缓存的文件。
        // 删除策略就是,首先删除修改时间更早的缓存文件
        if (self.maxCacheSize > 0 && currentCacheSize > self.maxCacheSize) {
            // 直接将当前cache大小降到允许最大的cache大小的一般
            const NSUInteger desiredCacheSize = self.maxCacheSize / 2;

            // 根据文件修改时间来给所有缓存文件排序,按照修改时间越早越在前的规则排序
            NSArray *sortedFiles = [cacheFiles keysSortedByValueWithOptions:NSSortConcurrent
                                                            usingComparator:^NSComparisonResult(id obj1, id obj2) {
                                                                return [obj1[NSURLContentModificationDateKey] compare:obj2[NSURLContentModificationDateKey]];
                                                            }];

            // 每次删除file后,就计算此时的cache的大小
            // 如果此时的cache大小已经降到期望的大小了,就停止删除文件了
            for (NSURL *fileURL in sortedFiles) {
                if ([_fileManager removeItemAtURL:fileURL error:nil]) {
                    // 获取该文件对应的属性
                    NSDictionary *resourceValues = cacheFiles[fileURL];
        // 根据resourceValues获取该文件所需磁盘空间大小
                    NSNumber *totalAllocatedSize = resourceValues[NSURLTotalFileAllocatedSizeKey];
        // 计算当前cache大小
                    currentCacheSize -= [totalAllocatedSize unsignedIntegerValue];

                    if (currentCacheSize < desiredCacheSize) {
                        break;
                    }
                }
            }
        }
        // 如果有completionBlock,就在主线程中调用
        if (completionBlock) {
            dispatch_async(dispatch_get_main_queue(), ^{
                completionBlock();
            });
        }
    });
}

4. init方法

讲完了一些具体的缓存方法。反过来,我们研究下SDImageCache的初始化。因为之前的很多方法中的参数都是已经在init中设置好了。另外一个原因是SDImageCache使用了单例模式。所以相对来说,init方法还是很重要的。

我们先从单例模式看起,正好学习下单例模式的正确写法:

// SDImageCache使用的是单例模式
+ (SDImageCache *)sharedImageCache {
    static dispatch_once_t once;
    static id instance;
    dispatch_once(&once, ^{
        // new = alloc + init
        instance = [self new];
    });
    return instance;
}

4.1 initWithNamespace:

接着我们来看看init方法,原来封装了initWithNamespace:方法,并且namespace的名称为@"default"。

- (id)initWithNamespace:(NSString *)ns {
    // iOS使用的是沙盒机制,此处makeDiskCachePath就是获取Cache目录,并在Cache目录下创建default目录
    // 比如我的mac上就显示/Users/poloby/Library/Developer/CoreSimulator/Devices/4404872F-4DDD-4AEA-AAD3-71BA1931D4C1/data/Containers/Data/Application/9C7E5D14-FBF0-41F1-A533-E8ACC59FCBAC/Library/Caches/default
    // 后面详解
    NSString *path = [self makeDiskCachePath:ns];
    // 最终的初始化,后面详解
return [self initWithNamespace:ns diskCacheDirectory:path];
}

4.1.1 makeDiskCachePath:

-(NSString *)makeDiskCachePath:(NSString*)fullNamespace{
    // 获取当前用户应用下的Caches目录
    // 返回了一个包含用户Caches目录作为第一元素的数组,所以底下用的是paths[0]
    // 即/Users/poloby/Library/Developer/CoreSimulator/Devices/4404872F-4DDD-4AEA-AAD3-71BA1931D4C1/data/Containers/Data/Application/9C7E5D14-FBF0-41F1-A533-E8ACC59FCBAC/Library/Caches/
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
   // 在Caches目录下构建一个fullNamespace目录,此处默认是default目录
    return [paths[0] stringByAppendingPathComponent:fullNamespace];
}

4.1.2 initWithNamespace:diskCacheDirectory:

(id)initWithNamespace:(NSString *)ns diskCacheDirectory:(NSString *)directory {
    if ((self = [super init])) {
        // 再给Caches/default/后面加上fullNamspace
        // 最终可能获得的diskCachePath可能为
        NSString *fullNamespace = [@"com.hackemist.SDWebImageCache." stringByAppendingString:ns];

        // 初始化kPNGSignatureData为PNG前8字节的标志:{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
        // 用于ImageDataHasPNGPreffix这个C函数中,判断该data是不是PNG格式
        kPNGSignatureData = [NSData dataWithBytes:kPNGSignatureBytes length:8];

        // 创建名为com.hackemist.SDWebImageCache的IO的串行队列
        _ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);

        // cache存储的最长时间为60 * 60 * 24 * 7,即一个星期
        _maxCacheAge = kDefaultCacheMaxCacheAge;

        // 注意此处不是直接使用[[NSCache alloc] init]进行初始化的,而是使用了一个AutoPurgeCache
        // AutoPurgeCache和NSCache不同之处在于,如果AutoPurgeCache收到一个内存警告,就会自动释放内存,调用NSCache的removeAllObjects
        _memCache = [[AutoPurgeCache alloc] init];
        _memCache.name = fullNamespace;

        // 初始化disk cache,一般情况下directory,除非你把Caches删除了
        if (directory != nil) {
            // 最终结果是/Users/poloby/Library/Developer/CoreSimulator/Devices/4404872F-4DDD-4AEA-AAD3-71BA1931D4C1/data/Containers/Data/Application/9C7E5D14-FBF0-41F1-A533-E8ACC59FCBAC/Library/Caches/default/com.hackemist.SDWebImageCache.default
            _diskCachePath = [directory stringByAppendingPathComponent:fullNamespace];
        } else {
            // 如果没有找到Caches目录,或者新建default目录失败。就重新使用makeCachePath新建一个缓存目录
            NSString *path = [self makeDiskCachePath:ns];
            _diskCachePath = path;
        }

        // 默认需要解压缩图片
        _shouldDecompressImages = YES;
       // 新建一个NSFileManager也是放在ioQueue中的
        dispatch_sync(_ioQueue, ^{
            _fileManager = [NSFileManager new];
        });

#if TARGET_OS_IPHONE
        // 订阅了app可能发生的时间
        // 出现内存警告(UIApplicationDidReceiveMemoryWarningNotification),调用clearMemory
        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(clearMemory)
                                                     name:UIApplicationDidReceiveMemoryWarningNotification
                                                   object:nil];
        // 程序终止(UIApplicationWillTerminateNotification),调用cleanDisk
        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(cleanDisk)
                                                     name:UIApplicationWillTerminateNotification
                                                   object:nil];
        // 程序进入后台运行(UIApplicationDidEnterBackgroundNotification),调用backgroundCleanDisk
        // backgroundCleanDisk就不赘述了,其实现了在后台注册了cleanDiskWithCompletionBlock函数来处理后台的磁盘缓存
        [[NSNotificationCenter defaultCenter] addObserver:self
                                                 selector:@selector(backgroundCleanDisk)
                                                     name:UIApplicationDidEnterBackgroundNotification
                                                   object:nil];
#endif
    }

    return self;
}

5. 总结

SDImageCache部分有些地方处理还是很简单的,比如清除缓存策略。如果有大牛实现LRU策略就更好了。

SDWebImage源码解析到此为止,当然还有一些模块没有解析,比如MKAnnotationView+WebCache.h、UIButton+WebCache.h、UIImageView+HighlightedWebCache.h以及一些模块的某些函数也没细讲。不过相信大家举一反三的能力还是很强的。

如果大家对我的文章有什么疑问,可以留言或私信,欢迎交流。

6. 参考文章


【原】SDWebImage源码阅读(五)

标签:

原文地址:http://www.cnblogs.com/polobymulberry/p/5016439.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!