背景
QQ阅读在首次启动,进入阅读页之后,呼出上下菜单,点击设置,会出现明显的卡顿现象,而之后再次操作便不会出现相同的问题。
首次分析解决
通过Instrument测量发现,大量的时间都集中在加载图片的逻辑里:[UIImage imageNamed:]
,此时也就可以理解为什么只有首次才会出现卡顿了。
通过hook [UIImage imageNamed:]
方法,直接将设置所使用到的图片全部截获输出,发现竟然需要这么多图片的加载
首先想到的解决方案是异步加载这些本地资源的图片,但是感觉工作量很大,于是采取了另外一种方案:在进入阅读页的时候,添加一个观察着,在主线程空闲的时候,去主动预加载这些图片,直到这些图片全部加载完成,然后主动去除观察者
优化之后直观上感觉好了一些,进行了数据对比
iPhoneX数据对比 | 优化前 | 优化后 |
---|---|---|
首次启动 | 0.9481920s | 0.0424747s |
非首次启动 | 0.0322121s | 0.0330871s |
非首次启动 | 0.0308346s | 0.0291347s |
可以看出,优化后,首次启动的时间和非首次启动的时间已经比较接近了,但是这里实际上还有待于进一步挖掘为什么首次的时间就一定长。
虽然进步不少,但是不太稳定,有时候还是会莫名其妙地卡顿,相比于前面提到的进一步缩短时间,这个问题更值得深究。用了以下几种方案测试
- 每次杀掉进程,重新启动App,然后进入阅读页,首次点击设置,卡顿不明显;
- 每次在阅读页切到后台,然后再切到前台,点击设置,卡顿必现;
- 竖屏切横屏,横屏切竖屏,有时候会卡顿;
关于第2点,怀疑是app在切后台的时候,UIImage的系统缓存会被清除,所以在阅读页切到前台的时候,再进行1次预加载
关于第3点,怀疑是app在切换横竖屏的时候,UIImage的系统缓存会被清除
另外还有一点,就是在内存紧张的情况下,UIImage的系统缓存会被清除。
最后各种转屏,切后台,切夜间模式,杀掉重启,效果比原来稳定了许多,该方案目前有一个缺陷,就是如果用户从后台切到前台,迅速点击设置按钮,还是会有些卡顿,原因就是图片尚未预加载完毕,毕竟虽然这些图片的加载都是利用主线程的闲暇时间完成的,但是这些图片预加载的总时间一点都节省不下,这点时间代价还是要付出的
二次分析解决
就在我以为问题已经解决的时候,另一个同学提出了将用到的图片资源简单地放到image.xcassets
中就可以解决这样的性能问题。出于好奇,把前文中提到的所有图片(27张图片)都挪到了image.xcassets中,并写了一个test方法,同时加载这些图片,并且和未挪动之前进行了对比
对比结果如下:
iPhoneX数据对比 | 图片文件夹 | image.xcassets |
---|---|---|
首次启动 | 0.99100500s | 0.00879625s |
非首次启动 | 0.00311238s | 0.00295617s |
非首次启动 | 0.00290383s | 0.00289400s |
image.xcassets中图片的恐怖的加载速度已经突破天际,它的加载速度和普通文件夹中的图片的加载速度已经不在一个数量级上了,前后差了2个数量级了
在把所有图片都挪到了image.xcassets的基础上,再次对比了优化前和优化后的速度
iPhoneX数据对比 | 优化前 | 优化后 |
---|---|---|
首次启动 | 0.0384008s | 0.0360755s |
非首次启动 | 0.0348377s | 0.0342009s |
非首次启动 | 0.0312605 s | 0.0307469s |
可以看出,优化的优势已经荡然无存,这种情况下,优化代码已经没有存在的必要了,所以二次优化的结果就是:
去除首次优化的代码,将图片挪到image.xcassets
中
!!!大写的尴尬 !!!
Asset Catalog的探索
二次优化后,被成功了炸回了石器时代,令人纳闷的是:为什么image.xcassets中的图片会有这么惊人的加载速度?
Asset Catalog 是 Xcode 提供的项目资源管理工具,其核心理念在于:以设备特征(traits)为单位配置资源,包括但不限于images, sprites, textures, ARKit resources, colors和 data、PDF。既让开发者免于代码配置资源的烦恼,也让苹果能够更好的控制包的大小
WWDC2018:Optimizing App Assets 一文中提到:
自动图片打包
在assets分类之前,仅仅是将图片直接放到工程的bundle中组织,这种方式存在一些缺点:
- 每个图片资源都会存储自己的元数据和其他的一些属性信息,如果存在很多同类型的资源,这些相同的信息会产生冗余,造成空间浪费;
- 压缩只针对到文件级别,那么对于一些小的资源文件,也没有进行充分的压缩;
- 在组织文件的时候,处理大量分散的图片资源会比较麻烦,而且调用的接口也不一样;
- 图片格式、属性的不一致性,例如一堆图片文件中,有的支持透明通道,而有的不支持;
Asset Catalog会根据图片的图谱对图片进行分类,通过图集的方式进行存储,帮我们处理图片的压缩以及元数据的存储工作。
- 同一种图片资源只需要存储一份元数据
- 图片数据得到充分的压缩,图片资源越大使用assets分类后优化的效果就越明显
这里的存储方式有点类似于创建texture atlas
,维基百科解释如下:
- a texture atlas (also called a sprite sheet or an image sprite) is an image containing a collection of smaller images, usually packed together to reduce the atlas size。
这在游戏领域屡见不鲜。
有损压缩
assets分类支持HEIF(High Efficiency Image File Format
),并且将该格式作为assets分类有损压缩的默认格式
- 比现有的格式图片更大的压缩率
- 支持透明度
- assets分类可以自动将其他类型的图片转换为HEIF
无损压缩
无损压缩是默认的压缩形式,工程中大部分assets分类都是使用的无所压缩
图片资源可以根据使用的色谱和轮廓分为两类,不同的形式在有损压缩时会有不同的优化方式。一种是简单的图片资源,这类使用了简单的配色和使用简单的设计简单,例如很多应用的icon。另一类指的是复杂的图片资源。有损压缩针对这两种形式都做了不同形式的优化
Apple Deep Pixel Image Compression是苹果新引入的一种压缩形式,这是一种灵活的压缩形式,会根据图片的色谱特性选择最优的算法进行压缩
- 会适配图片色谱
- 选择最优的压缩算法
- 压缩的大小能够提高15-20%
应用瘦身
各取所需
结论1
可以看出,App Assets通过分类、压缩等途径,能够有效地减小资源包的大小,进而给应用瘦身。的确,资源变小了,加载速度可以提升,但是这仍然无法解释为什么图片的加载速度差异达到2个数量级之多
.car文件探索
assets里面所有的资源在编译过程结束后会被编译为.car文件,关于.car文件的结构,苹果讳莫如深:
If your project has an iOS 7 deployment target, Xcode compiles your asset catalogs into a runtime binary file format that improves the speed of your app
文件结构
通过查询资料,发现.car文件实际上是一种特殊的bom文件,而bom(Bill of Materials)是从NeXTSTEP继承下来的一种文件格式,命令行输入:man 5 bom1
The Mac OS X Installer uses a file system "bill of materials" to determine which files to install, remove, or upgrade
类似于bom文件,car文件中包含一些blocks结构1
2
3
4
5
6CARHEADER // 头部信息
EXTENDED_METADATA // 扩展的元信息
KEYFORMAT
CARGLOBALS
KEYFORMATWORKAROUND
EXTERNAL_KEYS
同时包含一些trees结构:1
2
3
4
5
6
7
8
9
10
11FACETKEYS
RENDITIONS
APPEARANCEKEYS
COLORS
FONTS
FONTSIZES
GLYPHS
BEZELS
BITMAPKEYS
ELEMENT_INFO
PART_INFO
在macos中,私有库CoreUI.framework
负责解析car文件,中间会调用Bom.framework
的C的API接口来解析BOM:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25typedef uint32_t BOMBlockID;
typedef struct BOMStorage *BOMStorage;
typedef struct BOMTree *BOMTree;
typedef struct BOMTreeIterator *BOMTreeIterator;
// Opening a BOM
BOMStorage BOMStorageOpen(const char *inPath, Boolean inWriting);
// Accessing a BOM block
BOMBlockID BOMStorageGetNamedBlock(BOMStorage inStorage, const char *inName);
size_t BOMStorageSizeOfBlock(BOMStorage inStorage, BOMBlockID inBlockID);
int BOMStorageCopyFromBlock(BOMStorage inStorage, BOMBlockID inBlockID, void *outData);
// Accessing a BOM tree
BOMTree BOMTreeOpenWithName(BOMStorage inStorage, const char *inName, Boolean inWriting);
BOMTreeIterator BOMTreeIteratorNew(BOMTree inTree, void *, void *, void *);
Boolean BOMTreeIteratorIsAtEnd(BOMTreeIterator iterator);
void BOMTreeIteratorNext(BOMTreeIterator iterator);
// Accessing the keys and values of a BOM tree
void * BOMTreeIteratorKey(BOMTreeIterator iterator);
size_t BOMTreeIteratorKeySize(BOMTreeIterator iterator);
void * BOMTreeIteratorValue(BOMTreeIterator iterator);
size_t BOMTreeIteratorValueSize(BOMTreeIterator iterator);
举个栗子
该Asset Catalog包含以下文件:
1、a PNG with 3 resolutions @1x, @2x and @3x
2、a PDF
3、a text file
4、a jpg image
5、a color (red with 50% transparency)
编译之后,Asset Catalog里面所有的资源都会被编译为Assets.car文件(后缀名的含义是:Compiled Asset Catalogs)
命令行输入: assetutil -I Assets.car
1 | [ |
以加载其中的png图片为例:1
UIImage *myImage = [UIImage imageNamed:@"MyImage"];
当程序加载assets中的MyImage图片时,最终会找到图中的二进制流,并利用CUIThemePixelRendition
完成数据解析,找到图片的各种信息
1 | struct CUIThemePixelRendition { |
1 | if(csiHeader->pixelFormat == 'ARGB' || csiHeader->pixelFormat == 'GA8 ' || |
这个是图片对应的二进制流:
依次解释颜色含义:
- 蓝色:csiheader,长度固定为184字节
- 绿色:TLV列表
- 粉红色:tag标记:CELM
- 黄色:版本号,总是被设置为0
- 深蓝色:压缩算法RenditionCompressionType
- 水红色:图片数据长度
- 红色:图片数据,可能被压缩
压缩算法包含以下类型:1
2
3
4
5
6
7
8
9
10
11
12
13enum RenditionCompressionType
{
kRenditionCompressionType_uncompressed = 0,
kRenditionCompressionType_rle,
kRenditionCompressionType_zip,
kRenditionCompressionType_lzvn,
kRenditionCompressionType_lzfse,
kRenditionCompressionType_jpeg_lzfse,
kRenditionCompressionType_blurred,
kRenditionCompressionType_astc,
kRenditionCompressionType_palette_img,
kRenditionCompressionType_deepmap_lzfse,
};
更多的细节可以参考Reverse engineering the .car file format
结论2
- 这部分内容可以确认的事实是:当加载assets的图片时,不会再进行read I/O 系统调用,而是直接拿到了图片的二进制流数据,这好像离目的地近了一些
- 中间用到了CoreUI.framework和BOM.framework两个私有的framework
实验求证
调用栈
通过hook NSCache
的setObject:forKey:cost:
方法,确认了[UIImage imageNamed:]
的调用堆栈,从其中可以看到UIAssetManager
的使用,也可以看到CoreUI.framework
中的CUICatalog
的使用
优先级
实现过程中,发现有个现象很有意思:普通文件夹中和assets中有两张命名一样的图片,只是内容不一样,这个时候用[UIImage imageNamed:]
加载该文件名时,会显示assets中的图片
而如果assets中没有该文件时,肯定会显示普通文件夹中的图片
imageNamed实际上调用的是一个叫做UIAssetManager
的类,每个Bundle有一个UIAssetManager
。它有一个strong-strong的NSMapTable的属性,用来做缓存。这里推测下imageNamed的加载顺序:
- 首先从缓存中查找,如果命中直接返回;
- 如果查询不到缓存,那么接着命中的是Assets.car,这个是
CoreUI.Framework
处理的(私有framework),会解压(有缓存)那个Assets.car然后解码取回图。- 如果还找不到,会通过Bundle的path,按照搜索@3x @2x @1x .png等规则,直到找到一个那种非Assets.car的bundle图,然后加载。
虚拟内存和I/O
读取assets中的图片
1 | - (void)btnAction |
首次加载assets中的图片时,会首先触发car文件的打开操作。
另外,car文件的加载属于懒加载方式,由于本次实验中启动的时候没有图片加载,所以没有看到car文件的加载,只有在首次读取加载图片的时候,才会触发car文件的读取。由于前文中提到无论图片是否在assets中,都要去UIAssetManager
中查询一次,所以这里也同样,无论图片是否是来自assets中,都会触发car文件的打开操作
注意图中的Requested Bytes
,无论读取的图片的大小是多少,这里都统统是512 Bytes(实验中在assets中添加了1张2M的图片,进行加载,这里仍然是512 Bytes)
同时也会 触发虚拟内存的映射,更准确的描述是:
图片的加载逻辑[UIImage imageNamed:@"title_page_card_placeholder"];
会触发VM:CoreUI image file
而图片的渲染逻辑_tempView.image = image
则会触发VM:IOSurface
,它的Responsible Library
是CoreUI
,Responsible Caller
是__csiCompressImageProviderCopyIOSurfaceWithOptions
。
还记得前面提到的CoreUI.framework
吗
还记得前面提到的蓝色的184字节的csiheader吗?
能对应起来吗?
另外这里从命名上可以推测基本上是内存级拷贝了
读取普通文件夹中的图片
(1)首先会触发I/O1
2
3
4
5- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
UIImage* image = [UIImage imageNamed:@"header"];// header来自普通文件夹
_tempView.image = image;
}
(2)其次图片的加载逻辑[UIImage imageNamed:@"header"];
会触发VM:CoreUI image file
,这个和上面类似
(3)图片的渲染逻辑_tempView.image = image;
则会触发VM:ImageIO_PNG_Data
,它的Responsible Library
是ImageIO
,Responsible Caller
是ImageIO_Malloc
,
结论3
(1).car文件并不是整个文件都加载到内存中,而是在首次加载的时候使用了虚拟内存,在加载对应图片的时候,如果对应的物理页还尚未加载到内存,会产生一个缺页中断,等物理页加载到内存中,然后会使用BOM.framework
寻找到对应的二进制流,
(2)普通文件夹中图片的读取涉及到了I/O操作,在数量很多的情况下,无疑会拖慢速度;
(3)二者都会用到虚拟内存,后者在调用ImageIO的时候还用到了内存映射(mmap)
再次求证(更新)
前文中一直在寻找各种证据来解释[UIImage imageNamed:]
为什么会如此惊人的加载速度,而对[UIImage imageWithContentsOfFile:]
却没有提及。
实际上在之前的求证过程中也是做过比较的:同样的一张普通文件夹中的图片,两次冷启动,分别用[UIImage imageNamed:]
和[UIImage imageWithContentsOfFile:]
加载,得到了这样的结果:1
2Time taken for [[UIImage imageWithContentsOfFile:]] =0.0204472 s
Time taken for [[UIImage imageNamed:]] =0.0784136 s
一开始对这个结果没有太在意,因为当时觉得在情理之中:
- 1、二者的加载速度的确在同1个数量级上,毕竟二者都需要I/O操作
- 2、
[UIImage imageNamed:]
之所以相对慢一些,是因为在加载真正的文件之前,得先去.car的缓存系统中寻找一遍,直到最后没发现,再用路径加载,这在前面也提到过。
直到后来,我做了一次详细的数据统计对比:
10张图,screen0 ~ screen9,每张图片的的大小在1M左右,分别用如下3种方式加载:
图片名 | 普通文件夹imageNamed | 普通文件夹imageWithContentsOfFile | car文件 imageNamed |
---|---|---|---|
screen0(首次加载) | 0.1158520s | 0.04483080s | 0.00190367s |
screen1 | 0.0387474s | 0.00345462s | 0.00038454s |
screen2 | 0.0434894s | 0.00318442s | 0.00032900s |
screen3 | 0.0366609s | 0.00326258s | 0.00031320s |
screen4 | 0.0389902s | 0.00253567s | 0.00029370s |
screen5 | 0.0437911s | 0.00245288s | 0.00036012s |
screen6 | 0.0390221s | 0.00206237s | 0.00030758s |
screen7 | 0.0394655s | 0.00203346s | 0.00031808s |
screen8 | 0.0404458s | 0.00191017s | 0.00030712s |
screen9 | 0.0417215s | 0.00195079s | 0.00031966s |
total | 0.4788200s | 0.06915210s | 0.00616221s |
换个角度看一下上面的数据:
统计结果 | 普通文件夹imageNamed | 普通文件夹imageWithContentsOfFile | car文件 imageNamed |
---|---|---|---|
均值 | 0.040259s | 0.002424s | 0.000326s |
方差 | 0.0023475 | 0.000543 | 2.8735e-05 |
看到结果不由地再次陷入了深思:
- 3种加载方式首次加载耗时都比较长,
imageNamed
的加载方式可以理解为car文件的加载、内存映射和解析,那么imageWithContentsOfFile
又如何解释?- 除了首张图片的加载,普通文件夹的
imageNamed
和imageWithContentsOfFile
的加载耗时有了1个数量级的差距,同样是I/O操作,只是imageNamed
多了”1轮检查”,难道就是因为这1轮检查就差了1个数量级的加载速度?- car文件的加载速度无疑还是最厉害的,和前两者分别差了2个数量级和1个数量级;
imageWithContentsOfFile
的加载过程
这里分别前后加载了两张图片,分别代表首次加载和非首次加载
时间对比下来,时间也就差了差不多2倍左右,这个和前面测出来的数据还是有一定的出入(暂时无法解释why),从堆栈来看,有区别的是这个地方:
推测首次加载耗时长可能和一些必要初始化操作有一些关系
imageNamed
加载普通文件夹中的图片过程
可以看出,对于普通文件夹中的图片,imageNamed
优先到assets中去寻找,并且寻找的这个过程rendtionWithKey
占了绝大多数的时间,当然结果肯定是寻找不到的,最后还是会调用UIAssetManager newAssetNamed:fromBundle:
方法从bundle中去寻找,这个过程与前面的推理是一致的,只是assets的寻找占用了如此之多的时间是我始料不及的。
从图中可以看出rendtionWithKey
占用耗时最多的3部分
这个方法不知道苹果是否会进行优化?
imageNamed
加载assets中的图片过程
看完前面两位的表现,再来看下imageNamed
加载assets中的图片为什么如此逆天,
首次加载时间长的原因:
(1).car文件本身需要加载1次,需要进行I/O操作;
(2)需要UIAssetManager
的初始化操作;
imageNamed
加载assets资源快的原因:
(1)一方面,.car文件本身只需要极其有限的I/O操作,而bundle中资源每读取一个资源,都需要相应的I/O操作。
(2)另一方面,.car文件里面的各种资源的信息都是提前编译好的,几乎都是快速定位,而普通文件夹中加载需要先读取图像获取其参数,再生成rendition和renditionKey,并进行需要大量耗时的canGetRenditionWithKey操作.
这里需要搞清楚两个概念:[内容摘自这里](https://juejin.im/post/5cb74d786fb9a068773948fc#heading-8)
- 什么是rendition ?
rendition是 CoreUI.framework 对某一图像资源的不同样式的统称,如@1x,@2x,每一个rendition有一个renditionKey与之对应,renditionKey包含了不同的attribute,用于记录图片资源的参数
CUIMutalbeStructuredThemeStore
与CUIStructuredThemeStore
是什么东西?
可以将它们理解成 imageSet ,其中包含了不同的图像资源
结论4
从上述图片中可以看出,
1、如果图片资源在.car文件中,那么
imageNamed
会用到CUIStructuredThemeStore
,在Assets Catalogs被编译的时候。CUIStructuredThemeStore
对应的各种信息已经被编译到.car文件(一种BOM文件),那么读取的时候直接加载即可。2、如果图片在bundle中,
imageNamed
会用到CUIMutalbeStructuredThemeStore
。AssetManager没有办法提前知晓该文件的信息,只能通过加载文件获取,并动态添加到CUIMutalbeStructuredThemeStore
,并在首次加载后进行缓存,这个过程自然会慢。3、对于不在assets中的图片来说,
CUIMutalbeStructuredThemeStore
的renditionWithKey
方法占用了大部分耗时。个人觉得renditionWithKey
这个方法有待于进一步优化,尤其是对于追求极致的苹果。从imageWithContentsOfFile
的测试结果可以看出,直接对Bundle中资源的读取虽然慢一些,但是绝对不应该如此之慢,imageNamed
读取bundle中的图片资源变慢或多或少有点躺枪的味道。可以这么说,苹果在增强imageNamed
读取assets
资源速度的同时,也拖慢了imageNamed
读取bundle
中资源的速度,这也就解释了QQ阅读为什么从某个版本开始,启动变得卡顿,而之前却很流畅,同时也解释了为什么简单地把图片资源移动到assets中就可以解决性能问题。无论如何,从现在开始应该谨慎一点:绝对不应该用imageNamed
读取bundle
中的图片资源,这种读取方式目前来看是最慢的4、无论资源是来自assets,还是来自bundle,
UIAssetsManager
都需要建立自己的缓存体系,那么对于assets和bundle,就需要一定的统一规范。天生的assets在编译期间就完成了规范,运行的时候其中资源的各种信息都是就绪状态,而bundle中的资源需要等到运行时才能通过加载读取得到资源的具体信息才能完成这一行为。
结论
1、资源文件如果没有什么特殊需求,尽量迁移到assets中,同时这也是苹果的建议;
2、对于占用内存大的图片资源,最好放到bundle中,用imageWithContentsOfFile
读取,这样可以精确控制内存。
3、Asset Catalog中的资源有如此惊人的加载速度,苹果却谦卑地只字不提,大概是不以为然吧。
参考链接
WWDC2018:Optimizing App Assets
Reverse engineering the .car file format
iOS拾遗—— Assets Catalogs 与 I/O 优化