一、SDWebImage 如何保证UI操作放在主线程中执行?
在SDWebImage的SDWebImageCompat.h中有这样一个宏定义,用来保证主线程操作,为什么要这样写?
// SDWebImageCompat.h 中
#ifndef dispatch_main_async_safe
#define dispatch_main_async_safe(block)\
if (strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), dispatch_queue_get_label(dispatch_get_main_queue())) == 0) {\
block();\
} else {\
dispatch_async(dispatch_get_main_queue(), block);\
}
#endif
在此之前见到最多的是这样的:
#define dispatch_main_async_safe(block)\
if ([NSThread isMainThread]) {\
block();\
} else {\
dispatch_async(dispatch_get_main_queue(), block);\
}
对比两段代码可以发现前者有两个地方改变了,一是多了 #ifndef
,二是判断条件改变了。
显然,增加 #ifndef
是为了提高代码的严谨,防止重复定义 dispatch_main_async_safe
。
关于判断条件的改变的原因则是复杂得多了,可参考文档
GCD's Main Queue vs. Main Thread
Queues are not bound to any specific thread
分析:如何判断当前是否在main thread?
最简单的方法
检查我们当前在主线程上执行的最简单的方法是使用[NSThread isMainThread] - GCD缺少一个类似的方便的API来检查我们是否在主队列上运行,因此许多开发人员使用了NSThread API。如下:
if ([NSThread isMainThread]) {
block();
} else {
dispatch_async(dispatch_get_main_queue(), block);
}
这在大多数情况下是有效的,直到它出现了异常。下面是关于ReactiveCocoa repo问题的摘录:
ReactiveCocoa issue
image
潜在的问题是VektorKit API正在检查是否在主队列上调用它,而不是检查它在主线程上运行。
虽然每个应用程序都只有一个主线程,但是在这个主线程上执行许多不同的队列是可能的。
如果库(如VektorKit)依赖于在主队列上检查执行,那么从主线程上执行的非主队列调用API将导致问题。也就是说,如果在主线程执行非主队列调度的API,而这个API需要检查是否由主队列上调度,那么将会出现问题。
更安全的方法一
从技术上讲,我认为这是一个 MapKit / VektorKit
漏洞,苹果的UI框架通常保证在从主线程调用时正确工作,没有任何文档提到需要在主队列上执行代码。
但是,现在我们知道某些api不仅依赖于主线程上的运行,而且还依赖于主队列,因此检查当前队列而不是检查当前线程更安全。
检查当前队列还可以更好地利用GCD为线程提供的抽象。从技术上讲,我们不应该知道/关心主队列是一种总是绑定到主线程的特殊队列。
不幸的是,GCD没有一个非常方便的API来检查我们当前正在运行的队列(这很可能是许多开发人员首先使用NSThread.isMainThread()的原因)。
我们需要使用 dispatch_queue_set_specific
函数来将键值对与主队列相关联;稍后,我们可以使用 dispatch_queue_get_specific
来检查键和值的存在。
- (void)function {
static void *mainQueueKey = "mainQueueKey";
dispatch_queue_set_specific(dispatch_get_main_queue(), mainQueueKey, &mainQueueKey, NULL);
if (dispatch_get_specific(mainQueueKey)) {
// do something in main queue
//通过这样判断,就可以真正保证(我们在不主动搞事的情况下),任务一定是放在主队列中的
} else {
// do something in other queue
}
}
更安全的方法二 (SDWebImage使用的方法)
我们知道在使用 GCD 创建一个 queue 的时候会指定 queue_label,可以理解为队列名,就像下面:
dispatch_queue_t myQueue = dispatch_queue_create("com.apple.threadQueue", DISPATCH_QUEUE_SERIAL);
而第一个参数就是 queue_label,根据官方文档解释,这个queueLabel 是唯一的,所以SDWebImage就采用了这个方式
//取得当前队列的队列名
dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL)
//取得主队列的队列名
dispatch_queue_get_label(dispatch_get_main_queue())
然后通过 strcmp 函数进行比较,如果为0 则证明当前队列就是主队列。
SDWebImage中的实例 :判断当前是否是IOQueue
- (void)checkIfQueueIsIOQueue {
const char *currentQueueLabel = dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL);
const char *ioQueueLabel = dispatch_queue_get_label(self.ioQueue);
if (strcmp(currentQueueLabel, ioQueueLabel) != 0) {
NSLog(@"This method should be called from the ioQueue");
}
}
结论
SDWebImage 就是从判断是否在主线程执行改为判断是否由主队列上调度。而由于主队列是一个串行队列,无论任务是异步同步都不会开辟新线程,所以当前队列是主队列等价于当前在主线程上执行。可以这样说,在主队列调度的任务肯定在主线程执行,而在主线程执行的任务不一定是由主队列调度的。
二、SDWebImage 的最大并发数 和 超时时长
// SDWebImageDownloader.m -initWithSessionConfiguration:
_downloadQueue.maxConcurrentOperationCount = 6;
_downloadTimeout = 15.0;
//负责下载的类SDWebImageDownloader,多任务控制。
@property (strong, nonatomic, nonnull) NSOperationQueue *downloadQueue;
_downloadQueue.maxConcurrentOperationCount = _config.maxConcurrentDownloads;
// OperationQueue的任务是NSOperation的子类SDWebImageDownloaderOperation,用它来发起网路请求,处理URLSession的回调
//负责下载的组件是URLSession
NSMutableURLRequest *mutableRequest = [[NSMutableURLRequest alloc] initWithURL:url cachePolicy:cachePolicy timeoutInterval:timeoutInterval];
三、SDWebImage 的Memory缓存和Disk缓存是用什么实现的?
Memory缓存的实现:
SDMemoryCache
//NSMapTable对象类似与NSDictionary的数据结构,但是NSMapTable功能比NSDictionary对象要多的功能就是可以设置key和value的NSPointerFunctionsOptions特性!其他的用法与NSDictionary相同.
@property (nonatomic, strong, nonnull) NSMapTable<KeyType, ObjectType> *weakCache; // strong-weak cache
@property (nonatomic, strong, nonnull) dispatch_semaphore_t weakCacheLock; // a lock to keep the access to `weakCache` thread-safe
// `setObject:forKey:` just call this with 0 cost. Override this is enough
- (void)setObject:(id)obj forKey:(id)key cost:(NSUInteger)g {
[super setObject:obj forKey:key cost:g];
if (!self.config.shouldUseWeakMemoryCache) {
return;
}
if (key && obj) {
// Store weak cache
SD_LOCK(self.weakCacheLock);
[self.weakCache setObject:obj forKey:key];
SD_UNLOCK(self.weakCacheLock);
}
}
self.weakCache = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsWeakMemory capacity:0];
self.weakCacheLock = dispatch_semaphore_create(1);
// 监听内存警告做清理
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceiveMemoryWarning:)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
Disk缓存的实现:
- 创建了一个名为 IO的串行队列,所有Disk操作都在此队列中,逐个执行!!
@interface SDImageCache ()
#pragma mark - Properties
// 缓存和disk缓存都是面向协议的匿名类封装一层,底层可以改变实现,满足相应协议即可
@property (nonatomic, strong, readwrite, nonnull) id<SDMemoryCache> memoryCache;
@property (nonatomic, strong, readwrite, nonnull) id<SDDiskCache> diskCache;
@property (nonatomic, strong, nullable) dispatch_queue_t ioQueue;
@end
// Create IO serial queue
_ioQueue = dispatch_queue_create("com.hackemist.SDImageCache", DISPATCH_QUEUE_SERIAL);
- 判断当前是否是IOQueue (原理:七、SDWebImage 如何保证UI操作放在主线程中执行?)
- (void)checkIfQueueIsIOQueue {
const char *currentQueueLabel = dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL);
const char *ioQueueLabel = dispatch_queue_get_label(self.ioQueue);
if (strcmp(currentQueueLabel, ioQueueLabel) != 0) {
NSLog(@"This method should be called from the ioQueue");
}
}
- 在主要存储函数中,dispatch_async(self.ioQueue, ^{})
// SDImageCache.m
- (void)storeImage:(nullable UIImage *)image
imageData:(nullable NSData *)imageData
forKey:(nullable NSString *)key
toDisk:(BOOL)toDisk
completion:(nullable SDWebImageNoParamsBlock)completionBlock {
// .........
if (toDisk) {
dispatch_async(self.ioQueue, ^{
@autoreleasepool {
NSData *data = imageData;
if (!data && image) {
SDImageFormat imageFormatFromData = [NSData sd_imageFormatForImageData:data];
data = [image sd_imageDataAsFormat:imageFormatFromData];
}
[self storeImageDataToDisk:data forKey:key];
}
if (completionBlock) {
dispatch_async(dispatch_get_main_queue(), ^{
completionBlock();
});
}
});
}
// .........
}
- (NSUInteger)totalDiskSize {
__block NSUInteger size = 0;
dispatch_sync(self.ioQueue, ^{
size = [self.diskCache totalSize];
});
return size;
}
- (NSUInteger)totalDiskCount {
__block NSUInteger count = 0;
dispatch_sync(self.ioQueue, ^{
count = [self.diskCache totalCount];
});
return count;
}
结论:
- 真正的磁盘缓存是在另一个IO专属线程中的一个串行队列下进行的。
- 如果你搜索self.ioQueue还能发现、不只是读取磁盘内容。
- 包括删除、写入等所有磁盘内容都是在这个IO线程进行、以保证线程安全。
四、SDWebImage Disk缓存时长? Disk清理操作时间点? Disk清理原则?
默认为一周
// SDImageCacheConfig.m
static const NSInteger kDefaultCacheMaxCacheAge = 60 * 60 * 24 * 7; // 1 week
磁盘清理时间点:
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(deleteOldFiles)
name:UIApplicationWillTerminateNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(backgroundDeleteOldFiles)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
分别在『应用被杀死时』和 『应用进入后台时』进行清理操作
清理磁盘的方法
- (void)deleteOldFilesWithCompletionBlock:(nullable SDWebImageNoParamsBlock)completionBlock;
当应用进入后台时,会涉及到『Long-Running Task』
正常程序在进入后台后、虽然可以继续执行任务。但是在时间很短内就会被挂起待机。
Long-Running可以让系统为app再多分配一些时间来处理一些耗时任务。
- (void)backgroundDeleteOldFiles {
Class UIApplicationClass = NSClassFromString(@"UIApplication");
if(!UIApplicationClass || ![UIApplicationClass respondsToSelector:@selector(sharedApplication)]) {
return;
}
UIApplication *application = [UIApplication performSelector:@selector(sharedApplication)];
// 后台任务标识--注册一个后台任务
__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;
}];
}
磁盘清理原则
清理缓存的规则分两步进行。 第一步先清除掉过期的缓存文件。 如果设置了maxDiskSize,那么清除掉过期的缓存之后,空间还不够,就继续按文件时间从早到晚排序,先清除最早的缓存文件,直到剩余空间达到要求。
具体点,SDWebImage 是怎么控制哪些缓存过期,以及剩余空间多少才够呢? 通过两个属性:
@interface SDImageCacheConfig : NSObject
/**
* The maximum length of time to keep an image in the disk cache, in seconds.
* Setting this to a negative value means no expiring.
* Setting this to zero means that all cached files would be removed when do expiration check.
* Defaults to 1 week.
*/
@property (assign, nonatomic) NSTimeInterval maxDiskAge;
/**
* The maximum size of the disk cache, in bytes.
* Defaults to 0. Which means there is no cache size limit.
*/
@property (assign, nonatomic) NSUInteger maxDiskSize;
maxDiskAge 和 maxDiskSize 有默认值吗?
maxCacheAge
在上述已经说过了,是有默认值的 1week,单位秒。maxCacheSize
翻了一遍 SDWebImage 的代码,并没有对 maxCacheSize 设置默认值。 这就意味着 SDWebImage 在默认情况下不会对缓存空间设限制。可以这样设置:
[SDImageCache sharedImageCache].maxCacheSize = 1024 * 1024 * 50; // 50M
maxCacheSize 是以字节来表示的,我们上面的计算代表 50M 的最大缓存空间。 把这行代码写在你的 APP 启动的时候,这样 SDWebImage 在清理缓存的时候,就会清理多余的缓存文件了。
五、SDWebImage Disk目录位于哪里?
- 缓存在沙盒目录下
Library/Caches
- 默认情况下,二级目录为
~/Library/Caches/default/com.hackemist.SDWebImageCache.default
- 也可自定义文件名
- (instancetype)init {
return [self initWithNamespace:@"default"];
}
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns {
return [self initWithNamespace:ns diskCacheDirectory:nil];
}
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns
diskCacheDirectory:(nullable NSString *)directory {
return [self initWithNamespace:ns diskCacheDirectory:directory config:SDImageCacheConfig.defaultCacheConfig];
}
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns
diskCacheDirectory:(nullable NSString *)directory
config:(nullable SDImageCacheConfig *)config {
if ((self = [super init])) {
NSAssert(ns, @"Cache namespace should not be nil");
// Create IO serial queue
_ioQueue = dispatch_queue_create("com.hackemist.SDImageCache", DISPATCH_QUEUE_SERIAL);
if (!config) {
config = SDImageCacheConfig.defaultCacheConfig;
}
_config = [config copy];
// Init the memory cache
NSAssert([config.memoryCacheClass conformsToProtocol:@protocol(SDMemoryCache)], @"Custom memory cache class must conform to `SDMemoryCache` protocol");
_memoryCache = [[config.memoryCacheClass alloc] initWithConfig:_config];
// Init the disk cache
if (directory != nil) {
_diskCachePath = [directory stringByAppendingPathComponent:ns];
} else {
NSString *path = [[[self userCacheDirectory] stringByAppendingPathComponent:@"com.hackemist.SDImageCache"] stringByAppendingPathComponent:ns];
_diskCachePath = path;
}
NSAssert([config.diskCacheClass conformsToProtocol:@protocol(SDDiskCache)], @"Custom disk cache class must conform to `SDDiskCache` protocol");
_diskCache = [[config.diskCacheClass alloc] initWithCachePath:_diskCachePath config:_config];
// Check and migrate disk cache directory if need
[self migrateDiskCacheDirectory];
#if SD_UIKIT
// Subscribe to app events
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillTerminate:)
name:UIApplicationWillTerminateNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationDidEnterBackground:)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
#endif
#if SD_MAC
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillTerminate:)
name:NSApplicationWillTerminateNotification
object:nil];
#endif
}
return self;
}
- (void)migrateDiskCacheDirectory {
if ([self.diskCache isKindOfClass:[SDDiskCache class]]) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// ~/Library/Caches/com.hackemist.SDImageCache/default/
NSString *newDefaultPath = [[[self userCacheDirectory] stringByAppendingPathComponent:@"com.hackemist.SDImageCache"] stringByAppendingPathComponent:@"default"];
// ~/Library/Caches/default/com.hackemist.SDWebImageCache.default/
NSString *oldDefaultPath = [[[self userCacheDirectory] stringByAppendingPathComponent:@"default"] stringByAppendingPathComponent:@"com.hackemist.SDWebImageCache.default"];
dispatch_async(self.ioQueue, ^{
[((SDDiskCache *)self.diskCache) moveCacheDirectoryFromPath:oldDefaultPath toPath:newDefaultPath];
});
});
}
}