对于网络资源二级缓存的简单学习
缓存学习
- 前言
- 认识缓存
- 磁盘储存
- 内存储存
- 磁盘+内存组合优化
- 具体实现
- WebCache
- MD5签名
- WebDownloadOperation
- WebDownloader
- WebCombineOperation
- 总结
前言
在最近的写的仿抖音app中,遇到了当往下滑动视频后,当上方的视频进入复用池后,会自动清空内容,导致再次播放需要重新下载。而如果将视频资源下载到缓存后,则无需重新下载,以提高用户体验。
认识缓存
缓存是本地数据储存,储存方式主要有两种:磁盘储存和内存储存
磁盘储存
磁盘缓存也就是硬盘缓存,磁盘是程序的存储空间,磁盘缓存容量大速度慢,并且如果不手动删除,将会永久储存东西。
iOS为不同数据管理对储存路径做了如下规范:
- 每一个应用程序都会拥有一个应用程序沙盒。
- 应用程序沙盒就是一个文件系统目录。
沙盒根目录结构:Documents、Library、temp。
磁盘储存方式主要有文件管理和数据库,特点如下:
内存储存
内存缓存指当前程序运行空间,供cpu直接读取。因此具有速度快但容量小的特点。
以我们打开一个程序为例,程序就是运行在内存中的,当程序关闭后,内存又会释放。
iOS内存分为5个区:栈区,堆区,全局区,常量区,代码区。此处与C语言相似,不再介绍。
程序中声明的容器(数组 、字典)都可看做内存中存储,特性如下:
以上是对缓存的基本学习,那么缓存的使用场景诸如:离线加载,预加载,本地通讯录,还有此处的缓存播放。
磁盘+内存组合优化
针对磁盘和内存缓存的不同优点,我们采取磁盘+内存组合使用的方法,融合各自优点,实现网络资源的二级缓存。
先讲解缓存使用的思路:
- 当读取一个视频时,APP应当优先请求内存缓冲中的资源
- 如果内存缓冲中有,则直接返回资源文件。如果没有,则会请求资源文件。这是资源文件默认资源为本地磁盘储存,需要操作文件系统或者数据库来读取
- 获取到的资源文件,先缓存到内存缓存,方便以后不再重复获取
- 当退出app或者内存超限时,清空内存文件。
具体实现
那么我们的代码该如何具体设计呢?
首先我们来了解NSCache。
NSCache是苹果提供的一个简单的内存缓存,它有着和 NSDictionary 类似的 API,不同点是它是线程安全的,并且不会 retain key。
WebCache
我们可以先设计一个自己的管理缓存的类。
#import <Foundation/Foundation.h>@interface WebCache : NSObject@property (nonatomic, strong) NSCache *memoryCache;
@property (nonatomic, copy) NSString *diskCachePath;+ (instancetype)sharedCache;//检查内存缓存
- (NSData *)dataFromMemoryCacheForKey:(NSString *)key;
//检查磁盘缓存
- (NSData *)dataFromDiskCacheForKey:(NSString *)key;
//检查缓存(先内存后磁盘)
- (NSData *)dataFromCacheForKey:(NSString *)key;//储存到内存缓存
- (void)storeDataToMemoryCache:(NSData *)data forKey:(NSString *)key;
//储存到磁盘缓存
- (void)storeDataToDiskCache:(NSData *)data forKey:(NSString *)key;
//储存到二级缓存
- (void)storeData:(NSData *)data forKey:(NSString *)key;// 清除内存缓存
- (void)clearMemoryCache;
// 清除磁盘缓存
- (void)clearDiskCache;
// 清除所有缓存
- (void)clearAllCache;- (NSString *)filePathForKey:(NSString *)key;//获取缓存文件路径
- (NSString *)cachePathForKey:(NSString *)key;//获得md5签名
- (NSString *)md5String:(NSString *)string;- (BOOL)diskCacheExistsForKey:(NSString *)key;
@end
以上是文件方法的命名,现在我们来具体实现。
首先初始化 WebCache 对象时,会做以下操作:
- 初始化一个 NSCache 对象 _memoryCache,并给它设置一个名字。
- 获取应用的缓存目录,然后在该目录下创建一个名为 com.demo.webCache 的子目录,当作磁盘缓存的存储路径。
若该目录不存在,就使用 NSFileManager 创建它。
- (instancetype)init {if (self = [super init] ) {_memoryCache = [[NSCache alloc] init];_memoryCache.name = @"com.demo.webCache";NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);_diskCachePath = [paths[0] stringByAppendingPathComponent:@"com.demo.webCache"];//创建磁盘缓存目录if (![[NSFileManager defaultManager] fileExistsAtPath:_diskCachePath]) {[[NSFileManager defaultManager] createDirectoryAtPath:_diskCachePath withIntermediateDirectories:YES attributes:nil error:nil];}}return self;
}
之后我们设置查询方法:
重点为第三个方法,先查询内存再查询磁盘
- (NSData *)dataFromMemoryCacheForKey:(NSString *)key {return [self.memoryCache objectForKey:key];
}- (NSData *)dataFromDiskCacheForKey:(NSString *)key {NSString *filePath = [self cachePathForKey:key];return [NSData dataWithContentsOfFile:filePath];
}- (NSData *)dataFromCacheForKey:(NSString *)key {NSData *data = [self dataFromMemoryCacheForKey:key];if (data) {return data;}return [self dataFromDiskCacheForKey:key];
}
储存方法中只需要注意一点,在储存大型文件时,如果文件过大则需要异步执行,防止主线程堵塞
- (void)storeDataToDiskCache:(NSData *)data forKey:(NSString *)key {if (data.length > 5 * 1024 * 1024) { // 大于5MB的文件异步存储dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{NSString *filePath = [self cachePathForKey:key];[data writeToFile:filePath atomically:YES];});} else {NSString *filePath = [self cachePathForKey:key];[data writeToFile:filePath atomically:YES];}
}
MD5签名
每一个视频资源都是从网络URL中下载,我们可以吧每个视频的URL当作此处的key值。但是,复杂的URL往往带有很多其他参数的信息。并且过长的key值也不利于我们查询具体的文件。
使用MD5签名,可以隐藏具体的URL信息,便于储存和处理。通过一下代码,我们可以将一个网络URL转换为唯一的MD5签名,便于使用。
- (NSString *)cachePathForKey:(NSString *)key {NSString *filename = [self md5String:key];return [self.diskCachePath stringByAppendingPathComponent:filename];
}- (NSString *)md5String:(NSString *)string {const char *cStr = [string UTF8String];unsigned char result[CC_MD5_DIGEST_LENGTH];CC_MD5(cStr, (CC_LONG)strlen(cStr), result);return [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x",result[0], result[1], result[2], result[3],result[4], result[5], result[6], result[7],result[8], result[9], result[10], result[11],result[12], result[13], result[14], result[15]];
}
现在,我们写好了查询和保存缓存,那么我们如何下载网络资源呢。
WebDownloadOperation
对于视频下载,我们至少有以下功能:可并发执行的网络下载,支持下载进度回调和完成回调,同时可以取消下载。
因此我们可以设计WebDownloadOperation 类实现了一个可并发执行的网络下载操作。该类是继承与NSOperation
对于部分变量的定义:
- request:存储要下载数据的 NSURLRequest 对象。
- progressBlock:用于下载进度回调的块。
- downloadCompletionBlock:下载完成时的回调块。
- session:NSURLSession 对象,用于管理网络会话。
- dataTask:NSURLSessionDataTask 对象,执行具体的下载任务。
- data:NSMutableData 对象,用于存储下载过程中接收到的数据。
- expectedSize:预期下载数据的大小。
- executing 和 finished:表示操作是否正在执行和是否已完成的标志。
初始化:
- (instancetype)initWithRequest:(NSURLRequest *)requestprogress:(WebDownloadProgressBlock)progresscompletion:(WebDownloadCompletionBlock)completion {if (self = [super init]) {_request = request;_progressBlock = progress ? [progress copy] : nil;_downloadCompletionBlock = completion ? [completion copy] : nil;}return self;
}
此方法接收一个 NSURLRequest 对象、下载进度回调块和下载完成回调块作为参数,对相应的属性进行初始化。使用 copy 是为了避免块在后续被意外修改。
我们首先看执行和取消:
- (void)start {@synchronized (self) {if (self.isCancelled) {self.finished = YES;return;}NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];config.timeoutIntervalForRequest = 15;self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];self.dataTask = [self.session dataTaskWithRequest:self.request];self.executing = YES;[self.dataTask resume];}
}- (void)cancel {@synchronized (self) {if (self.isFinished) return;[super cancel];if (self.dataTask) {[self.dataTask cancel];if (self.isExecuting) self.executing = NO;if (!self.isFinished) self.finished = YES;}[self reset];}
}
最重要的就是先检查是否在加载,为对应的情况赋值executing
和finished
。
下面看NSURLSessionDataDelegate
didReceiveResponse
方法:当接收到服务器响应时,该方法会被调用:
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {if (self.isCancelled) {completionHandler(NSURLSessionResponseCancel);return;}NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;if (httpResponse.statusCode == 200) {self.data = [NSMutableData data];self.expectedSize = response.expectedContentLength;completionHandler(NSURLSessionResponseAllow);} else {completionHandler(NSURLSessionResponseCancel);}
}
以上代码就是判断网络请求是否成功,以及判断是否取消下载
didReceiveData
方法:当接收到数据时,该方法会被调用
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {if (self.isCancelled) return;[self.data appendData:data];if (self.progressBlock) { self.progressBlock(self.data.length, self.expectedSize);}
}
将接收到的数据追加到 data 中,若 progressBlock 不为 nil,则调用它并传入当前已下载的数据长度和预期数据大小。
didCompleteWithError
方法:当下载任务完成时,该方法会被调用
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {@synchronized (self) {if (self.isCancelled) return;if (self.downloadCompletionBlock) { self.downloadCompletionBlock(self.data, error);}[self reset];self.executing = NO;self.finished = YES;}
}
该方法主要工作有两个,一个是如果downloadCompletionBlock
不为 nil,即正确下载了,则返回数据。同时重制当前的属性。
WebDownloader
接下来我们需要实现一个网络资源下载器,使用并发队列管理上面的WebDownloadOperation
任务。
首先看初始化:
- (instancetype)init {if (self = [super init]) {_downloadQueue = [[NSOperationQueue alloc] init];_downloadQueue.name = @"com.demo.webDownloader";_downloadQueue.maxConcurrentOperationCount = 6;}return self;
}
以上代码初始化了一个NSOperationQueue
对象 _downloadQueue
,该队列用于管理下载操作,并且最大并发操作数为 6,意味着同一时间最多可以有 6 个下载操作同时进行。
下载方法:
- (WebDownloadOperation *)downloadWithRequest:(NSURLRequest *)request progress:(WebDownloadProgressBlock)progress completion:(WebDownloadCompletionBlock)completion {WebDownloadOperation *operation = [[WebDownloadOperation alloc] initWithRequest:request progress:progress completion:completion];[self.downloadQueue addOperation:operation];return operation;
}
调用此方法时,会创建一个 WebDownloadOperation
实例,并使用传入的 NSURLRequest
、下载进度回调块 progress
和下载完成回调块 completion
进行初始化。
WebDownloadOperation
类负责具体的网络请求和数据下载逻辑,包括建立会话、处理响应、接收数据、更新进度等。然后将这个操作添加到 _downloadQueue
中,队列会根据其设置的规则调度和执行该操作。最后返回这个操作实例,
取消所有下载操作:
- (void)cancelAllDownloads {[self.downloadQueue cancelAllOperations];
}
取消 _downloadQueue 中所有正在进行或等待执行的 WebDownloadOperation 实例,当应用程序需要停止所有下载任务时。
WebCombineOperation
上述代码我们完成了对视频的缓存以及对视频资源的下载,但是如何判断当前是视频是否已经缓存,检查本地是否有需要的资源,以及下载资源。
因此我们需要设计一个类来组合协调缓存操作和下载操作。
属性 executing
和 finished
,分别用于表示操作是否正在执行和是否已经完成。
初始化:
1. (instancetype)initWithRequest:(NSURLRequest *)request cacheOperation:(NSOperation *)cacheOperation downloadOperation:(WebDownloadOperation *)downloadOperation {if (self = [super init]) {_request = request;_cacheOperation = cacheOperation;_downloadOperation = downloadOperation;}return self;
}
主要是开始操作的逻辑:
- 如果存在cacheOperation,则为其设置一个完成块(completionBlock)。在完成块中,再次检查操作是否已取消,如果是则调用finish 方法结束操作;否则,如果存在downloadOperation,则启动 downloadOperation,否则也调用finish 方法结束操作。最后启动 cacheOperation。
- 如果不存在 cacheOperation 但存在 downloadOperation,则直接启动 downloadOperation。
- 如果两者都不存在,则直接调用 finish 方法结束操作。
总而言之:缓存操作优先于下载操作执行,只有在缓存操作无法进行或完成后仍未获取到资源(且存在下载操作)时,才会启动下载操作;若两者都不存在,则直接结束操作。
- (void)start {if (self.isCancelled) {self.finished = YES;return;}self.executing = YES;__weak __typeof(self) weakSelf = self;if (self.cacheOperation) {[self.cacheOperation setCompletionBlock:^{if (weakSelf.isCancelled) {[weakSelf finish];return;}if (weakSelf.downloadOperation) {[weakSelf.downloadOperation start];} else {[weakSelf finish];}}];[self.cacheOperation start];} else if (self.downloadOperation) {[self.downloadOperation start];} else {[self finish];}
}
取消操作方法与结束操作方法:
- (void)cancel {if (self.isFinished) return;[super cancel];if (self.cacheOperation) {[self.cacheOperation cancel];}if (self.downloadOperation) {[self.downloadOperation cancel];}[self finish];
}- (void)finish {self.executing = NO;self.finished = YES;
}
以上,我们完成了一个二级缓存的基本思路:以网络资源路径进行md5签名,生成唯一的字符串作为key值,根据这个key值查找NSCache内存缓存中是否存在数据,没有则在磁盘缓存——沙盒中查找是否存在以key值为文件名的文件,如果两者都没有缓存数据则下载网络资源,资源下载成功后再以网络资源路径的md5值作为key值,将数据缓存进内存缓存和磁盘缓存中。
在具体使用中,还可以继续将上述代码封装为WebCacheHelper
,以简化使用。如下:
- (void) queryDataFromCacheWithURL:(NSURL *)url completion:(WebCacheCompletionBlock)completion {if (!url || !completion) return;NSString *key = [self cacheKeyForURL:url];NSLog(@"查询缓存: %@", key);// 创建缓存查询OperationNSOperation *cacheOperation = [NSBlockOperation blockOperationWithBlock:^{NSData *data = [[WebCache sharedCache] dataFromCacheForKey:key];if (data) {NSLog(@"Helper内存缓存命中: %@", key);[[NSOperationQueue mainQueue] addOperationWithBlock:^{completion(data);}];return; // 找到内存缓存后直接返回}data = [[WebCache sharedCache] dataFromDiskCacheForKey:key];if (data) {NSLog(@"Helper磁盘缓存命中: %@", key);[[NSOperationQueue mainQueue] addOperationWithBlock:^{completion(data);}];return; // 找到磁盘缓存后直接返回}NSLog(@"Helper缓存未命中");[self downloadDataWithURL:url completion:completion];NSLog(@"downLoadURL:%@",url);}];[[WebDownloader sharedDownloader].downloadQueue addOperation:cacheOperation];
}- (void)downloadDataWithURL:(NSURL *)url completion:(WebCacheCompletionBlock)completion {WebDownloadOperation *downloadOperation = [[WebDownloadOperation alloc] initWithRequest:[NSURLRequest requestWithURL:url] progress:nil completion:^(NSData * _Nullable data, NSError * _Nullable error) {if (data && !error) {[[WebCache sharedCache] storeData:data forKey:[self cacheKeyForURL:url]];[[NSOperationQueue mainQueue] addOperationWithBlock:^{completion(data);}];NSLog(@"进入if");} else {NSLog(@"进入else");[[NSOperationQueue mainQueue] addOperationWithBlock:^{completion(nil);}];}}];NSLog(@"下载Data");[[WebDownloader sharedDownloader].downloadQueue addOperation:downloadOperation];
}
当内存超限清空时:
总结
此篇文章参考了许多大牛写的内容,作为初学者仍然还有很多搞不太懂的地方以及无法解决的bug。最头痛的一个bug就是没有实现边播边下载,导致每次都是下载完后播放,因此播放器总是从缓存中读入。此处是否还有其他原因后续会继续学习查找。