ios xcframework 和 framework的区别是什么

138 阅读11分钟

打包方式

  • Framework (.framework):

  • 是一个单平台的二进制包。

  • 如果你想支持多个架构(例如,同时支持 iOS 设备 arm64 和 iOS 模拟器 x86_64),你需要创建多个独立的 .framework 文件,然后手动管理它们。用户在使用时,可能需要通过脚本(如 lipo 命令)来合并它们。

  • XCFramework (.xcframework):

  • 是一个多平台的二进制捆绑包

  • 它可以将多个平台的 Framework(如 iOS设备、iOS模拟器、macOS、tvOS等)打包成一个单一的 .xcframework 文件。Xcode 会自动根据当前编译的目标选择正确的二进制文件。


首先可以看下lipo合并和.xcframework 这个综合文件有什么本质区别

lipo合并(创建“胖二进制”Framework):

  • 它只是将多个架构的二进制代码(例如,arm64, x86_64)合并到一个单一的二进制文件中。

  • 但是,它仍然是一个单平台的Framework。也就是说,一个用lipo合并的Framework可以包含真机和模拟器的架构,但它仍然是iOS平台(或macOS等单一平台)的。

  • 当你构建一个iOS项目时,Xcode会根据你当前的目标设备(真机或模拟器)自动从胖二进制文件中提取对应的架构。但是,这仅限于同一平台内的架构。

本质:将不同架构的机器代码物理合并到同一个文件

结果:一个包含多个架构的单一文件,比如:

lipo -create ios_arm64 ios_x86_64 -output universal_binary

得到的文件同时包含ARM64和x86_64的机器指令

为什么lipo不能合并多个架构的代码呢?后边会有这个问题的讲解

XCFramework

  • 它是一个多平台的二进制包,可以包含不同平台(如iOS、macOS、tvOS等)的Framework,每个Framework本身可以是多架构的(比如iOS模拟器架构包括x86_64和arm64)。

  • XCFramework在结构上是一个文件夹,里面包含了每个平台的Framework以及一个描述文件(Info.plist),该文件说明了每个Framework适用的平台和架构。

  • 本质:将不同平台的完整Framework逻辑组织到同一个容器

  • 结果:一个包含多个独立Framework的文件夹结构,每个Framework都是针对特定平台优化的

本质区别

1、lipo合并的Framework是单平台多架构的,而XCFramework是多平台多架构的。

这意味着,XCFramework可以包含iOS、macOS、watchOS等多个平台的二进制文件,而lipo合并的Framework只能针对一个平台(尽管可以是多个架构)。

lipo合并是"代码层"的混合,XCFramework是"包层"的组织

2、lipo合并后的文件,所有平台共享同一套资源文件、头文件等

XCFramework中每个平台都有自己独立的资源文件、头文件和二进制文件


Xcode如何选择XCFramework中对应的包?

XCFramework目录中有一个Info.plist文件,这个文件描述了每个子Framework所支持的平台、架构和环境(模拟器或真机等)。

当Xcode构建项目时,它会根据当前构建的目标平台(如iOS、macOS)、架构(如arm64, x86_64)和运行环境(模拟器或真机)来匹配XCFramework中提供的各个子Framework,然后选择最匹配的那个。

例如,当你为iOS模拟器构建时,Xcode会选择XCFramework中标记为iOS模拟器且架构匹配的二进制文件。

为什么传统的“胖二进制”Framework无法覆盖所有情况?

lipo确实可以合并任何Mach-O文件,不区分平台,真正导致无法合并的****根本原因:平台标识

需要从Mach-O开始讲起,首先,Mach-O(Mach Object)文件不是简单的字节集合,它有严格的结构:

Mach-O 文件
├── 头部 (Mach-O Header)
│   ├── magic number
│   ├── cputype     ← 关键:CPU架构标识
│   ├── cpusubtype  ← CPU子类型
│   └── filetype    ← 文件类型
├── 加载命令 (Load Commands)
│   ├── LC_SEGMENT_64
│   ├── LC_LOAD_DYLIB     ← 动态库依赖
│   ├── LC_MAIN
│   └── LC_VERSION_MIN_*  ← 关键:平台版本要求
└── 数据段 (Data Segments)

例如,一个Mach-O文件可以是针对iOS设备的arm64架构,也可以是针对macOS的x86_64架构。但是,一个Mach-O文件不能同时属于两个不同的平台。也就是说,在Mach-O头部的filetypeflags等字段中,无法同时表示两个平台。这个可以说天经地义了。

另外Framework是一个文件夹结构,其中包含一个Mach-O二进制文件(在Framework的根目录下,与Framework同名)以及其他资源(如头文件、模块映射、资源包等)。当我们说合并Framework时,实际上是指合并多个Framework中的Mach-O二进制文件(每个对应一个架构)到一个Mach-O二进制文件中,然后把这个合并后的二进制文件放到一个新的Framework中。每个要被合并的 Mach-O 文件称为一个“架构切片”(architecture slice)。这些切片可以是同一平台的不同架构(比如 iOS 设备 arm64 和 iOS 模拟器 x86_64),也可以是不同平台的(比如 iOS 的 arm64 和 macOS 的 x86_64)。

因为每个 Mach-O 切片都包含了平台信息(在 Mach-O 头部的标志位和加载命令中)。当 dyld 加载胖二进制时,它会根据当前运行的环境(iOS 设备、iOS 模拟器、macOS 等)来选择对应的切片。但是,dyld 在选择切片时只考虑架构,而不考虑平台。也就是说,如果你在一个 iOS 设备上运行,dyld 会寻找 arm64 架构的切片,而不管这个切片是来自 iOS 平台还是 macOS 平台

这就导致了问题:假设你合并了一个 iOS 设备的 arm64 切片和一个 macOS 的 arm64 切片,那么当你在 iOS 设备上运行的时候,dyld 会找到两个 arm64 切片(一个 iOS,一个 macOS),它无法确定应该加载哪一个,因此会报错。


Xcode如何选择XCFramework中的对应包

XCFramework内部有一个Info.plist文件,它明确描述了每个变体的适用条件:

<key>AvailableLibraries</key>
<array>
    <dict>
        <key>LibraryIdentifier</key>
        <string>ios-arm64</string>
        <key>LibraryPath</key>
        <string>MyFramework.framework</string>
        <key>SupportedArchitectures</key>
        <array>
            <string>arm64</string>
        </array>
        <key>SupportedPlatform</key>
        <string>ios</string>
        <key>SupportedPlatformVariant</key>
        <string>device</string>
    </dict>
    <dict>
        <key>LibraryIdentifier</key>
        <string>ios-arm64_x86_64-simulator</string>
        <key>LibraryPath</key>
        <string>MyFramework.framework</string>
        <key>SupportedArchitectures</key>
        <array>
            <string>arm64</string>
            <string>x86_64</string>
        </array>
        <key>SupportedPlatform</key>
        <string>ios</string>
        <key>SupportedPlatformVariant</key>
        <string>simulator</string>
    </dict>
</array>
  1. Xcode解析当前构建目标(iOS设备、iOS模拟器、macOS等)

  2. 读取XCFramework的Info.plist,找到匹配的Library

  3. 只将对应的Framework链接到最终产物中


Framework 的另外一个问题: Swift 的 ABI (应用程序二进制接口) 在 Swift 5.0 之前是不稳定的。这意味着用不同版本的 Swift 编译器编译的 Framework 无法兼容。.framework 中的 Swift 模块文件 (.swiftmodule) 是与编译它的特定 Swift 版本绑定的。

ABI(Application Binary Interface) 定义了二进制组件如何交互:

  • 函数调用约定(参数传递、返回值)

  • 内存布局(结构体、类的大小和对齐)

  • 名称修饰(符号命名规则)

  • 类型信息(运行时类型表示)

ABI稳定 = 编译后的二进制可以在不同版本的编译器/运行时之间互操作

// Swift 4.0 中的类布局
class MyClass {
    var value: Int  // 在内存偏移量 16 字节处
    var name: String // 在内存偏移量 24 字节处
}

// Swift 4.2 中的相同类可能变成
class MyClass {
    var name: String  // 现在在内存偏移量 16 字节处!
    var value: Int    // 在内存偏移量 32 字节处
}
// Swift 4.0 中的函数
func calculateTotal(price: Double, quantity: Int) -> Double

// 名称修饰后可能为:_T012MyFramework14calculateTotal5price8quantitySdSd_SitF

// Swift 4.2 中的相同函数
// 名称修饰后可能为:_T012MyFramework14calculateTotal5price8quantityS2d_SitF

XCFramework 的解决方案: 构建 XCFramework 时,你可以为同一个二进制文件包含不同版本 Swift 编译器生成的模块接口文件 (.swiftinterface)。这实现了模块稳定性,意味着使用不同 Swift 版本的开发者(只要版本不低于你构建时所用的版本)都可以正常使用你的库,而不会遇到 “Module compiled with Swift X.X cannot be imported by Swift Y.Y” 的错误。

咋个做到的呢?

首先了解一下我们的app依赖于另一个模块 MyFramework 时,究竟做了什么

  1. 导入与查找:在你的 MyApp 源码中,你写了 import MyFramework

  2. 读取接口:编译器(在编译 MyApp 时)会找到 MyFramework.swiftmodule 文件。

  3. 语义分析:编译器读取这个 .swiftmodule 文件,将其中的 AST 和类型信息加载到内存中。这样,它就能:

  • 进行类型检查:验证你调用 MyFramework 中的函数时,参数类型、返回值类型是否正确。

  • 解析符号:知道 MyFramework.SomeClass 这个符号具体指的是什么。

  • 确保安全性:编译器完全基于这个接口文件来验证你的代码是否正确使用了依赖库,而不需要去查看 MyFramework 的源代码。

  1. 代码生成:在确认所有类型和调用都正确后,编译器生成 MyApp 的机器码。对于调用 MyFramework 的部分,它只生成一个符号引用(比如 _call_MyFramework_function),这个引用会在最后的链接阶段被解析。

这就突出了.swiftmodule 文件的重要性

.swiftmodule 文件是Swift编译器生成的二进制文件,它描述了一个模块(比如一个框架或库)的公共接口。它包含了模块中所有公共类型的符号信息、函数签名、类型布局等。具体来说,它包括了:

  • 所有公共类、结构体、枚举、协议的类型信息

  • 所有公共方法的签名

  • 类型的内存布局(大小、对齐方式等)

  • 泛型特化信息

  • 模块的依赖关系

在Swift 5.0之前,.swiftmodule 文件的格式是编译器版本特定的,也就是说,不同版本的Swift编译器生成的.swiftmodule文件不能互相兼容。

MyFramework.swiftmodule/
├── arm64.swiftmodule        # 架构特定的二进制模块文件
├── x86_64.swiftmodule       # 另一个架构的模块文件  
├── arm64.swiftdoc           # 文档信息
└── Project.swiftmodule      # 项目级模块信息

swiftmodule 和 framework的目录关系

MyFramework.framework/
├── MyFramework              # Mach-O 二进制(编译后的代码)
├── Headers/                 # C/Obj-C 头文件
└── Modules/
    └── MyFramework.swiftmodule/  # Swift 模块信息
        ├── arm64.swiftmodule     # 架构特定的模块文件
        └── x86_64.swiftmodule    # 另一个架构的模块文件

.swiftmodule是与编译器版本相关的,它是 Swift 编译器为了实现快速、安全的跨模块编译而设计的一个核心中间产物,它就像是给编译器看的、一个模块的“详细说明书”或“API 合同”。。而.swiftinterface是文本文件,它描述了模块的公共接口,可以被不同版本的Swift编译,所以,当编译器版本升级后,.swiftmodule 可能无法被读取,而 .swiftinterface 可以被读取并重新生成 .swiftmodule。这个新的swiftmodule里将使用当前swift版本的类型信息,内存布局等描述公共接口。

最后说一下framework 和App Thinning

苹果官方的App Thinning的三个核心技术:App Slicing、On-Demand Resources和Bitcode

这里的app slicing,其实也包含了对架构的选择,比如针对arm64架构的用户,用户下载的就是只有arm64架构的二进制,基本上是这个说法:苹果服务器会根据用户的具体设备型号(如iPhone 13 Pro Max或iPad Air),动态生成并分发一个仅包含该设备必需代码和资源的定制化应用包。因此我也猜测针对一个胖二进制的framework,应该也有对应的架构筛选。

但是,

针对这个筛选架构的问题,首先要看我们的app有多少架构

如果应用支持iOS 10或更早版本,可能包含:arm64 和armv7

但是主流的 App 最低支持的 iOS 版本主要集中在 iOS 13 至 iOS 15了

也就是说现在可以说你提交到app store 的iOS app基本就只有arm64一个架构了

当然这也基于包含模拟器架构的 IPA 文件在提交审核时会被视为不符合技术要求。

不论从哪个方面讲,app slicing在架构筛选方面实现减少应用安装包大小并节省设备存储空间的功劳,说是0也不为过了,当然也不排除未来ios会有多个架构,到那时,app slicing就又派上用场了(当然资源优化才是App Slicing发挥最大价值的地方)。

原始IPA资源 (300MB)
├── 图片资源
│   ├── @1x 图片 (50MB) → 现代设备不需要
│   ├── @2x 图片 (100MB) → 部分设备需要  
│   └── @3x 图片 (150MB) → 现代设备需要
├── Metal渲染器
│   ├️── A11芯片以下版本 (40MB) → 旧设备需要
│   └── A11芯片+版本 (60MB) → 新设备需要
└── 其他设备特定资源