第50条:构建缓存时选用NSCache而非NSDictionary
本条要点:(作者总结)
- 实现缓存时应选用 NSCache 而非 NSDictionary 对象。因为 NSCache 可以提供优雅的自动删减功能,而且是“线程安全的”,此外,它与字典不同,并不会拷贝键。
- 可以给 NSCache 对象设置上限,用以限制缓存中的对象总个数及“总成本”,而这些尺度则定义了缓存删减其中对象的时机。但是绝对不要把这些尺度当成可靠的“硬限制”(hard limit),它们仅对NSCache 起指导作用。
- 将 NSPurgeableData 与 NSCache 搭配使用,可实现自动清除数据的功能,也就是说,当 NSPurgeableData 对象所占内存为系统所丢弃时,该对象自身也会从缓存中移除。
- 如果缓存使用得当,那么应用程序的响应速度就能提高。只有那种 “重新计算起来很费事的”数据,才值得放入缓存,比如那些需要从网络获取或从磁盘读取的数据。
开发 Mac OS X 或 iOS 应用程序时,经常会遇到一个问题,那就是从因特网下载的图片应如何来缓存。首先能想到的好办法就是把内存中的图片保存到字典里,这样的话,稍后使用时就无须再次下载了。有些程序员会不假思索,直接使用 NSDictionary 来做(准确来说,是使用其可变版本),因为这个类很常用。其实,NSCache 类更好,它是 Foundation 框架专为处理这种任务而设计的。
NSCache 胜过 NSDictionary 之处在于,当系统资源将要耗尽时,它可以自动删减缓存。如果采用普通的字典,那么就要自己编写挂钩,在系统发出“低内存”(low memory)通知时手工删减缓存。而 NSCache 则会自动删减,由于其是 Foundation 框架的一部分,所以与开发者相比,它能在更深的层面上插入挂钩。此外,NSCache 还会先行删减 “最久未使用的”(lease recently used)对象。若想自己编写代码来为字典添加此功能,则会十分复杂。
NSCache 并不会“拷贝”键,而是会 “保留”它。此行为用NSDictionary 也可以实现,然而需要编写相当复杂的代码。NSCache 对象不拷贝键的原因在于:很多时候,键都是由不支持拷贝操作的对象来充当的。因此,NSCache 不会拷贝键,所以说,在键不支持拷贝操作的情况下,该类用起来比字典更方便。另外,NSCache 是线程安全的。而 NSDictionary 则绝对不具备此优势,意思就是:在开发者自己不编写加锁代码的前提下,多个线程便可以同时访问 NSCache。对缓存来说,线程安全通常很重要,因为开发者可能要在某个线程中读取数据,此时如果发现缓存里找不到指定的键,那么就要下载该键所对应的数据了。而下载完数据之后所要执行的回调函数,有可能会放在背景线程中执行,这样的话,就等于是用另外一个线程来写入缓存了。
开发者可以操控缓存删减其内容的时机。有两个与系统资源相关的尺度可供调整,其一是缓存中的对象总数,其二是所有对象的“总开销”(overall cost)。开发者在将对象加入缓存时,可为其指定“开销值”。当对象总数或总开销超过上限时,缓存就可能会删减其中的对象了,在可用的系统资源趋于紧张时,也会这么做。然而要注意,“可能”会删减某个对象,并不意味着“一定”会删减这个对象。删减对象时所遵照的顺序,由具体实现来定。这尤其说明:想通过调整“开销值” 来迫使缓存优先删减某对象,不是个好主意。
向缓存中添加对象时,只有在能很快计算出“开销值”的情况下,才应该考虑采用这个尺度。若计算过程很复杂,那么照这种方式来使用缓存就达不到最佳效果了,因为每次向缓存中放入对象时,还要专门花时间来计算这个附加因素的值。而缓存的本意则是要增加应用程序响应用户操作的速度。比方说,如果计算“开销值”时必须访问磁盘才能确定文件大小,或是必须访问数据库才能决定具体取值,那就不太好了。然而,如果要加入缓存中的是NSData 对象,那么就不妨指定“开销值”了,可以把数据大小当作“开销值”来用。因为NSData 对象的数据大小是已知的,所以计算 “开销值”的过程只不过是读取一项属性。
下面这段代码演示了缓存的用法:
#import <Foundation/Foundation.h>
// Network fetcher class
typedef void (^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
- (id)initWithURL:(NSURL *)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler;
@end
// Calss that uses the network fetcher and cache results
@interface EOCClass : NSObject
@end
@implementation EOCClass {
NSCache *_cache;
}
- (id)init {
if ((self = [super init])) {
_cache = [NSCache new];
// Cache a maximum of 100 URLs
_cache.countLimit = 100;
/**
* The Size in bytes of data is used as the cost,
* so this sets a cost limit of 5MB.
*/
_cache.totalCostLimit = 5 * 1024 * 1024;
}
return self;
}
- (void)downloadDataForURL:(NSURL *)url {
NSData *cachedData = [_cache objectForKey:url];
if (cachedData) {
// Cache hit
[self useData:cacheData];
} else {
// Cache miss
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data) {
[_cache setObject:data forKey:url cost:data.length];
[self useData:data];
}];
}
}
@end
在本例中,下载数据所用的 URL,就是缓存的键。若缓存未命中(cache miss)(意思是指缓存中没有访问者所需的数据),则下载数据并将其放入缓存。而数据的“开销值”则设为其长度。创建 NSCache 时,将其中可缓存的总对象数目上限设为 100,将“总开销”上限设为 5MB,不过,由于“开销值”以“字节”为单位,所以要通过算式将 MB 换算成字节。
还有个类叫做 NSPurgeableData,和 NSCache 搭配起来用,效果很好,此类是NSMutableData 的子类,而且实现了 NSDiscardableContent 协议。如果某个对象所占的内存能够根据需要随时丢弃,那么就可以实现该协议所定义的接口。这就是说,当系统资源紧张时,可以把保存 NSPurgeableData 对象的那块内存释放掉。NSDiscardableContent 协议里定义了名为 isContentDiscarded 的方法,可用来查询相关内存是否已释放。
如果需要访问某个 NSPurgeableData 对象。可以调用其 beginContentAccess 方法,告诉它现在还不应丢弃自己所占据的内存。用完之后,调用 endContentAccess 方法,告诉它在必要时可以丢弃自己所占据的内存了。这些调用可以嵌套,所以说,它们就像递增与递减引用计数所用的方法那样。只有对象的 “引用计数”为0 时才可以丢弃。
如果将 NSPurgeableData 对象加入NSCache,那么当该对象为系统所丢弃时,也会自动从缓存中移除。通过 NSCache 的 evictsObjectsWithDiscardedContent 属性,可以开启或关闭此功能。
刚才那个例子可用 NSPurgeableData 改写如下:
- (void)downloadDataForURL:(NSURL *)url {
NSPurgeableData *cachedData = [_cache objectForKey:url];
if (cachedData) {
// Stop the data being purged
[cacheData beginContentAccess];
// Use the cached data
[self useData:cachedData];
// Mark that the data may be purged again
[cacheData endContentAccess];
} else {
// Cache miss
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data) {
NSPurgeableData *purgeableData = [NSPurgeableData dataWithData:data];
[_cache setObject:purgeableData forKey:url cost: purgeableData.length];
// Don't need to beginContentAccess as it begins
// with access already marked
// Use the retrieved data
[self useData:data];
// Mark that the data may be purged now
[purgeableData endContentAccess];
}];
}
}
注意,创建好 NSPurgeableData 对象之后,其 “purge 引用计数” 会多1,所以无须再调用 beginContentAccess 了,然而其后必须调用 endContentAccess,将多出来的这个 "1" 抵消掉。
END