[UIImage imageNamed:] 缓存策略窥探

都知道 [UIImage imageNamed:] 有一个缓存,但是试想,如果我们要对沙盒里的图片也做一个缓存,这个缓存应该怎么设计,似乎不是那么容易解答的问题。这么一想,[UIImage imageNamed:] 到底是如何设计这个缓存的,倒是一个可以探究的问题。

在探究之前,先试想一下,如果是我来设计 [UIImage imageNamed:] 的缓存,需要考虑哪些点?
我能想到的有这样几点:

  1. [UIImage imageNamed:] 大部分情况下都是在主线程调用的,那么高效的磁盘 IO 很关键,并且需要尽可能减少 IO 的次数,防止卡顿;
  2. 图片最终显示前需要解码成 Bitmap,缓存图片压缩后的 Data 更好,还是缓存解码后的 Bitmap 更好,还是都缓存下来?显然 Bitmap 比对应的 Data 大几倍,如果选择缓存 Bitmap,那么可以缓存的图就更少,可能导致触发更多 IO;而如果选择缓存 Data,那么解码的开销便会增大。
  3. 图片缓存应该在什么场景下清理?memory warning 时显然需要清理,那退后台需要清理么?需要设置缓存的最大值么?

基于这些想法,在窥探 [UIImage imageNamed:] 真正的缓存策略时,我也带上了这样几个问题:

  1. 图片解码发生的时机是什么?
  2. 磁盘 IO 发生的时机是什么?
  3. 缓存的内容是什么?
  4. 清理缓存时机是什么?

这篇文章就是对这几个问题的探索。

问题一:图片解码发生的时机是什么?

一句话回答:在 UIImage 显示到屏幕上时。

同事的博客里对解码的时机有比较详细的介绍。总结下来就是,除了 Force Decode,只有当 UIImage 显示在屏幕上时,解码操作才会被触发。

通过设计一些实验,观察 app 内存水位的变化,也能快速验证这个结论。

但是,能预想到,后续的很多分析会和这个解码时机有较强的关联。如果能知道到底那个函数代表了解码操作,那后续的验证也会有力很多。
这里我能想到的窥探方法,是使用 Instruments 的 Time Profile 功能。可以想象,解码一张大图一定是一个比较耗时的操作,很可能能被 Time Profiler 捕捉到。于是设计 Demo,果然在 Time Profiler 中发现了一个调用栈:


用符号断点也可以验证,只有当即将有 UIImage 展示到屏幕上时,这个调用栈才会触发。因此假设,CUIUncompressDeepmap2ImageData 是解码(至少是我的 Asset Catalog 中的那张图解码)会走到的函数。

之后我们可以用符号断点 CUIUncompressDeepmap2ImageData 是否触发来验证一些缓存策略。

问题二:磁盘 IO 发生的时机是什么?

一句话回答:第一次读取某个 Assets.car 时,会有 open 和 read 操作(推测是读取索引),但真正读图的时机,是解压的时机,也就是 UIImage 展示到屏幕上时。

使用 Instruments 的 File Activity,可以窥探 UIImage imageNamed: 是如何处理 IO 的,可以很容易地找到什么时间、什么调用栈读取了 Assets.car。


结合符号断点也容易验证,第一次调用 [UIImage imageNamed:],会触发 open 和 read 的系统调用。但之后再次从同一个 Assets.car 中获取 UIImage,open 和 read 就不再被调用了。



从内存占用和设计的合理性上看,这个 read 操作并不是一次性把 Assets.car 的内容都读到了内存中。结合 File Activity 中,大部分读取 Assets.car 的 Operation 是 Page In,说明真正读取图片数据,用的是内存映射的方式。

那么发生 Page In 的时机又是什么时候呢?是调用 [UIImage imageNamed:] 的时候,还是图片展示到屏幕上的时候呢?通过实验和堆栈可以推测,至少大部分的 Page In 的时机,是图片展示到屏幕上,也就是解码的时机。


而且 Page In 时的堆栈也是 CUIUncompressDeepmap2ImageData。说明解码时,是一边从磁盘中 Page In 一边解码的。

问题三:缓存的内容是什么?

一句话回答:缓存了 CGImage(如果已经解码,相当于缓存了 Bitmap),图片的 Data 通过内存映射也达到了缓存的目的。

首先我们可以用 Xcode Memory Graph 来窥探,CGImage 对象是被什么持有的。
这时 Demo 中的图像已经不再展示在屏幕,已知的强持有也释放了,但 CGImage 对象依然存在内存中。这里能看到一条持有它的链条:


最直接的是,CGImage 对象被 CUIStructuredThemeStore->_cache 持有着。
CUIStructuredThemeStore->_cache 是一个字典,用 debugger 打印它的样例内容:

po ((CUIStructuredThemeStore *)0x6000021f8c80)->_cache
{
    "0{0-0-3-0-0-0-0-0-0-0-0-0-0-0-0-0-8019-55-b5-0-0" = "<_CUIThemePixelRendition: 0x7ff18461cae0> -- Rendition name: [email protected]";
}

怀疑这就是 imageNamed: 的缓存。

通过 CUIStructuredThemeStore 的头文件 猜测和验证,CUIStructuredThemeStore 有一个

NSCache* _namedRenditionKeyCache;

其中 key 是 Image Set 的名字,value 是一个 struct renditionkeytoken,这个 renditionkeytoken 可以通过 ​keySignatureForKey:​ 转换成 key signature(一个 NSString),即 _cache 字典的 key。

所以如果简化理解一下,图片的缓存是一个 NSDictionary,可以根据 Asset Catalog 中 Image Set 的名字,找到缓存的 CGImage 对象。

啊对了,Memory Graph 里,CUIStructuredThemeStore 还被一个 MapTable 持有,实验了一下,一个 MapTable 中的元素,对应了一个 Assets.car。也就是说如果 App 中有多个 Assets.car,它们的缓存是隔离的。

那图片的 Data 有没有缓存呢?由于 Data 使用的是内存映射的方式,重复读取并不会发生多次 Page In,只有当内存紧张时,这块映射的内存才会被 Page Out,因此相当于一个天然的缓存。

问题四:清理缓存的时机是什么?

这里的“缓存”,仅讨论 CGImage 的缓存。
一句话回答:至少退后台、memory warning 会触发清空,但猜测缓存大小没有限制。

从 CUIStructuredThemeStore 的头文件 中,发现 CUIStructuredThemeStore 有个 clearRenditionCache 方法,看起来是用于清空缓存的。

符号断点验证,在 app 退后台、memory warning 时,clearRenditionCache 会触发,CUIStructuredThemeStore->_cache 也会被清空。


缓存清理后,下次创建 UIImage 并展示到屏幕上时,解码的 CUIUncompressDeepmap2ImageData 也确实被调用了。

那缓存的大小是否有限制呢?我猜测是没有的。
通过不断读取大图,我制造了缓存高达 600+MB 且还能继续上升的场景。结合缓存使用的 NSDictionary 而不是 NSCache,猜测在 memory warning 之前,缓存是可以持续增长的。

总结

一句话总结一下开头的四个问题:

问题一:图片解码发生的时机是什么?
在 UIImage 显示到屏幕上时。

问题二:磁盘 IO 发生的时机是什么?
第一次读取某个 Assets.car 时,会有 open 和 read 操作(推测是读取索引),但真正读图的时机,是解压的时机,也就是 UIImage 展示到屏幕上时,利用的是内存映射。

问题三:缓存的内容是什么?
缓存了 CGImage(如果已经解码,相当于缓存了 Bitmap),图片的 Data 通过内存映射也达到了缓存的目的。

问题四:清理缓存的时机是什么?
至少退后台、memory warning 会触发清空,但猜测缓存大小没有限制。

其中磁盘 IO 的部分,尤其感受到了,自研缓存想要超越 Assets.car 并不是一件容易的事情。

那还有一个问题:我们能利用 [UIImage imageNamed:] 的缓存做些什么?
一个启动优化的思路,是将启动期间需要读取的图片,预先在异步线程调用 imageNamed: 读取并画在一个画布上使它强制解码,等主线程真正要读取时,能减少主线程 Page In 和解码的开销。
也许还能像二进制重排一样对 Assets.car 进行重排,进一步降低 Page In 的开销。

相关资料

主流图片加载库所使用的预解码究竟干了什么
iOS拾遗—— Assets Catalogs 与 I/O 优化

你可能感兴趣的:([UIImage imageNamed:] 缓存策略窥探)