【译】了解 mergeable libraries(可合并库)

3,618 阅读14分钟

这是一篇来自 Pol Piella Abadia 的文章,主要介绍了今年 WWDC 中一个非常低调却蛮重要的新特性 mergeable libraries(可合并库)。建议阅读。

在阅读之前,首先需要了解几个关键词和它们之间的区别,有助于理解下文:

链接 link:声明对一个库的引用关系,声明后才能在代码中使用对应库的接口,也是编译链接时的信息来源

嵌入 embed:将对应的库文件塞到最终的应用包中。静态库会直接塞到应用的二进制文件中,动态库会塞到应用 bundle 的 Framework 文件夹中。

签名 sign:对库进行签名,运行时会对所有库进行签名验证。编译时会对主二进制以及所有库同时签名,这也就导致了后下发的动态库是无法执行的(过不了签名验证)。

合入 merge:区别于嵌入,是 mergeable libraries 特有的一种将库塞入应用包中的方式。

在过去,当我们构建一个库时,需要做一个决定:是让这个库构建成静态库还是动态库。这个决定是需要深思熟虑的,因为我们的选择可能会对应用程序的构建耗时和启动时间产生连锁反应。

静态库的好处是不会影响应用程序的启动时间(因为静态库不需要在运行时进行动态查找),但它们会导致应用程序的二进制大小和编译时间增加。另一方面,动态库不会影响构建时间和应用程序的大小(动因为态库不是应用程序二进制文件的一部分),但它们需要在运行时被找到和加载,所以它们对应用程序的启动时间有负面影响。

好消息是,从 Xcode 15 开始,我们不再需要在这两个类型中纠结。我们现在可以使用 mergeable libraries 。这是一种新型的库,结合了动态和静态库的优点。它针对构建时间和启动时间进行了优化,在使用方式上更像是静态库。

mergeable libraries 是在 Meet mergeable libraries WWDC 会议上介绍的,它们在苹果的文档中有自己的页面,如果你对 mergeable libraries 感兴趣,推荐你去阅读。

在这篇文章中,我将向你展示 mergeable libraries 在模块化代码中的用途,以及如何配置 Xcode 项目以开始采用它们。

链接动态框架

让我们从一个简单的例子开始,了解动态框架在 iOS 应用中是如何被链接的。

假设我们有一个 iOS 应用,它依赖一个动态框架 Home 。同时,Home 还依赖了另外两个动态库: HomeCore 和 HomeUI 。 image.png

Home 直接引用了 HomeCore 和 HomeUI,应用的 target 只直接依赖了 Home 框架。因此如上图所示,我们可以在应用的 target 中链接 Home 模块(并将其嵌入),然后在 Home 的 target 中链接 HomeCore 和 HomeUI 模块(不嵌入它们 -- 稍后告诉大家原因)。

在模拟器上构建和运行应用程序没有问题,但是当在设备上运行该应用程序时,我们得到了一个崩溃,控制台日志如下:

dyld: Library not loaded: @rpath/HomeCore.framework/HomeCore
...
dyld: Library not loaded: @rpath/HomeUI.framework/HomeUI
...

嵌入缺少的框架

在使用动态框架时,dyld: Library not loaded 的崩溃是非常常见的。这种崩溃通常发生在有动态框架被链接但没有被嵌入和签名的情况下,就像本例中的 HomeCore 和 HomeUI 。

与静态库的工作方式相反,动态框架不是主库或二进制的一部分。相反,它们是在运行时被查询并调用的。这意味着,当应用程序编译和安装时,不会有任何报错。但当应用程序启动时,动态链接器将在应用程序 bundle 中寻找 HomeCore 和 HomeUI 框架。我们这个例子动态链接器肯定是找不到这两个库的,因为我们还没有嵌入它们。

为了验证这一点,我们可以通过 otool 检查应用程序二进制文件在运行时将寻找哪些动态框架:

otool -L ~/Library/Developer/Xcode/DerivedData/MergeableLibraries-<hash>/Build/Products/Release-iphoneos/MergeableLibraries.app/MergeableLibraries

上面的命令打印了以下结果,这表明该应用程序依赖于一个名为 Home 的动态框架和设备上的一堆系统框架:

/Users/polpielladev/Library/Developer/.../Debug-iphoneos/MergeableLibraries.app/MergeableLibraries:
	@rpath/Home.framework/Home (compatibility version 1.0.0, current version 1.0.0)
	/System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 2036.0.0)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
    ...

如果我们现在用同样的命令检查 Home 框架,我们会看到它在所有系统依赖的基础上依赖于另外两个动态框架 HomeCore 和 HomeUI:

/Users/polpielladev/Library/Developer/.../Debug-iphoneos/MergeableLibraries.app/Frameworks/Home.framework/Home:
	@rpath/Home.framework/Home (compatibility version 1.0.0, current version 1.0.0)
	@rpath/HomeUI.framework/HomeUI (compatibility version 1.0.0, current version 1.0.0)
	@rpath/HomeCore.framework/HomeCore (compatibility version 1.0.0, current version 1.0.0)
	/System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 2036.0.0, weak)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
    ...

什么是 @rpath

正如我们在 otool 命令的输出中看到的,被检查的二进制文件不知道它们所依赖的动态库的完整路径。相反,它们依赖于一个叫做 @rpath 的属性。这个 @rpath 属性在运行时被解析,通常被设置为相对于应用程序二进制文件的路径。

在 Xcode 中,@rpath 是通过 Runpath Search Paths 设置的,它需要一个链接器在运行时用来定位动态框架的位置列表。

默认情况下,Xcode 将此构建设置为 @executable_path/Frameworks,这意味着链接器将在应用程序二进制文件内的 Frameworks 文件夹中寻找动态框架。

如果我们检查应用程序的包文件夹,就能明白崩溃发生的原因。HomeCore 和 HomeUI 框架没有出现在 Frameworks 文件夹中:

image.png

Umbrella frameworks?

既然知道了崩溃的原因,我们接下来就需要嵌入和签名缺失的动态框架,以便在运行时可以找到它们。

我们的第一直觉可能是直接在 Home target 中嵌入和签名 HomeCore 和 HomeUI 这两个框架。然而,这并不是正确的做法,因为这将将创建一个 Umbrella frameworks(即一个包含其他框架的框架),这是苹果强烈反对的做法(这就是上文我们没有直接把 HomeCore 和 HomeUI 直接嵌入 Home 的原因)。

Don't Create Umbrella Frameworks

While it is possible to create umbrella frameworks using Xcode, doing so is unnecessary for most developers and is not recommended. Apple uses umbrella frameworks to mask some of the interdependencies between libraries in the operating system. In nearly all cases, you should be able to include your code in a single, standard framework bundle. Alternatively, if your code was sufficiently modular, you could create multiple frameworks, but in that case, the dependencies between modules would be minimal or nonexistent and should not warrant the creation of an umbrella for them.

苹果在文档中只是点名了不建议创建 Umbrella Frameworks (虽然 Xcode 可以),但并没有说明原因。

原因主要是因为如果嵌入的是公开的三方库,很有可能其他库也嵌入了相同的三方库,这不仅会造成代码量的增加,还会导致因重复的符号而导致的链接失败,甚至是运行时问题。这边有一个更详细的回答。

正确的方法

我们需要在应用程序的 target 中链接并嵌入和签名 HomeUI 和 HomeCore ,而不是在 Home 的 target 中嵌入它们。在做出这一改变后,可以看到 HomeCore 和 HomeUI 框架现在也存在于 Frameworks 文件夹中:

image.png

这是模块化应用程序中非常常见的方法,效果很好,但有一些缺点:

  1. 框架不再是独立的。我们不仅要链接 Home ,还必须链接 HomeCore 和 HomeUI 。
  2. 我们可能向调用方公开了太多信息。应用 target 只需要知道 Home ,但它现在也可以访问内部 HomeCore 和 HomeUI 接口,即使它不需要它们。
  3. 嵌入在应用目标中的每个动态模块都需要在运行时加载,这将增加应用的启动时间。

Mergeable libraries

在引入 Mergeable libraries 之前,解决上述问题的唯一方法是尽可能使用静态库。但是,这可能是一项非常艰巨的任务,甚至是不可能的,尤其是在处理第三方动态依赖项或资源时,有时甚至需要更改整个项目中的依赖路径。

随着 Xcode 15 中 Mergeable libraries 的引入,这一切都发生了变化。我们现在可以告诉 Xcode 合并一个动态框架,而不是动态链接它,Xcode 将负责其余的工作。Mergeable libraries 和 Xcode 经过优化,使得整个使用体验设计得像是在使用静态库,同时具有最佳的构建时间和启动时间性能。

在 WWDC 的讲座中,Cyndy Mtenga Ishida 分享了以下幻灯片,该幻灯片完美地总结了 Mergeable libraries

image.png

自动合并

合并一个动态框架的最简单方法是在 target 中设置自动合并。将 Create Merged Binary 设置为 Automatic 。然后,Xcode 会将目标的所有直接依赖构建为 Mergeable libraries,并将它们合并到目标的二进制文件中。

让我们在 Home 框架中设置它:

image.png

现在,我们可以删除从应用 target 到 HomeCore 和 HomeUI 的引用,并保持嵌入和签名 Home 框架(Home 会继续以传统动态库的方式引入项目)。应用程序现在可在任何设备上成功运行,每个 target 仅嵌入他们需要的内容🎉。

让我们更进一步,看看我们是否可以将 Home 合并到应用程序二进制文件中。让我们在 App 的目标中将 Create Merged Binary 设置为 Automatic :

我们需要在应用程序目标中保留指向 Home 框架的链接,但我们现在不再需要嵌入它,让 Xcode 负责将其合并到应用程序二进制文件中。如果我们在设备上运行它,一切仍将按预期工作。🎉

Automatic 会默认将所有直接依赖的库都以合并的方式引入 target,如果你想要更加精细的控制需要合并的库,可以将 Create Merged Binary 设置为 Manual。此时,你就需要单独设置每个被依赖的库, Build Mergeable Library 设置为 Yes 或 No 来指定当前这个库是否可以以合并的方式被引入到项目中。

上面讲的可能有点乱,我们直接来个实操,手动合并 Home 框架中的所有依赖项:

  1. 在 Home 框架中将 Create Merged Binary 生成设置设置为 Manual 。 image.png
  2. 在 HomeCore 和 HomeUI 框架中将 Build Mergeable Library 生成设置设置为 Yes 。 image.png 我们还可以通过以下方式手动将 Home 框架合并到应用程序二进制文件中:
  3. 在应用目标中将 Create Merged Binary 生成设置设置为 Manual 。 image.png
  4. 在 Home 框架中将 Build Mergeable Library 生成设置设置为 Yes 。 image.png

Build Mergeable Library是用来控制,当自己被别人引用时,是否可以以合并的方式引用。

Create Merged Binary 是用来控制,自己在引用其他库时,是默认都尝试用合并的方式引用,还是根据每个库自己的配置(Build Mergeable Library)来引用,还是完全不使用合并的方式引用。

生成的二进制文件

现在所有依赖项都是可合并的,并且我们不再手动嵌入任何框架,我们可以检查应用包中生成的二进制文件,并和我们之前的文件做个比较。

调试与发布

首先检查应用的包内容: image.png 可以看到,虽然我们不再需要嵌入动态框架,但它们仍然出现在了 Framework 文件夹中。

这是因为我们正在使用 Debug 配置构建应用程序,为了使调试更容易并使增量构建更快,Xcode 会将动态框架重新导出到 bundle 中。

如果我们在 release 模式下再次构建项目并重新检查应用的包内容,我们将看到动态框架不再存在:

image.png

检查动态链接的框架

现在我们已经为应用程序生成了一个合并的二进制文件(bundle 中没有动态框架),我们可以像以前一样使用 otool 检查它,以确保没有对合并的动态框架的引用:

otool -L ~/Library/Developer/Xcode/DerivedData/MergeableLibraries-<hash>/Build/Products/Release-iphoneos/MergeableLibraries.app/MergeableLibraries

上面的命令产生以下输出:

/Users/polpielladev/.../Build/Products/Release-iphoneos/MergeableLibraries.app/MergeableLibraries:
	/System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 2036.0.0)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1336.0.0)
	/System/Library/Frameworks/DeveloperToolsSupport.framework/DeveloperToolsSupport (compatibility version 1.0.0, current version 21.0.8)
	/System/Library/Frameworks/SwiftUI.framework/SwiftUI (compatibility version 1.0.0, current version 5.0.59)
	/System/Library/Frameworks/UIKit.framework/UIKit (compatibility version 1.0.0, current version 7058.3.110, weak)
	/usr/lib/swift/libswiftCore.dylib (compatibility version 1.0.0, current version 5.9.0)
	/usr/lib/swift/libswiftCoreFoundation.dylib (compatibility version 1.0.0, current version 120.100.0, weak)
	/usr/lib/swift/libswiftCoreImage.dylib (compatibility version 1.0.0, current version 2.0.0, weak)
	/usr/lib/swift/libswiftDarwin.dylib (compatibility version 1.0.0, current version 0.0.0, weak)
	/usr/lib/swift/libswiftDataDetection.dylib (compatibility version 1.0.0, current version 750.0.0, weak)
	/usr/lib/swift/libswiftDispatch.dylib (compatibility version 1.0.0, current version 32.0.0, weak)
	/usr/lib/swift/libswiftFileProvider.dylib (compatibility version 1.0.0, current version 1492.0.0, weak)
	/usr/lib/swift/libswiftMetal.dylib (compatibility version 1.0.0, current version 341.1.0, weak)
	/usr/lib/swift/libswiftOSLog.dylib (compatibility version 1.0.0, current version 4.0.0, weak)
	/usr/lib/swift/libswiftObjectiveC.dylib (compatibility version 1.0.0, current version 8.0.0, weak)
	/usr/lib/swift/libswiftQuartzCore.dylib (compatibility version 1.0.0, current version 3.0.0, weak)
	/usr/lib/swift/libswiftUniformTypeIdentifiers.dylib (compatibility version 1.0.0, current version 785.0.0, weak)
	/usr/lib/swift/libswiftos.dylib (compatibility version 1.0.0, current version 1040.0.0, weak)
	/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation (compatibility version 150.0.0, current version 2036.0.0)
	/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1600.135.0)

如我们所见,只有对系统框架 🎉。这意味着上面提到的三个库都不是以动态库的方式引入工程了。

检查二进制文件的符号

现在让我们看看应用程序本身中是否嵌入了上面三个库中的符号。

在二进制上运行 nm 并检索符号列表:

nm -gU ~/Library/Developer/Xcode/DerivedData/MergeableLibraries-<hash>/Build/Products/Release-iphoneos/MergeableLibraries.app/MergeableLibraries

上面的命令生成了这个符号列表,其中我们可以看到有对 HomeCore 接口的引用,这是我们合并的框架之一:

...
0000000100005378 T _$s8HomeCore0aB3APIC5helloSSvM
00000001000060b8 S _$s8HomeCore0aB3APIC5helloSSvMTq
00000001000052e0 T _$s8HomeCore0aB3APIC5helloSSvg
00000001000060a8 S _$s8HomeCore0aB3APIC5helloSSvgTq
0000000100005e50 S _$s8HomeCore0aB3APIC5helloSSvpMV
0000000100005e58 S _$s8HomeCore0aB3APIC5helloSSvpWvd
00000001000052cc T _$s8HomeCore0aB3APIC5helloSSvpfi
0000000100005328 T _$s8HomeCore0aB3APIC5helloSSvs
00000001000060b0 S _$s8HomeCore0aB3APIC5helloSSvsTq
00000001000053b8 T _$s8HomeCore0aB3APICACycfC
00000001000060c0 S _$s8HomeCore0aB3APICACycfCTq
00000001000053ec T _$s8HomeCore0aB3APICACycfc
0000000100005448 T _$s8HomeCore0aB3APICMa
000000010000c6d8 D _$s8HomeCore0aB3APICMm
0000000100006074 S _$s8HomeCore0aB3APICMn
000000010000c718 D _$s8HomeCore0aB3APICN
0000000100005424 T _$s8HomeCore0aB3APICfD
0000000100005408 T _$s8HomeCore0aB3APICfd
0000000100005e48 S _HomeCoreVersionNumber
0000000100005e18 S _HomeCoreVersionString
0000000100005de0 S _HomeUIVersionNumber
0000000100005db8 S _HomeUIVersionString
0000000100005da0 S _HomeVersionNumber
0000000100005d78 S _HomeVersionString
0000000100000000 T __mh_execute_header
0000000100005154 T _main
...

上面列表中的每个条目都可以使用 swift demangle 进行解析,以生产我们可以直接阅读的内容。

例如,上面列表中的符号 s8HomeCore0aB3APIC5helloSSvsTq 运行 swift demangle 将返回以下内容:

$s8HomeCore0aB3APIC5helloSSvsTq ---> method descriptor for HomeCore.HomeCoreAPI.hello.setter : Swift.String

实际就是下面的代码:

import Foundation

public class HomeCoreAPI {
    public var hello = "Hello"
    
    public init() {}
}

因此,我们可以确认合并的库中的符号是直接嵌入到二进制文件本身中,就像静态库一样!🤯

剥离导出的符号

应用程序二进制文件中被嵌入额外符号可能会对其最终大小产生负面影响。Xcode 默认会去除重复的符号来优化最终的二进制大小,但我们可以进一步优化。

应用程序二进制文件中嵌入的一些符号为 exported ,如果应用程序没有任何扩展,我们可以通过在应用程序 target 的 Other Linker Flags 中设置 -Wl,-no_exported_symbols 来安全地去除它们:

image.png

如果要去除所有不必要的导出符号并仅保留所需的符号,Apple 建议在目标的构建设置中提供导出列表文件。

个人总结

简单讲,mergeable libraries 可以让库同时表现出动态库和静态库的特性。Xcode 默认在 debug 配置下,库会像动态库一样被塞入应用 bundle 的 Framework 文件夹下并动态链接到宿主中,以提升编译速度。而在 release 配置下,库会像静态库一样被打入应用的二进制文件中,以提升应用启动时间。

不仅仅是静态动态“我全都要”的优势,mergeable libraries 还能简化依赖关系,减少宿主对依赖的感知(不过现在大家基本都适用 CocoaPos 或 SPM 等依赖管理工具,可能感知不强)。

就是不知道具体用起来有没有坑,因为真实项目的依赖可能会非常复杂。我理解 mergeable libraries 只是多了支持把库在动/静直接切换的能力,但前提是你的库本身就支持动态和静态链接,如果不行还是没戏。好在 mergeable libraries 不具有传染性,可以一部分依赖库走 mergeable libraries 一部分走动/静态链接。