野火🔥

iOS数据的磁盘缓存实现

2015/11/09

虽然第三方框架AFNetworking SDWebImage都有自己的内存缓存和数据缓存,但有时候app内部有一些数据(json、image等)可能有必要单独缓存进行管理,本文就根据最近的需求设计了一个比较简单磁盘缓存工具。

代码前根据需求进行一些分析设计

  • 缓存管理的全局性,使得该类应由单例管理
  • 因为磁盘缓存,所以有必要在子线程处理,避免卡IO
  • 缓存应该有过期时间管理,有默认值、也可以自定义
  • 能够具备 增、删、查的操作
  • 因为要根据url和params缓存返回的json数据,所以要有对url为key的处理

根据设计,定义h文件的public方法和public的成员变量

@interface QDiskCacheService : QService<QService>
- (void)clearCache;
- (void)removeCacheForKey:(NSString* __nonnull)key;
- (BOOL)hasCacheForKey:(NSString* __nonnull)key;

- (NSDictionary * __nullable)jsonForUrl:(NSString * __nonnull)url withParams:(NSDictionary* __nullable)params;
- (void) setJson:(NSDictionary * __nullable)item forURL:(NSString * __nonnull)url withParams:(NSDictionary* __nullable)params;
- (void) setJson:(NSDictionary * __nullable)item forURL:(NSString * __nonnull)url withParams:(NSDictionary* __nullable)params withTimeoutInterval:(NSTimeInterval)timeoutInterval;

- (NSData* __nullable)dataForKey:(NSString* __nonnull)key;
- (void)setData:(NSData* __nonnull)data forKey:(NSString* __nonnull)key;
- (void)setData:(NSData* __nonnull)data forKey:(NSString* __nonnull)key withTimeoutInterval:(NSTimeInterval)timeoutInterval;
@property(nonatomic) NSTimeInterval defaultTimeoutInterval; //默认缓存时间 
@end

代码中QService为个人定义的一个协议,用于统一管理单例,在工程中,凡是实现该协议的类,均可以通过一个固定的宏实现单例,以后会单独一片文章介绍这个用于单例管理的QServiceCenter

由于需要设定保存时间,所以设计一个plist用于保存缓存的key和过期时间,该plist的处理需要一个队列处理,文件保存本地和本地读取的操作在另一个队列完成。

m文件中需要定义一个方法来将params和url合并成一个key的方法。下面是实现细节:

  1. init方法需要初始化几个线程队列,初始化目录并把过期缓存清理

    _cacheInfoQueue = dispatch_queue_create("com.qdaily.qcache.info", DISPATCH_QUEUE_SERIAL);
    dispatch_queue_t priority = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
    dispatch_set_target_queue(priority, _cacheInfoQueue);
    
    _frozenCacheInfoQueue = dispatch_queue_create("com.qdaily.qcache.info.frozen", DISPATCH_QUEUE_SERIAL);
    priority = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
    dispatch_set_target_queue(priority, _frozenCacheInfoQueue);
    
    _diskQueue = dispatch_queue_create("com.qdaily.qcache.disk", DISPATCH_QUEUE_CONCURRENT);
    priority = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
    dispatch_set_target_queue(priority, _diskQueue);
    
    _directory = cacheDirectory;
    
    _cacheInfo = [[NSDictionary dictionaryWithContentsOfFile:cachePathForKey(_directory, @"QCache.plist")] mutableCopy];
    
    if(!_cacheInfo) {
        _cacheInfo = [[NSMutableDictionary alloc] init];
    }
    
    [[NSFileManager defaultManager] createDirectoryAtPath:_directory withIntermediateDirectories:YES attributes:nil error:NULL];
    
    NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate];
    NSMutableArray* removedKeys = [[NSMutableArray alloc] init];
    
    for(NSString* key in _cacheInfo) {
        if([_cacheInfo[key] timeIntervalSinceReferenceDate] <= now) {
            [[NSFileManager defaultManager] removeItemAtPath:cachePathForKey(_directory, key) error:NULL];
            [removedKeys addObject:key];
        }
    }
    
    [_cacheInfo removeObjectsForKeys:removedKeys];
    self.frozenCacheInfo = _cacheInfo;
    [self setDefaultTimeoutInterval:86400*7];
    
  2. 缓存的一些必须有的基本操作,清空,删除,多是对缓存列表(plist)的处理

- (void)clearCache {
    dispatch_sync(_cacheInfoQueue, ^{
        for(NSString* key in _cacheInfo) {
            [[NSFileManager defaultManager] removeItemAtPath:cachePathForKey(_directory, key) error:NULL];
        }

        [_cacheInfo removeAllObjects];

        dispatch_sync(_frozenCacheInfoQueue, ^{
            self.frozenCacheInfo = [_cacheInfo copy];
        });

        [self setNeedsSave];
    });
}

- (void)removeCacheForKey:(NSString*)key {
    CHECK_FOR_QCACHE_PLIST();

    dispatch_async(_diskQueue, ^{
        [[NSFileManager defaultManager] removeItemAtPath:cachePathForKey(_directory, key) error:NULL];
    });

    [self setCacheTimeoutInterval:0 forKey:key];
}

- (BOOL)hasCacheForKey:(NSString*)key {
    NSDate* date = [self dateForKey:key];
    if(date == nil) return NO;
    if([date timeIntervalSinceReferenceDate] < CFAbsoluteTimeGetCurrent()) return NO;

    return [[NSFileManager defaultManager] fileExistsAtPath:cachePathForKey(_directory, key)];
}

- (NSDate*)dateForKey:(NSString*)key {
    __block NSDate* date = nil;

    dispatch_sync(_frozenCacheInfoQueue, ^{
        date = (self.frozenCacheInfo)[key];
    });

    return date;
}

- (NSArray*)allKeys {
    __block NSArray* keys = nil;

    dispatch_sync(_frozenCacheInfoQueue, ^{
        keys = [self.frozenCacheInfo allKeys];
    });

    return keys;
}

- (void)setCacheTimeoutInterval:(NSTimeInterval)timeoutInterval forKey:(NSString*)key {
    NSDate* date = timeoutInterval > 0 ? [NSDate dateWithTimeIntervalSinceNow:timeoutInterval] : nil;

    // Temporarily store in the frozen state for quick reads
    dispatch_sync(_frozenCacheInfoQueue, ^{
        NSMutableDictionary* info = [self.frozenCacheInfo mutableCopy];

        if(date) {
            info[key] = date;
        } else {
            [info removeObjectForKey:key];
        }

        self.frozenCacheInfo = info;
    });

    // Save the final copy (this may be blocked by other operations)
    dispatch_async(_cacheInfoQueue, ^{
        if(date) {
            _cacheInfo[key] = date;
        } else {
            [_cacheInfo removeObjectForKey:key];
        }

        dispatch_sync(_frozenCacheInfoQueue, ^{
            self.frozenCacheInfo = [_cacheInfo copy];
        });

        [self setNeedsSave];
    });
}

- (void)setNeedsSave {
    dispatch_async(_cacheInfoQueue, ^{
        if(_needsSave) return;
        _needsSave = YES;

        double delayInSeconds = 0.5;
        dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
        dispatch_after(popTime, _cacheInfoQueue, ^(void){
            if(!_needsSave) return;
            [_cacheInfo writeToFile:cachePathForKey(_directory, @"QCache.plist") atomically:YES];
            _needsSave = NO;
        });
    });
}
  1. 具体的实现,对于data的实现不列出来了,只标记url的处理,这种保存在debug下按分级目录保存,易于查看和调试,在release下直接采用md5为key
- (NSDictionary *)jsonForUrl:(NSString *)url withParams:(NSDictionary*)params {
#ifdef DEBUG
    NSString* filePath = [self keyForUrl:url params:params]; //debug下其实是一个相对路径
    NSDate* date = [self dateForKey:filePath];
    if(date == nil || [date timeIntervalSinceReferenceDate] < CFAbsoluteTimeGetCurrent()) return nil;

    if ([[NSFileManager defaultManager] fileExistsAtPath:[_directory stringByAppendingPathComponent:filePath]]) {
        NSData *data = [NSData dataWithContentsOfFile:[_directory stringByAppendingPathComponent:filePath]];
        NSDictionary *item = [NSKeyedUnarchiver unarchiveObjectWithData:data];
        return item;
    }
    return nil;
#else
    NSString* key = [self keyForUrl:url params:params];
    return [NSKeyedUnarchiver unarchiveObjectWithData:[self dataForKey:key]];
#endif
}

- (void) setJson:(NSDictionary *)item forURL:(NSString *)url withParams:(NSDictionary*)params {
    [self setJson:item forURL:url withParams:params withTimeoutInterval:self.defaultTimeoutInterval];
}

- (void) setJson:(NSDictionary *)item forURL:(NSString *)url withParams:(NSDictionary*)params withTimeoutInterval:(NSTimeInterval)timeoutInterval{
    if (item == nil || item.count == 0) {
        return;
    }
#ifdef DEBUG
    NSString* filePath = [self keyForUrl:url params:params]; //debug下其实是一个相对路径
    NSData *data = [NSKeyedArchiver archivedDataWithRootObject:item];
    dispatch_async(_diskQueue, ^{
        [data writeToFile:[_directory stringByAppendingPathComponent:filePath] atomically:YES];
    });
    [self setCacheTimeoutInterval:timeoutInterval forKey:filePath];
#else
    NSString* key = [self keyForUrl:url params:params];
    [self setData:[NSKeyedArchiver archivedDataWithRootObject:item] forKey:key withTimeoutInterval:timeoutInterval];
#endif
}

- (NSString*) keyForUrl:(NSString *)url params:(NSDictionary*)params {
#ifdef DEBUG
    NSRange range = [url rangeOfString:@"/" options:NSBackwardsSearch];
    NSString *cacheName = [NSString stringWithFormat:@"%@%@", [url substringFromIndex:range.location + range.length], [NSURL queryStringFromParameters:params]];

    NSFileManager *fileManager = [NSFileManager defaultManager];

    NSString* relativePath = [NSString stringWithFormat:@"%@/%@", @"QUrlCaches", [url substringToIndex:range.location]];
    NSString *filePath = [NSString stringWithFormat:@"%@/%@", _directory, relativePath];
    BOOL ui;
    if (![fileManager fileExistsAtPath:filePath isDirectory:&ui])
    {
        [fileManager createDirectoryAtPath:filePath withIntermediateDirectories:YES attributes:nil error:nil];
    }
    return [NSString stringWithFormat:@"%@/%@", relativePath, cacheName];
#else 
    NSString *cacheName = [NSString stringWithFormat:@"%@%@", url, [NSURL queryStringFromParameters:params]]; //非debug直接对url做md5
    return [cacheName lf_md5Hash];
#endif
}
CATALOG