这里是源码地址,该文章是基于commit43d94d7 on 25 Jan 的NSCache版本
从源码看本质
NSCache可以用内存缓存对象(比如常见的图片),相比于NSMutableDictionary,使用NSCache会有以下特点:
- 线程安全
- KeyType不需要实现NSCopying
- 支持限制缓存空间和数量,达到峰值自动清理
NSCache的内部实现包含:
- NSMutableDictionary: 保存数据和索引
- NSLock: 通过每次
lock()
和unlock()
保证了字典读写操作的线程安全 - NSCacheKey: 作为字典key的封装类,自身实现了hash和isEqual方法;即使存在没有实现Hashable的对象作为key,也可以借助NSObject提供的hashValue
- NSCacheEntry: 字典value的封装类,以及包含额外信息:
- cost: 记录对象占用内存空间的size值
- prevByCost: 链表中的前一个对象
- nextByCost: 链表中的后一个对象
至于NSCache为什么还要把缓存的对象相互连接成一个链表呢?答案是方便自动清理。从实现逻辑可以看出,NSCache还包含一个head指针,每次给缓存字典里增加一个新的对象时,同时执行链表插入操作。插入规则是:根据对象的占用内存的空间值cost的大小,将占用内存最小(即cost最小)的对象作为head,向后按大小顺序将对象插入到链表中合适的位置,最终形成一个按cost由小到大顺序排练成的有序链表。链表的特点是节点的快速插入和删除,所以链表的创建几乎可以不用在意性能损耗。当一个有序链表形成后,每次添加缓存对象时,都会检查是否达到缓存设置的峰值,如果超过峰值,就开始从head位置依次删除对象,直到缓存占用空间/数量回归到设定限制之内。
相比之下,AFNetworking也有个图片缓存类叫做AFAutoPurgingImageCache
(这里是源码地址 版本基于d6db830 on 9 Oct 2018),从它的实现可以看出,每次缓存图片时都会按照图片的访问时间进行排序操作,然后再依次删除时间较早的图片。这时候其实就可以看出有序链表的优势所在了,不仅插入迅速,而且提前建立了顺序,这样总比每次删除时候临时排序要节省额外的开销。
缺失必要的缓存淘汰算法
看了源码的实现,每次自动清理缓存的时候,删除节点的顺序是从链表的head开始,依次向后清理缓存数据。那么问题在于链表的排序是cost排序,如果出现对缓存对象无法估算占用空间的话,就会导致建立的链表丧失了“有序”的概念,每次添加cost为0的对象,就只能保存在head位置。
比如我用NSCache来缓存图片,然后设置countLimit
等于10,即最多允许缓存10张图片。缓存的时候正常调用[cache setObject:image forKey:imageURL]
,而该方法默认缓存对象的cost为0,即没法估算图片的占用存储空间,因此每次缓存图片时,只能把图片依次插入在head节点。等到缓存图片数量超过10张以后,NSCache因为数量限制原因,开始从head位置清理图片,这就导致每次只能清理掉最新缓存的图片,而最早保存的10张图片就一直占据着缓存里,不会释放,这样的实现其实并不科学对吧。
自swift-corelibs-foundation
开源以来,NSCache一直保持的应该就是这种简单的算法,而常见的缓存淘汰算法其实可以使用LRU
之类的算法,优先清理最早访问的缓存数据,比如AFAutoPurgingImageCache
的做法就是如此,但是使用链表来实现的话,也许效果会更好,而我们应该只需要将链表的排序规则改成让head永远指向最近访问的节点,然后从链表尾部开始依次向前删除数据,就可以了。
不过话说回来,使用NSCache做iOS开发,即使不去设置NSCache的空间或数量限制条件,只要响应App内存警告通知的时候及时清理缓存的话,使用起来也没什么问题,所以有没有更好的缓存淘汰算法,也变得无所谓了。
被遗弃的NSDiscardableContent
NSCache有个叫做evictsObjectsWithDiscardedContent
属性,文档解释是:
If YES, the cache will evict a discardable-content object after its content is discarded. If NO, it will not. The default value is YES.
关于这里的discardable-content,据说是objc中实现了NSDiscardableContent
协议的对象,这里是苹果的文档,里面描述了它的来龙去脉。但是NSCahe源码里没有对evictsObjectsWithDiscardedContent
进行任何实现,可能是不想太复杂或者没有人用吧。
关于计算cost的想法
默认的NSCache没有实现便捷的下标方法Subscript
,即cache[url] = image
,我想之所以不提倡这么做,很可能是因为我们无法传递cost参数,可是图片占用的内存空间是可以计算(或者估算)的。所以如果被缓存的对象有能力计算出自己所占用内存的数值,为什么不使用协议来解决问题呢?
设想一下,我们尝试定义一个NSCacheObjectCostCalculatable
的协议,只要求返回一个cost值。
@objc protocol NSCacheObjectCostCalculatable {
var totalBytes: UInt { get }
}
然后我们让UIImage
来实现它,这里参考了AFAutoPurgingImageCache
的做法:
extension UIImage: NSCacheObjectCostCalculatable {
var totalBytes: UInt {
let bytesPerPixel: CGFloat = 4
let pixelWidth = self.size.width * self.scale
let pixelHeight = self.size.height * self.scale
let estimatedBytes = bytesPerPixel * pixelWidth * pixelHeight
return UInt(estimatedBytes)
}
}
当然如果这个实现不够精准的话,也可以参考SDWebImage的计算图片cost的方式,毕竟NSCache的源码中也提到limits are imprecise/not strict
这样的情况,所以这里就不争论计算cost哪家强的事情了。既然UIImage
实现了自动计算占用空间的协议,那么源码就可以这么改:
open func setObject(_ obj: ObjectType, forKey key: KeyType) {
if let calculatable = obj as? NSCacheObjectCostCalculatable {
setObject(obj, forKey: key, cost: calculatable.totalBytes)
} else {
setObject(obj, forKey: key, cost: 0)
}
}
我觉得,NSCache源码可读性还是很高的,思路简洁清晰。但是可能是历史遗留或兼容等问题,目前这个缓存类没有在Swift的源码中有更理想的实现,不过还是非常值得借鉴和学习的。