在过往的开发过程中有过一个疑问,iOS启动过程中Assets.catalog里图片是怎么加载的,但一直没时间去探索。 本文是与AI对话深度探索得来的,目的是拨开对Assets.catalog的疑云。
本文围绕 Asset Catalog 从编译到运行时的完整生命周期展开,涵盖 actool 处理逻辑、Assets.car 内部结构、零拷贝查找机制以及并发安全性等核心话题,帮助读者建立起对系统图片资源管理机制的深度认知。
一、流程图解
flowchart TB
subgraph A[编译阶段 - Xcode Build]
A1[Assets.xcassets]
A2[actool 编译工具]
A3{是 AppIcon 吗?}
A4[提取为独立 PNG 文件]
A5[深度编码与序列化]
A6[打包进 .app 包]
A1 --> A2
A2 --> A3
A3 -->|是 AppIcon| A4
A3 -->|普通资源| A5
A4 --> A6
A5 --> A7[写入 Assets.car]
A7 --> A6
end
subgraph B[分发阶段 - App Thinning]
B1[Archive / Export IPA]
B2[actool 再次处理]
B3[生成设备专用 Assets.car 变体]
B4[App Store 按设备精确下发]
B1 --> B2
B2 --> B3
B3 --> B4
end
subgraph C[运行时查找 - Cocoa Touch Run Time]
C1["UIImage named: my_icon"]
C2[调用 CUICatalog 实例]
C3["mmap 映射 Assets.car (零拷贝)"]
C4["哈希表: 名字 -> Facet Table"]
C5["降级匹配: idiom/scale/gamut/appearance"]
C6[获取最佳 CUIRenditionKey]
C7["查 Rendition Table 获取 Heap 偏移量"]
C8["mmap基址 + 偏移量 = CGImage 数据源"]
C9["返回零拷贝 UIImage (GPU优化格式)"]
C1 --> C2
C2 --> C3
C3 --> C4
C4 --> C5
C5 --> C6
C6 --> C7
C7 --> C8
C8 --> C9
end
subgraph D[线程安全性]
D1["UIImage named: 线程安全"]
D2["只读属性: 任意线程安全"]
D3["衍生操作 (withTintColor等): 需主线程"]
end
A6 --> C1
B4 --> C1
C9 --> D1
C9 --> D2
C9 --> D3
二、各个过程详细解析
1. 编译与打包:actool 做了什么
当我们按下 Cmd+B 时,Xcode 不会简单地把 .xcassets 目录复制到 .app 包。它会调用一个名为 actool(Asset Catalog Compiler) 的命令行工具,将整个 Asset Catalog 编译成一个名为 Assets.car 的单一二进制文件。
核心命令示意:
/usr/bin/actool --compile /path/to/Build/Products/YourApp.app \
/path/to/YourProject/Assets.xcassets
1.1 App Icon 的特殊处理
对于 AppIcon(应用图标),图片数据 不会 被放入 Assets.car。actool 会:
- 将 App Icon 的所有变体提取为标准的 PNG 文件,写入
.app根目录,如AppIcon60x60@2x.png - 在
Assets.car内部仅保留指向这些外部文件的引用
原因:系统进程(SpringBoard、通知中心等)需要在 App 启动前就读取应用图标。直接读取包内已知路径的 PNG 文件,远比穿透一个封闭的 .car 快。这是 iOS 的硬性约束。
1.2 普通图片的优化
A.自动图片合并:
- 解决的问题:大量零散的小图片,每个文件都有重复的 PNG 头、色域等元数据,会造成严重的存储空间浪费。
- 优化逻辑:
actool会自动识别色域、色彩空间等属性相近的小图片的纹理图集 ,把它们合并拼接成一张或多张更大苹果wwdc相关说明视频。- 收益:消除重复的文件元数据,并让压缩算法能更高效地处理更大面积的像素数据。在 Apple 给出的案例中,原始总大小超过 50KB 的若干小图,合并压缩后仅为原来的 20% 苹果wwdc相关说明视频。
B.智能图像压缩:
这是整个优化的核心。
actool不会保留原始的 PNG 或 JPEG,而是先解码为位图(Bitmap),再重新编码。
- 格式选择:
actool会根据图片内容和你设置的压缩选项,自动选择最优编码算法。- Apple Deep Pixel Image Compression:这是 iOS 12 引入的新型无损压缩,能根据图片色彩特征自适应选择算法。苹果数据显示,相比传统 PNG,它平均能减少 20% 的体积,并将解码速度提升 20% 。
- High Efficiency Image Format:这是 iOS 12 开始默认支持的有损压缩,压缩率远优于 JPEG,且原生支持透明度。
- Palette 编码:对于颜色种类少、风格扁平化的图标,
actool可能会用调色板编码替代直接存储 RGBA,极大减少数据量。- GPU 友好格式:
actool会考虑图片渲染时的效率,直接生成设备/GPU偏好的像素格式,省去运行时的二次转换,这也呼应了我们之前聊过的零拷贝加载。
C.元数据与切片固化和去重
actool会将你在 Xcode 中的编辑操作直接编译、固化到二进制文件中,而不是把计算留到运行时。
- 切片信息固化:图片的九宫格拉伸参数在编译时就被算好并写入资源元数据[key_AssetsCAR_Slicing]。运行时无需再解析或计算边缘 insets。
- 多尺度统一管理:同一图片的
@2x、@3x版本被组织为一个 "Image Stack" 元数据实体,这能帮助运行时通过一次查找就选出正确的图片。
1.3 其他资源类型
- Color Set:sRGB、Display P3、暗黑模式变体等颜色数据,被序列化为结构化二进制数据存入。
- Data Set:原始数据文件被序列化后直接存入。
- Symbol Configuration:SF Symbols 的配置(点大小、粗细)也会进去。
2. App Thinning:按设备分发专用资源
当你通过 Xcode 或 CI 导出 App Store 包时,actool 会再次介入,协同 App Thinning 机制生成设备专用的变体:
- 生成多个
Assets.car变体:根据芯片架构和屏幕尺度组合(如arm64_3x对应 iPhone 14 Pro,arm64_2x对应 iPhone SE),actool 为每个变体生成裁剪后的Assets.car,剔除目标设备不需要的格式和尺度。 - 生成资源清单:
AssetPackManifest.plist描述所有可用变体及其适用范围。 - App Store 按需下发:用户下载时,App Store 根据其设备型号精确下发对应的
Assets.car,显著减少最终下载体积。
3. 运行时查找:零拷贝与 mmap 的完美配合
App 启动后,UIImage(named:) 一行简单的调用,背后其实有一套精心设计的查找机制。
3.1 Assets.car 的内部结构
Assets.car 的二进制布局大致如下:
- Header:魔数、版本等元信息
- Key Format Table:定义各属性(idiom、scale、gamut、appearance)在位掩码中占的位布局
- Facet Table:逻辑资源名到一组
renditionkey的映射(即索引目录) - Rendition Table:每个具体变体的元数据行,包含格式、尺寸、切片信息以及 在 Heap 中的偏移量
- Heap:巨大的连续二进制块,直接存放 GPU 优化后的位图数据
3.2 mmap:零拷贝的基础
CoreUI 使用 mmap 系统调用将 Assets.car 映射到进程虚拟内存空间。特点:
- 懒加载:操作系统不会立即加载整个文件,仅在代码实际访问某片地址时,以页为单位换入物理内存。
- 无 CPU 拷贝:磁盘块通过内核页缓存直接映射到用户空间,中间没有
memcpy。
3.3 查找全流程拆解
一次 UIImage(named: "my_icon") 调用,假设当前设备是 iPhone 14 Pro(3x、P3、深色模式),大致经历以下步骤:
-
获取 Key Format:解析
Key Format Table,获知本.car只用了 5 个属性(idiom、scale、subtype、gamut、appearance)及其位布局。 -
构建查询 Key:根据当前
traitCollection构建一个CUIRenditionKey(64 位整数掩码)代表 ideal(理想)匹配条件。 -
名字到 Facet 的哈希查找:对
"my_icon"字符串哈希,通过哈希表(RouHash)找到Facet Table中对应的行,获得该名字的所有可用renditionkey列表。 -
降级匹配:在 Facet 列表中按优先级逐级与 ideal key 比较。真正的匹配是一个降级链:从精确尺度/Gamut/外观开始,逐步放宽条件(3x→2x,P3→sRGB,深色→浅色),直到找到最佳可用变体。
伪代码表示逻辑:
for key in candidates: if key matches ideal key exactly → 使用 for key in candidates: if key matches ideal key except gamut → 使用 for key in candidates: if key matches ideal key except appearance → 使用 ... 以此类推 -
获取偏移量:用匹配到的
renditionkey查Rendition Table,拿到位图在 Heap 中的 偏移量 及格式、宽高等元数据。 -
零拷贝创建 UIImage:
mmap 基址 + 偏移量直接作为CGImage的dataProvider数据源,UIImage对象仅持有对该内存区域的引用。整个过程 无 malloc、无 memcpy。被频繁调用的imageNamed:还会将结果缓存到全局共享缓存,后续同名请求直接返回同一实例。
4. 线程安全性:哪里安全,哪里不安全
UIImage(named:) 本身的调用是 线程安全 的。安全基础来自:
CUICatalog内部锁:使用os_unfair_lock保护内部数据结构的并发读写。mmap只读映射:所有线程以只读方式访问同一物理内存页,无需额外同步。- 缓存操作的同步保护:全局图片缓存在写入时使用
@synchronized或原子操作,确保只有一个线程执行加载,其余线程等待后拿到同一实例。
需要警惕的场景
虽然获取 UIImage 对象及读取其只读属性(size、scale、capInsets 等)在任何线程都是安全的,但以下操作 不保证线程安全,建议在主线程或专用串行队列执行:
-
withTintColor(_:)等衍生操作:这些方法内部会触发 CoreUI 的重新渲染,可能涉及共享状态访问,多线程并发有极低概率导致崩溃或图像错乱。 -
UIImageAsset的动态解析:当 Asset Catalog 中为同一资源名配置了不同Trait Collection的变体时,image(with:)等方法的内部注册/解析过程同样建议在主线程完成。
如果你的确需要在后台大量处理图片(如着色、合成),最佳实践是先将 UIImage 转换为独立副本:
guard let cgImage = image.cgImage else { return }
let copy = UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
// 此后可安全在后台线程使用 copy 做进一步处理
这样你等于主动放弃了 Asset Catalog 动态特性,换来了确切的线程安全。
5. 总结
| 阶段 | 核心机制 | 要点 |
|---|---|---|
| 编译 | actool 将 xcassets 编译为 Assets.car | AppIcon 特殊提取;普通图片做格式转换、切片固化、多尺度融合 |
| 分发 | App Thinning 生成设备专用 .car | 按芯片+屏幕尺度裁剪;App Store 按设备精确下发 |
| 运行时 | mmap + 零拷贝查找 | 哈希表定位 Facet → 降级匹配 → 偏移量取图 → 零拷贝返回 GPU 优化位图 |
| 并发 | UIImage(named:) 线程安全 | 只读安全;衍生操作(withTintColor 等)需回到主线程 |
6. 实际开发运用
由上我们了解到,尽量将图片从bundle中转移到Assets.xcassets来管理:
- 苹果智能选择更高效的压缩压缩算法(降低20%+的体积,heic则更高)
- 图片位图数据重新生成GPU 友好格式,提升加载速度。
- APP Thinning:会将@2x@3x根据设备和架构类型区分开,降低包体积。
个人在实际模块化开发的项目中还有以下注意点:
- 多模块共用主工程中的Assert,避免每个模块一个Assets,非必要不新建。这样能让小图更多得得到合并;相同图标也不用各自维护一份。
- 使用
Assets管理后,可以通过编译设置来进一步降低包体积:ASSETCATALOG_COMPILER_OPTIMIZATION = space; - Pod库中的资源生成方式:使用Cocoapods管理集成三方库时,如果Pod库的resources集成方式不对,会带来的图片重复合并问题。
# podspec中资源生成的方式:
# 第一种:
# 是不应该使用的方式, iOS 优化IPA包体积(今日头条) 文章中说,
# * 如果是xcassets,会导致asset catalog中的图片,
# 既作为asset catalog被合并到主工程的asset.car中,
# 也会作为png被拷贝到安装包零散的存在中,导致其中一套图片白白占用了安装包空间。
s.resources = ['Home/Assets/**/*.{xcassets,png,xib}']
# 第二种:
# 应该使用的,并且图片推荐新建一个xcassets,因为这样从苹果的瘦身机制受益。
# 读取pod中图片的方式:
# 使用时是动态库,那么需要从mianBundle中获取Frameworks/Home.framework/Home.bundle然后读取
# 使用时是静态库,需要从mianBundle中获取Home.bundle然后读取
s.resource_bundles = {
'Home' => ['Home/{Assets,Classes}/**/*.{xcassets,png,xib}']
}
# 第三种
# 上述第二种中的方式跟下面这个方法是等价的,下面的方法是自己手动创建bundle
s.resources = ['Home/Home.bundle']
# 避免:会产生问题的写法
# ipa包中检查对应的bundle里面,看图片、Xib打包后发生了重复的一份。
s.resource_bundles = { 'Home' => ['Home/Assets/**/*'] }