流程图解:Asset Catalog 的完整生命周期

47 阅读10分钟

在过往的开发过程中有过一个疑问,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 机制生成设备专用的变体:

  1. 生成多个 Assets.car 变体:根据芯片架构和屏幕尺度组合(如 arm64_3x 对应 iPhone 14 Pro,arm64_2x 对应 iPhone SE),actool 为每个变体生成裁剪后的 Assets.car,剔除目标设备不需要的格式和尺度。
  2. 生成资源清单AssetPackManifest.plist 描述所有可用变体及其适用范围。
  3. 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、深色模式),大致经历以下步骤:

  1. 获取 Key Format:解析 Key Format Table,获知本 .car 只用了 5 个属性(idiom、scale、subtype、gamut、appearance)及其位布局。

  2. 构建查询 Key:根据当前 traitCollection 构建一个 CUIRenditionKey(64 位整数掩码)代表 ideal(理想)匹配条件。

  3. 名字到 Facet 的哈希查找:对 "my_icon" 字符串哈希,通过哈希表(RouHash)找到 Facet Table 中对应的行,获得该名字的所有可用 renditionkey 列表。

  4. 降级匹配:在 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 → 使用
    ... 以此类推
    
  5. 获取偏移量:用匹配到的 renditionkeyRendition Table,拿到位图在 Heap 中的 偏移量 及格式、宽高等元数据。

  6. 零拷贝创建 UIImagemmap 基址 + 偏移量 直接作为 CGImagedataProvider 数据源,UIImage 对象仅持有对该内存区域的引用。整个过程 无 malloc、无 memcpy。被频繁调用的 imageNamed: 还会将结果缓存到全局共享缓存,后续同名请求直接返回同一实例。


4. 线程安全性:哪里安全,哪里不安全

UIImage(named:) 本身的调用是 线程安全 的。安全基础来自:

  • CUICatalog 内部锁:使用 os_unfair_lock 保护内部数据结构的并发读写。
  • mmap 只读映射:所有线程以只读方式访问同一物理内存页,无需额外同步。
  • 缓存操作的同步保护:全局图片缓存在写入时使用 @synchronized 或原子操作,确保只有一个线程执行加载,其余线程等待后拿到同一实例。

需要警惕的场景

虽然获取 UIImage 对象及读取其只读属性(sizescalecapInsets 等)在任何线程都是安全的,但以下操作 不保证线程安全,建议在主线程或专用串行队列执行:

  • 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.carAppIcon 特殊提取;普通图片做格式转换、切片固化、多尺度融合
分发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/**/*'] }