前阵子被一个内部工程的编译速度折磨得够呛,每次在 CI 上拉下来都要把几十个第三方库从头编译一遍,一次全量构建奔着十几分钟去。当时我就在想,这些第三方源码能不能预先编译成二进制,后续开发直接用编译好的产物,省掉那段无脑等待。顺着这个念头调研了一圈,最后动手落地的是用 Carthage 编译静态库这套方案,过程比想象中顺畅,但坑也比文档里写得多。这篇文章就把这套思路和实操过程完整捋一遍,给正在考虑二进制化或者被编译速度困扰的朋友做个参考。
这篇内容不是单纯讲命令怎么敲,而是把为什么选 Carthage、为什么用静态库、构建时遇到的各种问题和排查思路一起讲清楚。适合被 CocoaPods 全量编译拖慢节奏的团队参考,也适合想把手头 SDK 封装成二进制库分发给别人使用的开发者阅读,不管你是刚接触依赖管理的新手,还是已经在用 Carthage 但没试过静态库配置的老手,都能在这篇文章里找到对应的操作细节。
1. 为什么把第三方库做成二进制,而不是继续用源码依赖
1.1 源码依赖的痛点,其实是时间的黑洞
先聊一个很多人都有过体感的场景。用 CocoaPods 的时候,每次执行pod install或者更新代码后构建,那些第三方库都会源码级参与编译。本地还好,跑一次也就几分钟,但在 CI 上这个成本会被放大,几十个 Pod 全部编译一次,十几分钟甚至更久就没了。如果工程里还引用了大量 Swift 第三方库,那编译时间更是不忍直视,因为 Swift 的编译本身就比 Objective-C 慢一截。
更烦的是,源码依赖会把“编译”这件事拖进你的迭代闭环里。改一行业务代码,理论上只想验证自己的改动,结果整个依赖链都要跟着重新编译。日常开发中这种无形成本很容易被忽略,但一旦团队变大、仓库变复杂,它就会变成每天必痛的大山。我当时统计了一下模拟项目 X 的 CI 时间,光依赖编译就占了将近一半,这个数字直接促使我下了二进制化的决心。
1.2 二进制化能解决什么,不能解决什么
把第三方库预编译成二进制,最直接的好处就是构建链路短了。源码依赖变成产物依赖之后,编译阶段直接从繁琐的源码编译变成简单的链接操作,开发机上增量编译的感受会比 CI 上更明显,因为本地通常已经有一份编译好的缓存产物。
二进制化的第二个价值在分发场景里特别突出。如果你维护的是一个内部 SDK 或者商业 SDK,你大概率不想把源码直接给到使用方。通过二进制分发,使用方拿到的是已经编译好的 framework 或静态库,既保证了代码逻辑不被直接阅读,也规避了源码授权上的纠纷。这个思路在组件化比较成熟的大型 App 里也很常见,基础组件基本都走二进制发布,业务组件才保留源码依赖。
不过二进制化并不是银弹,它解决不了依赖之间的 API 兼容问题。每次库升级新版本,二进制产物必须重新构建,如果库的 API 发生了 breaking change,使用方依旧要改代码。二进制化优化的是编译流程,而不是依赖管理本身,这一点需要提前想清楚,别期待它变魔法。
1.3 主流的三种二进制依赖方案对比
当时我在选型的时候,把主流的几种方案都过了一遍。CocoaPods 虽然也能做二进制化,但通常得靠插件或者繁琐的 podspec 配置,比如设置source_files为空、指向预编译产物阵,操作起来不够直观。Swift Package Manager 从 Xcode 11 开始支持binaryTarget,可以直接指定一个本地或者远程的.xcframework压缩包,这套方案在苹果官方生态里是最正规的,但要求产物体积和上传逻辑都按 SPM 的规则来,对某些需要频繁迭代的内部 SDK 来说反而偏重。
Carthage 在这几个方案里是最“轻”的。它不修改你的 Xcode 工程,只是通过命令行把依赖拉下来、构建好,然后把产物放到Carthage/Build目录下,集成方式完全由你自己掌控。这种非侵入式设计刚好契合我这次的需求,我不需要大改工程结构,只需要把依赖拉取的环节换掉,再手动引入构建产物就行。更重要的是,Carthage 对静态库的支持非常自然,只要在构建参数里把MACH_O_TYPE置为staticlib,产物就是一个带完整头文件和 modulemap 的静态 framework。
2. Carthage 的工作原理与静态库配置的核心逻辑
2.1 Carthage 的一次构建过程拆解
Carthage 的构建过程其实没有太多黑魔法。它先读取Cartfile里的依赖描述,然后通过 git 拉取对应仓库代码并切换到指定版本(tag 或 commit),接着查找目标工程里共享的 scheme,最后调用xcodebuild把对应的 target 构建成框架产物。
这里有一个容易忽略的细节:Carthage 默认会去检查远端有没有预编译的.framework包,如果有,就直接下载而不用本地编译。这个机制叫 binaries,适合官方发布了二进制包的库。但如果你对产物有特殊要求,比如要编译静态库,或者必须使用本地产物避免签名问题,那就必须加上--no-use-binaries参数,强制 Carthage 拉取源码后本地构建。我自己所有内部库都默认加这个参数,保证每次构建的产物可预期。
另一个关键点是共享 scheme。Carthage 构建某个依赖前,会先看这个工程的 scheme 是否被标记为共享(Shared)。如果源仓库里的 scheme 没打开共享开关,Carthage 直接跳过这个库并报错,这也是新手最常见的问题之一。共享 scheme 本质上就是往仓库里提交一份xcshareddata/xcschemes/xxx.xcscheme文件,让所有开发者都能用同一个 scheme 去构建。
2.2 静态库和动态库的本质差异
iOS 开发里静态库和动态库的选择,本质上是在启动性能、包体大小和资源管理之间做权衡。动态库的 Mach-O 类型是MH_DYLIB,它不会被直接复制进主可执行文件,而是在 App 启动时由 dyld 动态加载到内存,多个 App 之间甚至能共享同一份动态库副本,这是系统的资源缓存机制在起作用。
静态库则不同。把 framework 的MACH_O_TYPE设为staticlib后,它的二进制并不是独立的可执行文件,而是目标文件的归档集合。链接的时候,链接器会把被引用到的代码直接合并进主 App 的 Mach-O 文件里,App 运行时不依赖外部分子的动态加载步骤。
用一句话概括差异就是:动态库是“运行时再找人”,静态库是“编译时就搬进家”。这个差异带来的直观感受是,动态库方案下 App 启动时要多一步 dylib 加载和 bind 的过程,如果第三方库很多,启动时间会被拉长;静态库方案则把这段开销前置到编译阶段,运行时的启动速度更快,在主线程负载比较敏感的页面尤其能感觉到差别。
2.3 为什么 MACH_O_TYPE=staticlib 就能让 framework 变成静态库
很多人以为静态库只能以.a为后缀,其实放到 iOS 工程里,framework只是一个目录结构,里面有二进制、头文件和资源,真正的 Mach-O 类型完全由构建参数决定。当你在 xcconfig 里设置MACH_O_TYPE = staticlib时,这个 framework 的二进制就会被生成为静态库形式,目录结构保持不变,但链接行为已经变成静态链接。
这也是 Carthage 方案比较优雅的地方。它不像传统.a文件那样需要额外管理头文件搜索路径和模块映射,framework 目录自带Headers和Modules,你只要在 Xcode 工程里把它加到 Link Binary 和 Header Search Paths 里,所有符号和 API 声明都能正常暴露,体验上跟动态 framework 完全一致,但实际链接时又是静态的。
还有一个附带好处是签名逻辑简单了。动态库要求 embedding framework 时对每个库做签名校验,无论真机调试还是上架打包,签名环节都是最容易出问题的地方。静态库的链接发生在主 App 签名之前,产物被整合进主可执行文件后再统一签名,流程上少了一大截签名可能出错的路径。
3. 实操:完整配置 Carthage 编译静态库
3.1 第一步:让目标库暴露稳定的共享 Scheme
编译前的准备工作和命令行本身一样重要。打开你要构建的第三方库工程,选中 scheme,在管理界面里勾上 Shared 选项。这一步如果漏了,Carthage 会给出类似“Could not find any shared schemes”的错误提示,构建直接失败。
共享 scheme 提交到仓库之后,可以用xcodebuild -list命令验证一下:
xcodebuild -list -project YourLib.xcodeproj输出里能看到一个 Schemes 列表,检查目标 scheme 是否在里面。如果工程是 Workspace 形式,就用-workspace YourLib.xcworkspace参数替代。Carthage 并不关心源码是用 Swift 还是 Objective-C 写的,它只认 scheme,能构建并由xcodebuild产出 framework 就可以。
3.2 第二步:用 xcconfig 而不是界面上点选 Mach-O Type
控制 framework 的二进制类型,业内常见做法是通过 xcconfig 文件注入构建配置。创建一个配置文件StaticLib.xcconfig,内容就一行:
MACH_O_TYPE = staticlib然后把这份配置关联到你用来改库工程的 target 上。为什么不用 Xcode 的 Build Settings 界面直接改?因为 Carthage 构建时走的是命令行xcodebuild,它读取的还是工程文件和配置文件的组合,xcconfig 是原生机制,往往能绕过某些 Xcode 工程里因为 target 层级不同导致的显示差异。从可维护性角度讲,配置文件也能跟着工程仓库走,换了机器重新 checkout 后配置不会丢失。
如果你直接动手改第三方库的project.pbxproj,也行,但很麻烦。更推荐的做法是拿到源码后在命令行里临时加参数,比如我在自动化脚本里经常这样写:
xcodebuild -project YourLib.xcodeproj -scheme YourLib \ -sdk iphoneos -configuration Release \ ARCHS="arm64" \ MACH_O_TYPE=staticlib \ -derivedDataPath build这条命令直接覆盖了链接类型,产出物就是静态 framework。注意-derivedDataPath参数,它能保证构建中间产物和最终 framework 都落在当前目录下的 build 文件夹里,方便后续脚本取用。
3.3 第三步:用 Carthage 构建并验证产物
准备好共享 scheme 和 xcconfig 后,正式执行构建命令:
carthage build --platform iOS --no-use-binaries--platform iOS表示只构建 iOS 平台,--no-use-binaries强制本地源码构建。构建完成后,去Carthage/Build/iOS目录下看产物:
ls -l Carthage/Build/iOS/你会看到一个YourLib.framework目录。为了确认它确实是静态库而非动态库,用file命令检查二进制类型:
file Carthage/Build/iOS/YourLib.framework/YourLib输出里如果包含current ar archive字样,就表示这是静态库,连接方式是静态链接。如果显示Mach-O 64-bit dynamically linked shared library,那说明MACH_O_TYPE配置没有生效,回去检查 xcconfig 是否真的被 target 读取了。
3.4 第四步:处理多架构和资源文件的坑
Carthage 默认构建的产物会尽力包含多种架构,但实际使用中还是经常遇到真机和模拟器架构不匹配的问题。真机上只需要arm64,模拟器上要分两种:Intel 芯片的x86_64和 Apple Silicon 的arm64。如果你的开发机是 Apple Silicon,而 CI 上还有可能跑 Intel 环境,那模拟器版本的框架就必须同时带上这两个架构,否则换个机器就交叉编译报错。
Carthage 官方推荐在 target 的 Build Phase 里使用carthage copy-frameworks来复制框架到 App。但静态库场景下其实有个微妙的坑:如果直接把 framework 加到 Embed Frameworks 里,Xcode 可能会把它当动态库处理,把整个 framework 目录复制进 App 的 Frameworks 目录,导致包里出现一份多余的二进制。我实践的结论是,静态库只用 Link Binary With Libraries 链接就行,不需要 Embed。
资源文件的处理也容易踩雷。Carthage 构建的 framework 里如果包含了 xcassets 或其他 bundle 资源,源代码工程常规情况下会把资源打进 framework 的Resources目录。但静态库链接后资源不会自动合并进主 App,需要在使用方工程里手动把资源 bundle 拖进 Copy Bundle Resources 阶段。这一点和动态库不太一样,动态库本身的 Bundle 结构会一起打包,静态库则要显式处理。
4. 哪些坑是文档里不会写的,但我实际踩过的
4.1 签名问题最容易在 CI 上爆炸
Carthage 在本地构建 framework 时会用开发者本地的签名信息。如果你只在本地验证,问题不大,但把构建动作放到 CI 上,或者换一台机器重新拉取构建,经常会遇到errSecInternalComponent这类签名错误。
我的解决思路是在 CI 脚本里把代码签名强制关掉:
carthage build --platform iOS --no-use-binaries不过这个命令不方便传额外 xcodebuild 参数。更稳妥的办法是构建后手动调用 xcodebuild,用下面的方式直接关掉签名:
xcodebuild -project YourLib.xcodeproj -scheme YourLib \ -configuration Release -sdk iphoneos \ CODE_SIGNING_ALLOWED=NO MACH_O_TYPE=staticlib \ -derivedDataPath build这样产出的静态库不携带本地签名信息,交给使用方工程后,由 Xcode 在最终链接时统一完成签名,反而能绕开签名校验环境不一致的坑。
4.2 Bitcode 在新旧项目之间的兼容问题
Bitcode 曾经是 Apple 主推的中间码机制,但 Xcode 14 之后的版本基本移除了对这个功能的支持,新建工程默认也是关闭的。问题通常出现在老工程和旧依赖库混搭的场景:如果你的主工程还开着 Bitcode 编译选项,而某个静态库里没有 Bitcode 编译产物,链接阶段会直接报bitcode bundle could not be generated。
遇到这种情况,优先检查主工程的ENABLE_BITCODE是否已经关闭,如果没关,大胆关掉。原因也很实际,Apple 已经不在上架审核路径上强制要求 Bitcode,App Store 生态记录也证明这个开关已经没有实际用途。如果确实需要支持,构建静态库时就要额外附加ENABLE_BITCODE=YES,但这样做通常得不偿失,我建议直接关闭。
4.3 Swift 静态库跨架构时的 swiftmodule 问题
Swift 比 Objective-C 多了一层模块信息。编译 Swift framework 时,产物里多了一个.swiftmodule目录,里面存放了该架构的模块定义。真机 arm64 和模拟器 x86_64、arm64 三种架构分别会生成不同的.swiftmodule内容。
Carthage 构建多平台产物后,.swiftmodule目录下会出现多个子目录,比如arm64-apple-ios.swiftmodule、x86_64-apple-ios-simulator.swiftmodule。一旦目录缺失或者架构信息不完整,使用方编译时就会报Could not find or use auto-linked framework这类模棱两可的错误。
排查的时候,直接进 framework 看.swiftmodule目录里有哪些架构:
ls -R Carthage/Build/iOS/*.framework/Modules/如果发现缺了某个架构,最快的处理方式是重新构建,确保构建时拉取到对应架构的二进制。多个架构的 swiftmodule 需要手工合并时,用xcrun或者直接复制子目录到同一个.swiftmodule文件夹都是可行的。
4.4 模拟器与真机架构混用导致的 link 失败
静态库在模拟器上跑得挺好,一上真机就报错。报错信息通常类似building for iOS, but linking in object file built for iOS Simulator。这类问题的根源在于模拟器构建出的库二进制里只包含 x86_64 或模拟器 arm64 架构,而主工程在真机模式下尝试链接这个库,架构自然对不上。
用 Carthage 构建时,最有效的规避方式就是按目标环境重新构建:
# 构建真机版本 carthage build --platform iOS --no-use-binaries # 构建模拟器版本 carthage build --platform iOSSimulator --no-use-binaries构建完把两个平台的 framework 合并到同一个目录中,用lipo验证:
lipo -info YourLib.framework/YourLib如果输出列出 arm64、x86_64 等架构集合,说明合并成功。需要注意的是,Carthage 在某个平台构建时可能会把另一个平台的产物覆盖掉,所以合并前最好分别把产物备份到不同目录,避免后一个构建把前一个覆盖了。
4.5 不要把静态库和动态库的集成方式混用
静态库用框架方式集成时,有人习惯性走动态库流程,把 framework 拖进 Embed 阶段,结果 App 的 Frameworks 目录里莫名其妙多出一份重复的二进制。这个问题在包体审查时比较扎眼,而且多出来的框架对启动没任何帮助,纯粹是体积浪费。
我给自己定的规矩是,凡是MACH_O_TYPE=staticlib的产物,只加入 Link Binary With Libraries 构建阶段,Embed 阶段一律不碰。如果其他同事在代码审查里提醒“缺少 copy-frameworks 这个步骤”,先确认是不是静态库再决定要不要改。动态库的框架流程里,Embed Frameworks 和 copy-frameworks 是必须的,静态库就不是。
4.6 Carthage 本地缓存导致的老版本残留问题
Carthage 构建完依赖后会把源码和产物缓存到本地目录,具体位置在~/Library/Caches/org.carthage.superman。理论上这是一件好事,能加速重复构建,但在依赖升级的节骨眼上,老版本缓存残留也容易酿成惨案。
表现是代码上游已经发布了新版本,Cartfile也更新了 tag,但构建产物还是旧符号,排查半天方向全错。遇到这种情况,别犹豫,先清缓存:
rm -rf ~/Library/Caches/org.carthage.superman配合--no-use-binaries再构建一次。如果用的是 CI 的预置缓存节点,还要注意 CI 上清理和重新拉取的策略,不然同样的问题会在流水线里重复出现。
5. 静态库方案落地后的收益与实际建议
5.1 编译时长从“分钟级”降到“秒级”
我们落地这套方案后最直观的收益是 CI 上依赖编译时长的缩减。模拟项目 X 原先通过 CocoaPods 全量编译第三方源码需要十几分钟,改造后用 Carthage 输出静态库产物,主工程直接从产物链接,依赖编译这部分基本在一两分钟内完成,等于把一次完整 CI 的耗时砍掉了大半。这个差距在功能分支频繁提交的时候体现得特别明显,开发同学的等待焦虑都缓解了不少。
必须说明的是,收益幅度和库的数量、单个库的代码量强相关。如果项目里只有一两个第三方库,本身源码编译就不慢,二进制化带来的提速感知会弱很多。我当时选择这套方案的前提,是第三方依赖超过了二十个,并且有大量代码需要反复全量编译,这个数量级下提速效果才是可感知的。
5.2 团队协作中的集成规范和版本管理
任何依赖方案的迁移,都会踩到团队协作这一步。我的建议是迁移初期不要一步到位,先挑出那几个版本稳定、基本不动的底层库做二进制化,在工程里界面一次框架选型,后续新库再逐步接入。这个过程中,需要明确约定二进制产物的存放位置和版本命名规范,比如把Cartfile.resolved里的 commit 和 framework 产物的目录名对应起来,方便出问题时的回溯比对。
如果在本地之间流传 framework 产物,一定要避免“传文件”的方式,最好放到内部服务器或者统一的二进制仓库里,配合 tag 语义化版本。这样每个版本的产物都能可靠追溯,即使团队成员换了几轮,也能凭仓库里的 tag 快速复刻当时的二进制状态。
5.3 什么情况下不建议上 Carthage 静态库方案
最后泼一点冷水。Carthage 方案并不是万金油,如果遇到以下几种场景,可能就不太适合直接推进。第一种是团队里依赖资源管理自动化的程度很高,CocoaPods 的 podspec 能帮你把 resources、exclude_files、dependency 等一堆细节全部处理掉,而 Carthage 需要你把资源的复制和头文件搜索路径手动处理,这个成本在依赖数量膨胀后会被放大。
第二种是核心诉求是频繁调试第三方库本身的业务逻辑。二进制化后,改一段库代码就要重新编译产物、替换产物、重新链接,整个调试链路反而变长,远不如源码依赖直接改完就跑来得高效。第三种是团队已经全面拥抱 Swift Package Manager,而且上层的目标平台对 SPM 的支持很完善,那继续投入 Carthage 的边际收益就很有限了。
拿我现在维护的几个工程来说,旧项目里和本轮二进制化改造相关的方案依然用 Carthage 静态库,新项目则优先考虑 SPM 的二进制 target 方案。旧依赖里的关键大库已经固化下来,改动频率极低,静态库的启动性能和包体优势可以持续享受,新项目里的依赖相对清爽,SPM 的原生支持反而能省掉多余的自定义脚本。
说起来还有个压箱底的小习惯,每次合并完架构、准备把静态库交付到使用方之前,我会习惯性跑一次符号检查,确认产物里的类符号没有异常重复或者缺失:
nm -gU Carthage/Build/iOS/YourLib.framework/YourLib | grep _OBJC_CLASS_$_YourLib这个检查能提前发现很多链接期才会爆的问题,别问为什么会有这种习惯,都是被现场故障逼出来的。Carthage 编译静态库这套链路,本质上就是用一次性的构建成本,换取后续每天的稳定编译体验,值得有同样烦恼的团队认真试一次。