[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: my_image_375x640_@3x.png";
}

怀疑这就是 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 优化

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容