最近在给团队内部做一套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 updateCarthage会把所有依赖按版本约束拉取下来生成一个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_TYPEstaticlib \ BUILD_LIBRARY_FOR_DISTRIBUTIONYES \ deriveDataPath ./build \ build这里的核心参数是MACH_O_TYPEstaticlib。熟悉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_TYPEstaticlib然后分别针对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_TYPEstaticlib再编译集成后运行崩溃报某分类方法找不到Objective-C分类没有被链接器加载主工程Other Linker Flags加-ObjC模拟器运行正常真机编译报错架构切片缺失或冲突改用xcframework统一包含多平台切片carthage update每次都在编译源码没有开--cache-builds或环境变量没配置加--cache-builds参数构建缓存落地MACH_O_TYPEstaticlib后编译报符号重复静态库和主工程里同时包含了相同实现检查主工程是否也以源码方式引用了同一依赖分发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真正成为可控的二进制构建流水线而不是团队里每个人的本地编译时间黑洞。
阅读完成 · 觉得有帮助?