iOS 缓存机制详解

Untold numbers of developers have hacked together an awkward, fragile system for network caching functionality, all because they weren’t aware that NSURLCache could be setup in two lines and do it 100× better. Even more developers have never known the benefits of network caching, and never attempted a solution, causing their apps to make untold numbers of unnecessary requests to the server.
无数开发者尝试自己做一个丑陋而脆弱的系统来实现网络缓存的功能,殊不知NSURLCache只要两行代码就能搞定,并且好上100倍。甚至更多的开发者根本不知道网络缓存的好处,从来没有尝试过解决方案,导致他们的App向服务器发出无数不必要的请求。

iOS系统的缓存策略

    上面是引用Mattt大神在NSHipster介绍NSURLCache时的原话。

服务端的缓存策略

    先看看服务端的缓存策略。当第一次请求后,客户端会缓存数据,当有第二次请求的时候,客户端会额外在请求头加上If-Modified-Since或者If-None-MatchIf-Modified-Since会携带缓存的最后修改时间,服务端会把这个时间和实际文件的最后修改时间进行比较。

  • 相同就返回状态码304,且不返回数据,客户端拿出缓存数据,渲染页面
  • 不同就返回状态码200,并且返回数据,客户端渲染页面,并且更新缓存

    当然类似的还有Cache-ControlExpiresEtag,都是为了校验本地缓存文件和服务端是否一致,这里就带过了。

NSURLCache

    NSURLCache是iOS系统提供的内存以及磁盘的综合缓存机制。NSURLCache对象被存储沙盒中Library/cache目录下。在我们只需要在didFinishLaunchingWithOptions函数里面加上下面的代码,就可以满足一般的缓存要求。(是的,搞定NSURLCache就是这么简单)

NSURLCache * sharedCache = [[NSURLCache alloc] initWithMemoryCapacity:20 * 1024 * 1024
                                                            diskCapacity:100 * 1024 * 1024
                                                                diskPath:nil];
[NSURLCache setSharedURLCache:sharedCache];

    下面是几个常用的API

 //设置内存缓存的最大容量
[cache setMemoryCapacity:1024 * 1024 * 20];
    
//设置磁盘缓存的最大容量
[cache setDiskCapacity:1024 * 1024 * 100];
    
//获取某个请求的缓存
[cache cachedResponseForRequest:request];
    
//清除某个请求的缓存
[cache removeCachedResponseForRequest:request];
    
//请求策略,设置了系统会自动用NSURLCache进行数据缓存
request.cachePolicy = NSURLRequestReturnCacheDataElseLoad;

iOS常用的缓存策略

    NSURLRequestCachePolicy是个枚举,指的是不同的缓存策略,一共有7种,但是能用的只有4种。

typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
    //如果有协议,对于特定的URL请求,使用协议实现定义的缓存逻辑。(默认的缓存策略)
    NSURLRequestUseProtocolCachePolicy = 0,
    
    //请求仅从原始资源加载URL,不使用任何缓存
    NSURLRequestReloadIgnoringLocalCacheData = 1,
    
    //不仅忽略本地缓存,还要忽略协议缓存和其他缓存 (未实现)
    NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4,
    
    //被NSURLRequestReloadIgnoringLocalCacheData替代
    NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,
    
    //无视缓存的有效期,有缓存就取缓存,没有缓存就会从原始地址加载
    NSURLRequestReturnCacheDataElseLoad = 2,
    
    //无视缓存的有效期,有缓存就取缓存,没有缓存就视为失败 (可以用于离线模式)
    NSURLRequestReturnCacheDataDontLoad = 3,
    
    //会从初始地址校验缓存的合法性,合法就用缓存数据,不合法从原始地址加载数据 (未实现)
    NSURLRequestReloadRevalidatingCacheData = 5, // Unimplemented
};

AFNetworking的缓存策略

    之前写了SDWebImage的源码解析 里面介绍过SDWebImage的缓存策略,有两条线根据时间和空间来管理缓存和AFNetworking很相似。AFNetworkingAFImageDownloader使用AFAutoPurgingImageCacheNSURLCache管理图片缓存。

AFNetworking中的NSURLCache

    AFImageDownloader中设置NSURLCache,低版本iOS版本中设置内存容量和磁盘容量会闪退(这个我没有考证,iOS 7的手机还真没有)

if ([[[UIDevice currentDevice] systemVersion] compare:@"8.2" options:NSNumericSearch] == NSOrderedAscending) {
    return [NSURLCache sharedURLCache];
}
return [[NSURLCache alloc] initWithMemoryCapacity:20 * 1024 * 1024
                                     diskCapacity:150 * 1024 * 1024
                                         diskPath:@"com.alamofire.imagedownloader"];

AFNetworking中的AFAutoPurgingImageCache

    AFAutoPurgingImageCache是专门用来图片缓存的。可以看到内部有三个属性,一个是用来装载AFImageCache对象的字典容器,一个是可以用内存空间大小、一个同步队列。AFAutoPurgingImageCache在初始化的时候,会注册UIApplicationDidReceiveMemoryWarningNotification通知,收到内存警告的时候会清除所有缓存。

 @interface AFAutoPurgingImageCache ()
 @property (nonatomic, strong) NSMutableDictionary <NSString* , AFCachedImage*> *cachedImages;
 @property (nonatomic, assign) UInt64 currentMemoryUsage;
 @property (nonatomic, strong) dispatch_queue_t synchronizationQueue;
 @end

    AFCachedImage是单个图片缓存对象

 @property (nonatomic, strong) UIImage *image;
 
 //标志符(这个值就是图片的请路径 request.URL.absoluteString)
 @property (nonatomic, strong) NSString *identifier;
 
 //图片大小
 @property (nonatomic, assign) UInt64 totalBytes;
 
 //缓存日期
 @property (nonatomic, strong) NSDate *lastAccessDate;
 
 //当前可用内存空间大小
 @property (nonatomic, assign) UInt64 currentMemoryUsage;

    来看看AFCachedImage初始化的时候。iOS使用图标标准是ARGB_8888,即一像素占位4个字节。内存大小 = 宽 * 高 * 每像素字节数。

-(instancetype)initWithImage:(UIImage *)image identifier:(NSString *)identifier {
    if (self = [self init]) {
        self.image = image;
        self.identifier = identifier;

        CGSize imageSize = CGSizeMake(image.size.width * image.scale, image.size.height * image.scale);
        CGFloat bytesPerPixel = 4.0;
        CGFloat bytesPerSize = imageSize.width * imageSize.height;
        self.totalBytes = (UInt64)bytesPerPixel * (UInt64)bytesPerSize;
        self.lastAccessDate = [NSDate date];
    }
    return self;
}

    来看看添加缓存的代码,用了dispatch_barrier_async栅栏函数将添加操作和删除缓存操作分割开来。每添加一个缓存对象,都重新计算当前缓存大小和可用空间大小。当内存超过设定值时,会按照日期的倒序来遍历缓存图片,删除最早日期的缓存,一直到满足缓存空间为止。

- (void)addImage:(UIImage *)image withIdentifier:(NSString *)identifier {
    dispatch_barrier_async(self.synchronizationQueue, ^{
        AFCachedImage *cacheImage = [[AFCachedImage alloc] initWithImage:image identifier:identifier];

        AFCachedImage *previousCachedImage = self.cachedImages[identifier];
        if (previousCachedImage != nil) {
            self.currentMemoryUsage -= previousCachedImage.totalBytes;
        }

        self.cachedImages[identifier] = cacheImage;
        self.currentMemoryUsage += cacheImage.totalBytes;
    });

    dispatch_barrier_async(self.synchronizationQueue, ^{
        if (self.currentMemoryUsage > self.memoryCapacity) {
            UInt64 bytesToPurge = self.currentMemoryUsage - self.preferredMemoryUsageAfterPurge;
            NSMutableArray <AFCachedImage*> *sortedImages = [NSMutableArray arrayWithArray:self.cachedImages.allValues];
            NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"lastAccessDate"
                                                                           ascending:YES];
            [sortedImages sortUsingDescriptors:@[sortDescriptor]];

            UInt64 bytesPurged = 0;

            for (AFCachedImage *cachedImage in sortedImages) {
                [self.cachedImages removeObjectForKey:cachedImage.identifier];
                bytesPurged += cachedImage.totalBytes;
                if (bytesPurged >= bytesToPurge) {
                    break ;
                }
            }
            self.currentMemoryUsage -= bytesPurged;
        }
    });
}

YTKNetwork的缓存策略

    YTKNetwork是猿题库技术团队开源的一个网络请求框架,内部封装了AFNetworking。它把每个请求实例化,管理它的生命周期,也可以管理多个请求。笔者在一个电商的PaaS项目中就是使用YTKNetwork,它的特点还有支持请求结果缓存,支持批量请求,支持多请求依赖等。

准备请求之前

    先来看看请求基类YTKRequest在请求之前做了什么

- (void)start {
    
    //忽略缓存的标志 手动设置 是否利用缓存
    if (self.ignoreCache) {
        [self startWithoutCache];
        return;
    }

    // 还有未完成的请求 是否还有未完成的请求
    if (self.resumableDownloadPath) {
        [self startWithoutCache];
        return;
    }

    //加载缓存是否成功
    if (![self loadCacheWithError:nil]) {
        [self startWithoutCache];
        return;
    }

    _dataFromCache = YES;

    dispatch_async(dispatch_get_main_queue(), ^{
        
        //将请求数据写入文件
        [self requestCompletePreprocessor];
        [self requestCompleteFilter];
        
        //这个时候直接去相应 请求成功的delegate和block ,没有发送请求
        YTKRequest *strongSelf = self;
        [strongSelf.delegate requestFinished:strongSelf];
        if (strongSelf.successCompletionBlock) {
            strongSelf.successCompletionBlock(strongSelf);
        }
        
        //将block置空
        [strongSelf clearCompletionBlock];
    });
}

缓存数据写入文件

- (void)requestCompletePreprocessor {
    [super requestCompletePreprocessor];

    if (self.writeCacheAsynchronously) {
        dispatch_async(ytkrequest_cache_writing_queue(), ^{
            [self saveResponseDataToCacheFile:[super responseData]];
        });
    } else {
        [self saveResponseDataToCacheFile:[super responseData]];
    }
}

    ytkrequest_cache_writing_queue是一个优先级比较低的串行队列,当标志dataFromCacheYES的时候,确定能拿到数据,在这个串行队列中异步的写入文件。来看看写入缓存的具体操作。

- (void)saveResponseDataToCacheFile:(NSData *)data {
    if ([self cacheTimeInSeconds] > 0 && ![self isDataFromCache]) {
        if (data != nil) {
            @try {
                // New data will always overwrite old data.
                [data writeToFile:[self cacheFilePath] atomically:YES];

                YTKCacheMetadata *metadata = [[YTKCacheMetadata alloc] init];
                metadata.version = [self cacheVersion];
                metadata.sensitiveDataString = ((NSObject *)[self cacheSensitiveData]).description;
                metadata.stringEncoding = [YTKNetworkUtils stringEncodingWithRequest:self];
                metadata.creationDate = [NSDate date];
                metadata.appVersionString = [YTKNetworkUtils appVersionString];
                [NSKeyedArchiver archiveRootObject:metadata toFile:[self cacheMetadataFilePath]];
            } @catch (NSException *exception) {
                YTKLog(@"Save cache failed, reason = %@", exception.reason);
            }
        }
    }
}

    除了请求数据文件,YTK还会生成一个记录缓存数据信息的元数据YTKCacheMetadata对象。YTKCacheMetadata记录了缓存的版本号、敏感信息、缓存日期和App的版本号。

@property (nonatomic, assign) long long version;
@property (nonatomic, strong) NSString *sensitiveDataString;
@property (nonatomic, assign) NSStringEncoding stringEncoding;
@property (nonatomic, strong) NSDate *creationDate;
@property (nonatomic, strong) NSString *appVersionString;

    然后把请求方法、请求域名、请求URL和请求参数组成的字符串进行一次MD5加密,作为缓存文件的名称。YTKCacheMetadata和缓存文件同名,多了一个.metadata的后缀作为区分。文件写入的路径是沙盒中Library/LazyRequestCache目录下。

- (NSString *)cacheFileName {
    NSString *requestUrl = [self requestUrl];
    NSString *baseUrl = [YTKNetworkConfig sharedConfig].baseUrl;
    id argument = [self cacheFileNameFilterForRequestArgument:[self requestArgument]];
    NSString *requestInfo = [NSString stringWithFormat:@"Method:%ld Host:%@ Url:%@ Argument:%@",
                             (long)[self requestMethod], baseUrl, requestUrl, argument];
    NSString *cacheFileName = [YTKNetworkUtils md5StringFromString:requestInfo];
    return cacheFileName;
}
YTKNetwork缓存文件路径.png

校验缓存

    回到start方法中,loadCacheWithError是校验缓存能不能成功加载出来,loadCacheWithError中会调用validateCacheWithError来检验缓存的合法性,校验的依据正是YTKCacheMetadatacacheTimeInSeconds。要想使用缓存数据,请求实例要重写cacheTimeInSeconds设置一个大于0的值,而且缓存还支持版本、App的版本。在实际项目上应用,get请求实例设置一个cacheTimeInSeconds就够用了。

- (BOOL)validateCacheWithError:(NSError * _Nullable __autoreleasing *)error {
    // Date
    NSDate *creationDate = self.cacheMetadata.creationDate;
    NSTimeInterval duration = -[creationDate timeIntervalSinceNow];
    if (duration < 0 || duration > [self cacheTimeInSeconds]) {
        if (error) {
            *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorExpired userInfo:@{ NSLocalizedDescriptionKey:@"Cache expired"}];
        }
        return NO;
    }
    // Version
    long long cacheVersionFileContent = self.cacheMetadata.version;
    if (cacheVersionFileContent != [self cacheVersion]) {
        if (error) {
            *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorVersionMismatch userInfo:@{ NSLocalizedDescriptionKey:@"Cache version mismatch"}];
        }
        return NO;
    }
    // Sensitive data
    NSString *sensitiveDataString = self.cacheMetadata.sensitiveDataString;
    NSString *currentSensitiveDataString = ((NSObject *)[self cacheSensitiveData]).description;
    if (sensitiveDataString || currentSensitiveDataString) {
        // If one of the strings is nil, short-circuit evaluation will trigger
        if (sensitiveDataString.length != currentSensitiveDataString.length || ![sensitiveDataString isEqualToString:currentSensitiveDataString]) {
            if (error) {
                *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorSensitiveDataMismatch userInfo:@{ NSLocalizedDescriptionKey:@"Cache sensitive data mismatch"}];
            }
            return NO;
        }
    }
    // App version
    NSString *appVersionString = self.cacheMetadata.appVersionString;
    NSString *currentAppVersionString = [YTKNetworkUtils appVersionString];
    if (appVersionString || currentAppVersionString) {
        if (appVersionString.length != currentAppVersionString.length || ![appVersionString isEqualToString:currentAppVersionString]) {
            if (error) {
                *error = [NSError errorWithDomain:YTKRequestCacheErrorDomain code:YTKRequestCacheErrorAppVersionMismatch userInfo:@{ NSLocalizedDescriptionKey:@"App version mismatch"}];
            }
            return NO;
        }
    }
    return YES;
}

清除缓存

    因为缓存的目录是Library/LazyRequestCache,清除缓存就直接清空目录下所有文件就可以了。调用[[YTKNetworkConfig sharedConfig] clearCacheDirPathFilter]就行。

结语

    缓存的本质是用空间换取时间。大学里面学过的《计算机组成原理》中就有介绍cache,除了磁盘和内存,还有L1和L2,对于iOS开发者来说,一般关注diskmemory就够了。阅读SDWebImage、AFNetworking、YTKNetwork的源码后,可以看出他们都非常重视数据的多线程的读写安全,在做深度优化时候,因地制宜,及时清理缓存文件。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容