美文网首页
源码阅读笔记NSCache

源码阅读笔记NSCache

作者: __huangkun__ | 来源:发表于2019-07-22 20:11 被阅读0次

    这里是源码地址,该文章是基于commit43d94d7 on 25 Jan 的NSCache版本

    从源码看本质

    NSCache可以用内存缓存对象(比如常见的图片),相比于NSMutableDictionary,使用NSCache会有以下特点:

    1. 线程安全
    2. KeyType不需要实现NSCopying
    3. 支持限制缓存空间和数量,达到峰值自动清理

    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的源码中有更理想的实现,不过还是非常值得借鉴和学习的。

    相关文章

      网友评论

          本文标题:源码阅读笔记NSCache

          本文链接:https://www.haomeiwen.com/subject/dldglctx.html