ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Carthage编译静态库实战:iOS二进制化改造完整指南

Carthage编译静态库实战:iOS二进制化改造完整指南

最近在给团队内部做一套iOS基础组件库,遇到一个非常现实的问题:第三方SDK越来越多,全部以源码方式集成进主工程,编译时间肉眼可见地增长,而且主工程和SDK之间的耦合越来越重。领导提了一个要求:把核心第三方库都以二进制方式集成,最好还是静态库。我第一反应是用Carthage来做编译和分发,毕竟Carthage本身就是围绕“二进制依赖”设计的工具。绕了一圈踩了不少坑之后,把Carthage编译静态库的完整思路梳理了出来,希望对同样在做二进制化改造的iOS同学有帮助。

这篇文章适合三类人:正在纠结CocoaPods和Carthage怎么选的;想把第三方库做静态编译、但不知道从哪下手的;以及已经在用Carthage、但只会默认编译动态库、想更进一步理解底层逻辑的。

1. 为什么选Carthage来编译静态库

1.1 依赖管理工具的取舍

先说说为什么是Carthage而不是另一款主流依赖管理工具。很多iOS开发同学上手CocoaPods更多,它对工程的侵入性很强,会自动修改Xcode工程、生成workspace、自动配置链接参数。这对快速集成非常友好,但对“二进制化”这个场景反而成了负担——它不放你完全控制产物形态,也不方便你把编译好的库单独抽出来分发给别的团队。

Carthage的定位完全不同。它只负责三件事:拉取依赖源码、编译成framework、把产物留给你自己集成。它不会自动改工程配置,所有集成动作都由你决定。这种“工具只管构建、集成靠人”的设计理念,恰恰是做二进制分发时最舒服的:你可以在CI上跑一次Carthage构建,得到一批干净的framework或xcframework,然后把这些二进制产物归档、上传,团队其他人只需要拉取现成产物,不用再碰源码。

1.2 静态库与动态库的本质差异

把静态库和动态库的区别讲透,后面的操作才不会瞎。静态库在链接时被完整拷贝进可执行文件,最终App里没有独立的库文件;动态库则不同,二进制里只保留了导入表,运行时再从依赖路径加载对应的dylib。

iOS 8之前,系统只允许开发者使用静态库,之后Apple开放了App Extension等场景下嵌入式动态库的权限,大家才开始大量使用framework形态。framework只是一种打包目录结构,里面可以放静态库产物,也可以放动态库产物。判断一个framework到底是静态还是动态,最直接的办法是看里面的Mach-O二进制类型:静态库本质上是一个archive包,内部是一堆.o目标文件,而动态库是一个独立的Mach-O dylib文件。

用Carthage默认build出来的framework很多时候是动态库,因为它的核心命令carthage build会按照Xcode工程的默认配置去编译,很多第三方工程默认把Mach-O Type设置成了Dynamic Library。如果你想拿到静态库,就绕不开对这个编译参数的控制。

1.3 什么场景下需要用静态库

团队里总有人问:“Apple都鼓励动态库了,为什么还非要静态库?”这里把实际诉求列一下,只有需求对得上,才值得为了静态库折腾。

第一类是减少启动时间。动态库在App启动阶段要做rebase和bind操作,动态库数量越多,启动耗时越明显。一个大型App如果集成了二三十个动态framework,启动时加载的压力是实打实的。静态库直接编译进主二进制,启动时不需要额外加载。

第二类是控制包体积。静态库虽然也会占体积,但现阶段Apple对动态库有“最少加载路径”限制,动态库之间的公共代码还可能被重复打包。对包体积比较敏感的业务团队,静态库更容易做裁剪。

第三类是集成环境受限。一些企业内部安全要求严格,不允许对App进行额外的动态签名,嵌入式动态库会牵扯到代码签名和授权问题,静态库直接编进主二进制,签名链路最简单。

第四类是SDK分发给第三方时,静态库能让集成方少操心。集成方只要拖进去、加几个链接参数就能跑,不用担心动态库没嵌入、没签名之类的问题。

2. 编译静态库前的环境准备

2.1 安装与环境要求

Carthage本身的安装很简单,macOS用户直接使用命令行包管理器安装即可。需要注意的其实是对Xcode版本的适配,比较老的Carthage版本在新Xcode上经常遇到“unexpected directory”之类的报错,如果遇到这种问题,优先升级Carthage到较新版本。

另外一个容易忽略的点:编译静态库的机器建议用Intel Mac还是Apple Silicon Mac没有绝对限制,但产物的架构会不一样。如果你最终要同时支持模拟器和真机,最好统一构建机器的架构,或者用xcframeworks把多平台产物合并到一起,这个在后面的章节单独展开。

2.2 Cartfile的写法与语义

Cartfile是Carthage的入口配置文件,格式不复杂。最基础的写法是:

github "some-org/SomeLib" ~> 1.2.0

这里的github关键字表示从代码仓库拉取源码。如果你只想拉取某个特定分支、提交或tag,可以分别用.branch、.revision、.tag等修饰符。一个比较实用的写法是锁定到具体tag,避免团队成员在不同时间拉取到不同版本的源码,导致编译产物不一致:

github "some-org/SomeLib" == 1.2.0

除了仓库地址声明,还有一种binary类型的声明,它不从源码编译,而是直接从一个JSON描述文件拉取预编译好的二进制产物:

{ "1.2.0": "https://downloads.example.com/SomeLib/SomeLib-1.2.0.zip" }

Cartfile里写:

binary "https://downloads.example.com/SomeLib/SomeLib.json" == 1.2.0

如果你想做纯二进制分发,这种binary声明是效率最高的方式,因为它连源码都不碰,拉下来直接用。

2.3 源码拉取与版本锁定

写完Cartfile后,先执行carthage update,Carthage会把所有依赖按版本约束拉取下来,生成一个Cartfile.resolved文件,这个文件记录了当前所有依赖的精确版本。

这里有一个实操习惯很关键:Cartfile.resolved必须提交到版本管理库。这样团队其他成员拉代码后只需要执行carthage bootstrap,就能按照锁定版本拉取依赖并重新编译产物,而不是执行carthage update去重新解析版本范围。update和bootstrap的区别就是解析范围与使用锁定文件的区别,养成用bootstrap的习惯能极大降低协作时的版本漂移问题。

3. Carthage编译静态库的核心流程

3.1 常规编译与产物形态

先把最常规的编译命令理清楚。如果只想编译iOS平台的framework,并且不处理多架构,可以跑:

carthage build --platform iOS --no-use-binaries

--no-use-binaries的意思是强制从源码编译,不要去拉取预编译产物。产物默认放在Carthage/Build/iOS/目录下,通常是一个.framework。

这里就有一个关键认知:这一步并没有保证产物是静态库。产物是动态还是静态,取决于第三方开源库自己的Xcode工程是怎么配置的,Carthage只是调用了xcodebuild,本身不会替你改Mach-O类型。

检查产物类型可以用file命令:

file Carthage/Build/iOS/SomeLib.framework/SomeLib

如果输出里包含current ar archive,说明这是一个静态库;如果输出里包含current version和dylib相关字段,说明是动态库。

3.2 手动覆盖Mach-O类型编译静态库

很多开源库默认编译成动态framework,但它的工程结构其实支持静态编译。这时可以通过xcodebuild手动覆盖编译参数来强制生成静态库。

一个可行的思路是,先利用carthage checkout把源码拉取到Carthage/Checkouts/目录,然后进入该目录,找到对应的.xcodeproj,执行:

cd Carthage/Checkouts/SomeLib xcodebuild \ -project SomeLib.xcodeproj \ -scheme SomeLib-iOS \ -configuration Release \ -sdk iphoneos \ MACH_O_TYPE=staticlib \ BUILD_LIBRARY_FOR_DISTRIBUTION=YES \ deriveDataPath ./build \ build

这里的核心参数是MACH_O_TYPE=staticlib。熟悉Xcode的读者可能知道,在Build Settings里这个值的可选项是mh_object_dylib和staticlib,前者编译成Mach-O bundle类型,对应动态库;后者编译成静态库归档文件。覆盖这个参数后,xcodebuild产物会是一个.a文件,路径一般在build/Build/Products/Release-iphoneos/下。

拿到.a文件后,如果想要一个方便的framework形态,可以自己组装目录结构:把.a文件改名为SomeLib,放进SomeLib.framework/目录下,同时把公开头文件拷到Headers/目录,必要的模块映射文件放到Modules/目录。这一步手写shell脚本就能完成,也可以借助现成脚本工具。

这里需要特别提醒:不是所有第三方库都支持直接覆盖MACH_O_TYPE。有些库的源码里用了@_exported import等Swift特性,或者模块化程度很高,静态编译时可能出现符号找不到的问题。所以这个方法虽然通用,但每接一个新库都要先验证一遍。

3.3 使用xcframeworks整合多架构

手动编译静态库解决了“类型”问题,但还差一个“架构”问题。真机需要arm64,模拟器在Intel Mac上是x86_64,在Apple Silicon Mac上是arm64。老的方案是用lipo把多架构合并成一个fat binary,但这种方法在新架构环境下越来越力不从心,因为arm64同时出现在真机和模拟器里,lipo合并会直接冲突。

Carthage从0.37版本开始支持--use-xcframeworks,这个参数会把不同SDK和架构的产物分别编出来,再打包成一个.xcframework目录。xcframework本身就是Apple官方推荐的多平台二进制打包方式,它允许同一个二进制内包含多个平台的独立切片,不存在架构冲突。

编译命令可以写成:

carthage build \ --platform iOS \ --use-xcframeworks \ --no-use-binaries \ --configuration Release

但注意,这个命令默认编译出来的framework还是动态的,除非你在编译前已经准备好了让Xcode工程默认输出静态库的条件。实际操作中,想要“静态xcframeworks”,一般有两种路径。

路径一:如果第三方库本身提供了支持静态编译的配置,直接配合--use-xcframeworks构建,然后在输出目录检查产物类型。

路径二:如果第三方库只支持动态库,那就在carthage checkout之后,手动修改检查出来的工程配置或直接在xcodebuild命令里加MACH_O_TYPE=staticlib,然后分别针对iphoneos和iphonesimulator两个SDK各编一次,最后用xcodebuild -create-xcframework手动合并:

xcodebuild -create-xcframework \ -library ./build/Release-iphoneos/libSomeLib.a \ -headers ./Headers \ -library ./build/Release-iphonesimulator/libSomeLib.a \ -headers ./Headers \ -output SomeLib.xcframework

这样得到的SomeLib.xcframework同时包含真机和模拟器切片,且全部是静态库,可以直接拖进主工程使用。

4. 集成静态库到工程与二进制分发

4.1 工程集成与链接参数

拿到静态库之后,集成进Xcode工程比动态库要简单,但有一个容易翻车的细节:静态库里的Objective-C分类和Swift扩展符号,链接器默认可能不会全部保留到最终二进制里。

对于Objective-C写的静态库,需要在主工程的Build Settings的Other Linker Flags里加上-ObjC,必要时加上-all_load。-ObjC的作用是让链接器加载所有Objective-C类和分类,不加的话,一些通过运行时动态调用的分类方法会莫名丢失,运行起来直接“方法找不到”。

对于Swift写的静态库,情况稍微好一点,但模块地图(module map)的处理要仔细。拖入framework后,建议在导入时报错时检查FRAMEWORK_SEARCH_PATHS是否包含了framework所在目录。

另外,静态库不需要像动态库那样设置Embed和Sign,因为它直接进入主二进制,这一步省了很多事,也少了很多签名相关的坑。

4.2 二进制分发与缓存思路

Carthage编译静态库的一个经典用法是“公司内部基础库的二进制分发”。思路大概是这样的:

在CI机器上配置好统一的Xcode环境,使用carthage build --use-xcframeworks --configuration Release编译出静态xcframework,然后归档、上传到内部二进制仓库或对象存储服务。同时生成好对应的JSON描述文件,团队其他成员在自己的项目里通过binary类型的Cartfile引用这个JSON。

这样做的收益非常明显:每个团队成员的机器上不再需要拉取所有基础库的源码,不再有大量耗时编译,只需要下载现成的二进制产物。版本管理上,只需要在JSON描述文件里增加一个新版本号,指向新的压缩包地址,集成方重新执行carthage update或bootstrap就能拿到新版本。

4.3 产物校验:架构与类型检查

分发的二进制产物,如果架构或类型不对,到了集成方那边就是黑盒崩溃。所以在发布前,强烈建议加一道自动化校验工序。

用file命令校验类型:

file MyStaticLib.xcframework/ios-arm64/MyStaticLib.framework/MyStaticLib

期望看到current ar archive,就说明是静态库。

用lipo校验架构:

lipo -info MyStaticLib.framework/MyStaticLib

如果输出包含arm64和x86_64,说明是通用二进制。不过要注意,真机和模拟器同架构混在一起时,lipo会报错,这时用xcframework的目录结构就能避开这个问题。

还可以用nm命令抽查外部符号:

nm -u MyStaticLib.framework/MyStaticLib

如果发现大量未定义符号指向主工程的类,这是正常现象,前提是主工程确实提供了这些实现;如果发现未定义符号指向某个同样应该被打进去的库,那就是漏了依赖。

5. 常见问题与避坑实录

5.1 高频问题速查表

问题现象可能原因解决办法
carthage build提示unexpected directoryCarthage版本过旧,Xcode太新升级Carthage,或改用--use-xcframeworks
产物是动态库而不是静态库第三方工程默认用了mh_object_dylib手动用xcodebuild覆盖MACH_O_TYPE=staticlib再编译
集成后运行崩溃,报某分类方法找不到Objective-C分类没有被链接器加载主工程Other Linker Flags加-ObjC
模拟器运行正常,真机编译报错架构切片缺失或冲突改用xcframework统一包含多平台切片
carthage update每次都在编译源码没有开--cache-builds或环境变量没配置加--cache-builds参数,构建缓存落地
MACH_O_TYPE=staticlib后编译报符号重复静态库和主工程里同时包含了相同实现检查主工程是否也以源码方式引用了同一依赖
分发JSON拉取下来无法解压压缩包格式或路径配置不对确认zip压缩包根目录结构,JSON中URL指向zip直链

5.2 从动态库切到静态库的迁移经验

最后分享一段实际迁移过程。我们当时有一个基础组件库,之前一直通过Carthage生成动态framework,后来统一切到静态库,切完发现主App启动时间少了近80毫秒,包体也有一定缩减,但代价是排查了整整两天兼容问题。

最大的坑在于:这个基础组件库里混合了Objective-C和Swift,而且引用了另一个静态依赖。静态编译时,基础组件库的符号和依赖库的符号全被塞进了主二进制,主工程里如果也直接或间接引用了同一个依赖,就会出现duplicate symbol报错。

解决办法是把所有被基础库引用的依赖,全部在基础库的工程里改为private级别引用,从依赖关系上保证“同一份符号只出现一次”。这个动作不是代码层面的改动,而是对依赖树的一次梳理,建议每个准备做全静态化改造的团队,先把整棵依赖树画出来,确认每个底层库只被一个上层库引用。

另外,迁移过程中要注意资源文件的处理。静态库不负责打包资源文件,如果原来动态库里通过bundle包含图片、xib等资源,切成静态库后,这些资源不会自动出现在主包里,需要在主工程的Copy Bundle Resources里手动添加对应bundle,或者在静态库代码里改成运行时从主包读取。

5.3 关于Carthage缓存优化的小技巧

团队里使用Carthage久了,最烦的就是每次拉新依赖都要全量编译。Carthage提供了一个--cache-builds参数,会在~/Library/Caches/org.carthage.carthage下缓存编译产物。如果某次编译的源码hash没有变化,Carthage会直接复用缓存,大幅缩短拉取时间。

还有一个小技巧:可以让CI机器在每天空闲时段把依赖编译好,生成缓存目录,团队成员再手动把缓存目录同步到各自机器上,配合carthage bootstrap做离线构建。虽然现在不太推荐手动同步缓存了,但对于内网环境、外网受限的团队来说,这是一个相当实用的方案。注意缓存目录的结构要一致,不同Carthage版本之间缓存不兼容。

6. 用自己的工程验证了一遍的体会

把Carthage编译静态库这件事从头到尾跑通之后,最大的体会是:Carthage本身只是一个构建调度工具,真正决定产物是静态还是动态的,是Xcode工程里那一个Mach-O Type参数。理解了这一层,遇到任何第三方库都能快速判断这条路线通不通。

我的最终建议是:如果是给内部团队做基础库分发,优先走二进制Cartfile加静态xcframework这条路;如果是自己主工程里管理几个量级不算大的第三方开源源码,继续用动态framework也完全没问题,没必要为了“静态”而静态。

最后再分享一个小技巧:无论最终选静态还是动态,都别直接在主工程里频繁执行carthage update,尽量把“编译产物”这一步骤放在CI上做,主工程只做bootstrap。这样才能让Carthage真正成为可控的二进制构建流水线,而不是团队里每个人的本地编译时间黑洞。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进