在iOS开发中,我们常常需要以二进制形式依赖三方库。本文章详细对比和介绍介绍不同格式的二进制产物的关系与特点。
通过阅读本文,可以理解需要选择哪种方式进行二进制集成,以及明白其中的一些重要概念。
1. Files
1.1 Mach-O file
Mach是由CMU开发的操作系统内核。1988年,乔布斯创立了NeXT公司,基于Mach内核和部分BSD Unix的代码,做了一个叫NEXTSTEP的操作系统。
1996年他回到苹果后,基于NEXTSTEP又开发了Rhapsody操作系统,这个系统后面演变成了OSX(即现在的macOS)。因此,macOS也基于Mach内核。
Mach-O是Mach Object的简写,定位类似于Linux上的ELF文件。包括了.o, .dylib, 可执行文件等。
1.2 Static library (archive file)
1.2.1 Basic info
.a文件(Archive file)是目标文件的压缩包,它由一组.o文件(Object file)通过ar
命令压缩生成,作为静态库提供给依赖者。需要注意的是,这个过程仅仅执行打包和压缩等操作,并不包含静态链接。
一般而言,一个.a文件附近还会有一个或多个头文件来暴露接口供外部代码调用。典型结构如下:
makefile 代码解读复制代码MyStaticLibrary/
├── include/
│ ├── MyStaticLibrary.h
│ └── OtherHeader.h
├── lib/
│ └── libMyStaticLibrary.a
└── README.md
比起通过源文件提供三方库,对开发者来说,.a不需要暴露源代码,便于分发和版本管理。对集成者来说,.a省去了编译的时间,只需要链接就能用,因此也是编译优化的常客。
.a文件的使用很简单,在Xcode中可以直接拖进工程文件。命令行使用clang可以通过参数-L指定静态库所在目录,以及-l指定静态库名称来参与链接:
clang ... -L/path/to/MyStaticLibrary -lMyStaticLibrary ...
1.2.2 Load required only
按需加载是静态库的一个优点。因为是“静态”的,大多数时候可以假设所有对该库的引用在编译和静态链接期间就可以确定,因此如果静态库中的某一个.o文件中没有被用到的符号,则可以不加载这个文件,从而节省内存。这对应了-noall_load
的效果,这个选项是默认打开的。
此外,还可以额外使用-dead_strip
,从更细的粒度将未被使用的symbol和代码等从产物中移除,进一步优化内存。这一选项是对整个静态链接过程生效的,其他的.o文件也会被影响。在iOS开发中,使用-dead_strip是推荐的行为。
注意-noall_load
和-dead_strip
的区别在于修改粒度和影响范围。前者只对静态库生效,修改粒度是.o文件。后者对所有.o文件都生效,修改粒度是单个符号。这两个特性的存在,使得静态库在内存占用上往往优于动态库。
当然,任何优化方案本质都是跷跷板。这样的机制虽然对app的启动时间和内存占用有利,但由于静态链接过程中要一一检查符号是否被引用、代码是否被用到,导致链接时间会随之增长,对开发体验不利。尤其是有大量静态库符号时。
1.2.3 Symbol conflict
由于静态库的特性,在链接时,所有静态库中定义的符号都将被链接到最终的产物里。因此在多个静态库中有相同符号时,会导致符号重复定义的报错。使用-force_load
可以一定程度上规避这一问题。这一链接器选项指定的静态库中的符号会被强行加载,即使会导致重复。
当然,-force_load
会导致这个库中的所有符号不论是否被引用都会被加载,导致产物变大,加载变慢。而且这也有可能导致其他运行时错误,因为所有对该符号的引用都将指向这个库,这不是其他库预期的行为。
符号冲突是集成三方库的常见问题,后面的部分中,会有更多符号冲突的场景。
1.3 Dynamic library (dylib file)
动态库在MacOS上是.dylib文件,类似于Linux的.so。与静态库不同,动态库的生成需要经过静态链接。它本身也是一种Mach-O文件,可以理解为一个没有main函数的可执行文件。
1.3.1 Comparation with static library
动态库与静态库的区别是一个老生常谈的问题。下面详细介绍动态库的特点以及与静态库的区别:
- 动态链接:静态库中的内容会被直接链接到最终的可执行文件中,而动态库只有依赖的符号名(或者甚至不知道符号名),实际的符号等内容要在必要时,才会由
dyld
(The dynamic link editor)加载进来。这一过程叫做动态链接。主要包括两种时机:- app启动时。如果在链接时使用了
-l
等命令来显式依赖动态库,这些动态库会在app启动时就被加载。 - 主动调用
dlopen
等方法时。这可以发生在app运行的任意时刻。实际执行到这一句代码时才会加载。
- app启动时。如果在链接时使用了
- 系统库复用:多个进程依赖了同一个系统动态库,操作系统会复用这些动态库的代码段内存,这可以有效节省应用进程的内存占用。因此,iOS中的系统库,比如UIKit, libSystem等都是动态库。实际上,所有系统动态库都被合并到了一个大的缓存文件中。当然,出于安全考虑,只有系统库才能复用。例如两个进程都依赖了同一个非系统三方库,即使版本完全相同,它们也不能被复用。
- 符号冲突:在动态链接中,遇到重复符号并不会让程序crash,而是会抛出一个warning。如果两个冲突的符号都是动态库中定义的,则会根据动态库链接的顺序,先遇到的符号会被绑定,后遇到的同名符号会被忽略。比起静态库的
-force_load
,这种UB的不可靠程度还要更高,是我们要想办法避免的事情。 - 四大指标:
- 文件大小:由于动态库在运行时才会链接,app生成的Mach-O文件的size会更小。但最终生成的.ipa(即.app文件的压缩包,.app是Bundle文件)中依然需要包含(即Embed)这些动态库,体积不会减小。反而由于静态库清理了无用符号,导致使用动态库往往使package size更大。
- 内存占用:如上面所说,静态库一般更优。
- 启动速度:由于动态链接的过程需要一一绑定符号,单独链接动态库的耗时远比静态库产生的二进制文件增量导致的耗时更长。因此,对于app启动时就必须加载的第三方库,静态库的性能更好。反之,若非启动时必须的三方库,使用动态库可以优化启动时间。但代价是运行时的加载动态库的耗时,以及增加了一些coding上的复杂性。
- 编译速度:这里的“编译”指编译和静态链接等过程,即按下“Build”按钮到“Build Success”之间的耗时。显然动态库的这部分耗时更少。
前面提到,动态库带来了coding上的不便,这以“隐式依赖”(即没有用-l命令在静态链接期间指定)的动态库为甚。除了要时刻记得“这个符号会在某某时间点才能使用,加载符号可能是一个异步操作”等等增加心智负担以外,写法上也可能造成不便。例如下面的代码,其中MyClass符号定义在被隐式依赖的动态库里:
ini 代码解读复制代码MyClass* instance = [[MyClass alloc] init];
这在静态链接过程会遇到报错:符号_OBJC_CLASS_$_MyClass
找不到。这是当然的,因为我们连占位的符号名都没有提供给链接器。
我们可以用runtime的NSClassFromString
接口找到这个类,然后调用它的接口。虽然work了,但很不好用。此外,当有更复杂的需求,比如继承/扩展动态库里的类,实现protocol等,就更加难用了。这一问题理论上可以用tbd文件来解决,但我本人并没有试过,仅在这里提一下。
由于动态库可以在运行时加载的特性,苹果对使用动态库的行为审核更加严格,尤其防止“通过http请求下载动态库然后加载到代码里”等方式绕过上架流程实现热更新。
这个特性还有一个有趣应用,就是实现ObjC热重载,节约修改代码重新build的时间。这件事情之前在手Q貌似有团队做过,但后面似乎没人维护了。
1.3.2 Crossing dependency between static and dynamic library
由于动态库的生成需要经过静态链接,如果动态库依赖了静态库,那么这个静态库就会被“合”进.dylib里,成为Mach-O文件的一部分。
反过来,由于生成静态库不需要静态链接,如果静态库依赖了动态库,并不会导致.a文件有任何变化。但是在使用这个.a时,要记得同时链接这个动态库。
1.3.3 More scenarios about symbol conflict
前面提到了两个静态库之间的重复符号与两个动态库之间重复符号的情景。如果是静态库与动态库之间存在重复符号呢?结果会略显“智能”一点:它们会各用各的符号。
举例来说,静态库libA与动态库libB都定义了符号MyClass。在libA中对MyClass的引用会被绑定到MyClass(defined in A),而libB中的会被绑定到MyClass(defined in B)。这是因为libA中的绑定是在静态链接阶段发生的,而动态库在生成时也经过了静态链接,内部的调用已经完成了绑定。
这一场景下,如果我们自己程序的代码里也引用了MyClass符号,实际上用到的是哪一个呢?显然是MyClass(defined in A)。如上面所说,动态链接libB时,dyld会发现MyClass符号已存在,从而报出warning,并略过这个符号。
“两个三方库定义了同一个符号”这种情况可能相对少见。但“菱形依赖”非常常见,也是最容易产生符号冲突的场景。
考虑这样的场景:一个app依赖了静态库A和B,而A和B都依赖了C,这样就构成了一个菱形依赖。
css 代码解读复制代码 MyApp
/ \
A B
\ /
C
此时如果A和B依赖的是相同版本的C,这还算好解决。否则情况就复杂了,符号冲突几乎必定发生,而且大概率无法简单通过链接选项解决,需要修改三方库的源码。这包括改名/加命名空间,设置符号为hidden等。
1.4 Fat (Universal binary)
事实上,iOS中的三方库常常需要支持多种架构。例如较为早期的iPhone使用armv7(现在已经不需要考虑了),较为现代的则是arm64。而模拟器则可能是arm64(Apple Silicon)或x86_64(Intel Silicon)。这导致三方库提供者可能需要为每个架构单独提供一个.a或.dylib文件。
苹果提供了一个名叫lipo
(Library Interleaver and Portable Object)的工具,可以将多个二进制文件合成为一个支持多架构的二进制文件,让事情方便了许多。这种包含了多架构的二进制文件,就被称为"Fat binary"或"Universal binary"。
通过lipo -info
命令可以查看一个二进制文件是否是fat binary,以及它包含了哪些架构。当然也可以通过lipo -thin
将特定架构的二进制文件分离出来。
sql 代码解读复制代码➜ lipo -info MicrosoftCognitiveServicesSpeech
Architectures in the fat file: MicrosoftCognitiveServicesSpeech are: x86_64 arm64
Fat binary有一个显而易见的问题,那就是可能引入完全不需要的架构的符号,这会导致最终产物的体积膨胀,尤其是动态库和使用了-force_load/-all_load的静态库。因此针对real device,出于包体积的考虑,还是用单一架构的二进制文件更合适。
2. Binary framework
2.1 Bundle file
在讨论Binary framework之前,需要先介绍一下Apple中Bundle文件的概念。
Bundle文件的定义较为宽泛,它是一个特殊的目录,里面可能包含代码、二进制产物、资源等各种文件。这些文件以特定的层级结构组织起来,可以供构建系统识别。Bundle中需要包含一个Info.plist文件,用于描述这个Bundle的各种信息。
Bundle实际上有很多种类,除了最显而易见的.bundle文件外,包括.dSYM, .app, 以及我们接下来要了解的.framework都属于Bundle。在Finder中,可以看到有些Bundle直接以目录的形式存在,有些则需要"Show Package Contents"来进入,属于"opaque file"。
以.bundle文件为例,典型的目录结构如下:
css 代码解读复制代码MyBundle.bundle/
├── Info.plist
├── Resources/
│ ├── Images/
│ │ └── icon.png
│ └── Sounds/
│ └── bell.wav
├── en.lproj/
│ └── Localizable.strings
└── SomeView.xib
我们可以在运行时通过NSBundle提供的接口来加载Bundle文件中的资源。除了支持媒体文件、xib文件等之外,Bundle还为localization提供了标准化规范。可以看出,Bundle文件结构规整,使用方便,功能灵活,具有很好的可复用性。
2.2 'Fake' framework
在实际应用中,有一种“假”framework,里面只有一个.a文件和一个include目录,包含了一些头文件。它实际上就是一个普通的静态库,只是将"MyStaticLibrary"这个目录名改为了"MyStaticLibrary.framework"。虽然使用起来没有问题,Xcode也可以正确识别它,但本质上这并不属于标准的binary framework,因此只是在此提及一下。
2.3 Framework
实际的开发中,一个三方库往往需要考虑更多事情,例如版本控制,资源管理,文件组织等。苹果在很早就推出了Framework文件来为二进制库提供更丰富的能力。随着iOS 8的退出,苹果支持了基于动态库的Framework。
Framework这是一个Bundle文件,典型结构如下:
arduino 代码解读复制代码MyFramework.framework/
├── Headers/
│ └── MyFramework.h
├── Modules/
│ └── module.modulemap
├── Resources/
│ └── ...
├── MyFramework
└── Info.plist
Framework也分为静态和动态,取决于它包含的Mach-O文件是静态还是动态的。具体使用时,它们的表现与普通的动态库或静态库相同。
除了作为二进制产物的Mach-O文件以外,它还包含Info.plist、资源文件、modulemap文件等,这种标准化的文件结构为三方库的开发和使用带来很多方便。
2.4 Embed
在Xcode中引入framework时,有一个是否"Embed"的选项。是否Embed的区别在于是否将整个framework copy到最终生成的Bundle的Frameworks/目录下。
显然,动态库必须选择Embed,因为它的符号并没有被链接到Mach-O中。如若不然,动态链接时就会找不到这些符号。
而对于静态库,由于符号已经被静态链接到Mach-O中了,再copy一份会导致ipa包变大,因此不应该Embed。对于framework中的其他资源文件,应该通过脚本等方式单独copy到最终的Bundle里。
3. XCFramework
前面提到了"Fat binary"有加载无用符号增加内存的弊端。直到2019年,随着M1 Mac的发布,为了帮助开发者更好地发布framework(更好地卖电脑),苹果推出了xcframework。
xcframework是多个framework的集合,典型结构如下:
erlang代码解读复制代码MyFramework.xcframework/ ├── Info.plist ├── ios-arm64/ │ └── MyFramework.framework │ └── ... ├── ios-arm64_x86_64-simulator/ │ └── MyFramework.framework │ └── ... └── ...
可以看到它包含了支持不同架构的framework,包括用于iOS真机的ios-arm64,和用于Simulator的ios-arm64_x86_64-simulator(这其实是一个fat的,包含了arm64和x86_64两种架构)。
事实上,除了iOS,其他Xcode支持的platform,包括tvOS, watchOS的framework也都可以加到同一个xcframework里。在链接时,系统会选择正确的架构目录下的framework,避免冗余。
4. Conclusion
本文介绍了一些关于iOS上集成三方库的前备知识。通过对动态库/静态库加载方式、符号处理等原理的深入认识,不仅可以更好地选择三方库集成的形式,也能帮助我们更好地理解我们的工程。
评论记录:
回复评论: