做iOS依赖治理做久了免不了要处理一个很现实的需求某个第三方库能力很强但你不想把它的编译逻辑完全摊开或者一个业务组件被多个App复用每次全量编译都要等老半天又或者供应商交付SDK时只给二进制不给源码。这三个场景走到最后都会落在同一个词上二进制。而iOS上把第三方库做成二进制Carthage编译静态库是值得优先尝试的一条路。Carthage在依赖管理工具里存在感不如CocoaPods强但它在构建层面足够纯粹能稳定地把仓库源码编译成我们需要的静态framework再以xcframework的形态分发。这篇文章给的是思路和可复现的流程不是泛泛而谈适合正在做组件化、SDK打包、CI提速或者对二进制交付有硬性要求的iOS开发同学。1. 为什么二进制第三方库这条路最终会走到Carthage头上1.1 三个让我非做不可的真实场景第一个场景来自一次SDK交付。当时A同学负责对外SDK对方要求核心算法不能以源码形式出现可我们依赖的某个开源库本身要一起打进SDK如果直接把源码引进去等于把内部结构全部暴露了。于是想到一个妥协把第三方库编译成静态二进制再和我们的业务代码一起打包对方拿到的是完整release包但看不到第三方库的源码组织。第二个场景是CI。老项目里的网络层、数据库、图片处理三个库全挂在CocoaPods下面pod install之后每个job都要重新编译经常一等就是十几分钟。仔细一看很多依赖版本根本不会变它们完全应该预编译成二进制放在产物库里每次集成时直接复制而不是让CI把所有C工程重新构建一遍。Carthage恰好能承担这个“预编译”的活。第三个场景是组件复用。多个App维护同一套登录组件如果把源码拷到每个仓库后面修了bug要同步到处分发如果发布二进制版本大家只要锁定release tag就能平滑升级还能避免某个工程里的私有改动污染共享组件的稳定性。这三个场景的共同点很清楚我们想要的不是“怎么管理源码”而是“怎么拿到一个稳定的二进制产物”。Carthage的工作方式天然满足这个需求。1.2 三个依赖工具在“产出二进制”这件事上的定位差异很多人讨论依赖管理时会下意识把CocoaPods、Carthage、SPM放在同一个维度比较但它们在“二进制产出”上的表现差异非常大。我用一个表格把关键差异理一下工具本质默认产出二进制友好度集成方式CocoaPods集成工具源码 Xcode工程脚本中二进制需额外插件或vendored_frameworks自动化注入工程方便但链路重Carthage构建工具framework / xcframework高一条build命令即可得到产物只负责构建集成由自己拖拽或脚本决定SPM源码优先的包管理器源码工程一般需在Package.swift里声明binaryTargetXcode原生支持但低版本兼容有限为什么CocoaPods不是首选因为它向工程注入了大量脚本集成自动化很强但二进制化往往要通过额外插件或者写podspec里的vendored_frameworks还要处理subspec依赖关系。这套链路维护起来并不轻松尤其在依赖比较深的工程里插件升级、Ruby环境、缓存失效都可能变成新问题。SPM在近几个Xcode版本里对二进制Target的支持越来越好但它总体还是源码优先的思路。如果团队没有全面切换到SPM生态单独为了某一个第三方库去建一个Package.swift成本不低。相比之下Carthage的定位最“单纯”它只解决“拉源码、编译、产出framework”这一段剩下集成工作交给你。这种割裂反而成了二进制分发场景下的优势。1.3 Carthage的本质构建工具不是集成工具Carthage的工作流非常简单Cartfile里声明来源carthage update解析并拉取依赖carthage build执行构建最终产物放在Carthage/Build目录集成阶段由开发者自己把framework拖进工程或写脚本拷贝。正因为Carthage不关心你最终怎么集成它才敢把构建参数完全暴露给你。举个例子我想把某个依赖强制编译成静态库在CocoaPods里要处理Podfile、插件、post_install钩子在SPM里要看这个库是否支持binaryTarget但在Carthage里只需要在构建时注入自定义xcconfig或者干脆修改源工程的Build Settings得到的产物形态就完全由我说了算。另外Carthage的--no-use-binaries参数能保证不下载作者上传的预编译二进制强制从源码构建。这一步非常关键预编译二进制往往是动态库如果我们想要静态库就必须从源码按自己的配置重新编译。Carthage把“下载预编译产物”和“从源码构建”的切换做得足够简单这套思路才真正跑得通。2. 静态库与动态库的博弈选型背后的编译与运行时逻辑2.1 动态库在App启动链路上埋了哪些雷很多团队在追求启动耗时优化时会把“减少动态库数量”写进KPI因为App启动时dyld要把主二进制依赖的所有动态库加载进内存逐一解析符号、绑定地址。动态库越多启动路径越长。虽然单个动态库的加载时间可能只有几毫秒但二三十个累加起来就很可观。更重要的是动态库在进程里一旦加载版本冲突就是“运行时问题”而不是“编译时问题”。两个动态库依赖同一个第三方库的不同版本时链接器可能静默选择其中一个结果在特定路径上崩溃排查起来非常痛苦。静态库在链接阶段就把符号并进主二进制没有运行时加载依赖启动路径更短符号归属也更明确。从App Store审核的角度看项目里嵌入太多自定义动态库虽然不是绝对禁忌但审核包中的动态库需要单独签名处理不当很容易出现“invalid signature”或者“framework not found”。静态库直接打进主二进制这些麻烦会少一大截。2.2 静态framework比.a好在哪里有人会说既然要静态为什么不直接用.a文件确实.a就是最传统的静态归档格式由一堆.o目标文件打包而成。但它有几个实际痛点头文件、资源文件、modulemap全都得自己额外管理集成方拿到的不是一个完整结构而是一堆零散文件而且没有通用的Bundle布局跨项目复制时很容易漏东西。静态framework则是一个标准的Bundle目录里面有Headers、Modules、二进制文件甚至还有Info.plist。它和系统框架长得一模一样在Xcode里拖进工程就能被识别Link阶段和Search Path设置都更直观。如果要把这个framework进一步封装成xcframework进行多架构分发静态framework也正好是xcframework支持的标准切片格式。所以做二进制交付时静态framework是比.a更合适的交付单元。资源、头文件、二进制放在同一个包里对方集成时只需要关心“要不要Embed”而不需要自己拼装各种文件。2.3 xcframework是如何收拾多架构烂摊子的老一代iOS开发应该都经历过fat framework的折腾一个arm64真机架构、一个x86_64模拟器架构用lipo合并成单个Mach-O文件。用于本地调试没问题但上传App Store时容易触发“binary is for multiple platforms”之类的报错因为App Store不能接受包含模拟器架构的二进制。xcframework用目录结构把不同平台和架构拆成独立slice比如ios-arm64对应真机ios-arm64_x86_64-simulator对应模拟器每个slice可以放不同版本的framework或者静态库。集成端由Xcode自动选择当前需要的slice归档时自动剔除模拟器部分不需要开发者手动合并。Carthage从0.37开始支持--use-xcframeworks构建出的产物直接就是xcframework。静态库放进xcframework后本地调试时加载模拟器slice真机运行时加载arm64 slice分发方和集成方都不需要处理lipo合并整个链路干净很多。3. 实操流程一条命令把源码编译成静态xcframework3.1 先准备好Cartfile和静态化xcconfig实际操作时我通常会先把环境准备好。安装Carthage在macOS上很简单brew install carthage在项目根目录创建一个Cartfile声明依赖来源。这里以一个内部网络库为例写法是git https://git.internal.example.com/ios/NetworkKit.git ~ 3.2~ 3.2表示锁定在3.2及以上、4.0以下的版本符合语义化版本规则。多依赖的话就继续加行每行一个库。接下来是关键的一步创建一个static.xcconfig内容如下BUILD_LIBRARY_FOR_DISTRIBUTION YES MACH_O_TYPE staticlib OTHER_LDFLAGS $(inherited)其中MACH_O_TYPE staticlib告诉Xcode把可执行产物按静态归档方式编译这样framework里的二进制不再是动态dylib而是静态库内容。BUILD_LIBRARY_FOR_DISTRIBUTION YES是为了生成完整稳定的modulemap和swiftinterface后面会专门讲这个配置的价值。我建议把这个xcconfig提交到代码仓库里而不是放在个人电脑的某个角落。团队成员构建同一个二进制时使用同一份配置结果才一致。3.2 carthage build参数逐个拆解准备好之后执行构建XCODE_XCCONFIG_FILEstatic.xcconfig carthage build --no-use-binaries --platform iOS --use-xcframeworks如果第一次使用也可以先做依赖解析和构建一步到位的版本XCODE_XCCONFIG_FILEstatic.xcconfig carthage update --no-use-binaries --platform iOS --use-xcframeworks这里有几个参数需要解释清楚。--no-use-binaries的意思是禁止Carthage去Release页下载作者预编译好的二进制强制从源码仓库构建。这是整个静态化流程的基石因为只有从源码构建xcconfig里的MACH_O_TYPE staticlib才能真正作用到产物上。如果你不小心漏了它Carthage可能直接拉一个别人做好的动态framework前面所有努力都白费。--platform iOS限定只构建iOS和iOS Simulator平台。如果不写Carthage会把macOS、tvOS、watchOS相关的scheme也跑一遍耗时成倍增长。大部分第三方依赖只需要iOS这个参数能极大缩短构建时间。--use-xcframeworks让构建产物直接变成xcframework。我个人的经验是新项目绝对不要再生成传统framework了哪怕本地自用也不要。直接用xcframework后面不管是分发还是多架构管理都省心。XCODE_XCCONFIG_FILEstatic.xcconfig是环境变量注入xcodebuild会读取它作为额外的Build Settings来源。这里要留意优先级问题xcconfig的优先级低于target自身的Build Settings也就是说如果某个依赖工程里写死了Mach-O Type这里的配置可能不生效。碰到这种情况要么在依赖工程里手动改要么换一个支持静态化的版本。绝大多数常规组件工程不会把这个字段写死所以这个方案在大多数场景下都能跑通。3.3 怎么确认得到的真是静态库构建完成后很多人会下意识地认为“生成的是framework应该就是静态了吧”其实不一定。framework只是一种目录格式里面的二进制既可能是动态库也可能是静态归档必须实际检查。我最常用的验证命令是file Carthage/Build/NetworkKit.xcframework/ios-arm64/NetworkKit.framework/NetworkKit如果输出类似NetworkKit: current ar archive random order说明这个slice是静态库因为它本质上是归档文件不是可动态加载的Mach-O。如果输出是NetworkKit: Mach-O 64-bit dynamically linked shared library arm64那说明它还是动态库检查一下xcconfig有没有生效或者依赖工程是否有更高优先级的配置。还可以用nm看符号表是否完整nm -gU Carthage/Build/NetworkKit.xcframework/ios-arm64/NetworkKit.framework/NetworkKit | head正常静态库会导出Objective-C类符号和C函数符号。如果符号少得可疑说明链接阶段有问题后面会讲到如果处理。最后看一眼目录结构ls Carthage/Build/NetworkKit.xcframework/ios-arm64/NetworkKit.framework期望看到Headers、Modules、Info.plist和二进制文件。Modules目录尤其重要Swift侧import依赖它。3.4 归档、分发与集成构建出了xcframework接下来的事情就是把产物归档并交到使用方手里。如果依赖比较少可以直接用Carthage的archive能力carthage build --no-use-binaries --platform iOS --use-xcframeworks --archive不过Carthage的--archive在配合--use-xcframeworks时不同版本的表现不太一致。老版本里--archive默认生成的是Framework的zip而不是xcframework的zip。我自己现在的习惯是手动打包逻辑更可控cd Carthage/Build zip -r NetworkKit.xcframework.zip NetworkKit.xcframework把这个zip上传到内部制品库或者Release页使用方下载解压把.xcframework拖进目标的Frameworks, Libraries, and Embedded Content区域。这里有一个很容易忽略的操作因为是静态库Embed属性应该选择Do Not Embed而不是默认的Embed Sign。静态库不需要运行时加载也不需要签名后嵌入App包所以集成方不需要跑carthage copy-frameworks脚本也不需要把framework拷到Frameworks目录。对方接受到的整个接入成本就是拖一个文件、改一个Embed选项最多在Build Settings里加几个Linker Flags这对二进制分发场景来说非常友好。4. 静态化接入时最容易翻车的几个坑4.1 强制改Mach-O Type后链接器开始报错我第一次尝试把所有依赖强制静态化时遇到最典型的问题是某个库单独构建一切正常但集成到主工程后链接阶段报找不到符号或者提示某个类重复定义。后来排查发现这个库内部还依赖了另一个子系统那个子系统在Carthage构建时没有被静态化仍然以源码或动态库形式存在最终导致主工程链接时符号来源不一致。处理办法是把它的子依赖也一并加入Cartfile统一走静态化构建如果某些符号确实需要从系统库取就在集成端Other Linker Flags里显式声明。还有一种情况是库本身用了extern C导出C接口但头文件没有正确包含导致链接器看不到声明。这不是Mach-O类型的问题而是modulemap不完整可以回到源工程开启BUILD_LIBRARY_FOR_DISTRIBUTION后再构建。4.2 Carthage缓存让新配置“不生效”改完xcconfig重新执行carthage build结果发现产物没有任何变化这是第二个高频坑。Carthage的构建默认会使用DerivedData缓存。如果之前构建过同一份依赖它会认为源码没有变化直接跳过编译自然也就不会读取新的xcconfig。尤其在开启了--cache-builds的情况下这个问题特别容易发生。排查方式很简单构建前清掉本地产物目录Carthage/Build再执行一次不带--cache-builds的构建命令强制它从源码重新来一遍。不要觉得这很麻烦因为你的最终产物是给整个团队用的一次干净构建带来的确定性比那几分钟编译时间重要得多。我在团队里定的规矩是所有正式发布用的二进制必须从干净环境构建不允许任何增量缓存参与。4.3 模拟器、真机、Catalyst的架构混局xcframework避免了手动lipo但如果你手里的构建产物比较旧或者Carthage版本和Xcode版本不匹配有可能会生成包含Catalyst slice的xcframework。集成时如果使用脚本做静态检查不小心遍历到macOS slice就会在真机上报“no suitable image found”。我建议在发布前写一个校验脚本遍历.xcframework下所有slice确认每个slice都符合预期平台。比如iOS真机只需要ios-arm64iOS模拟器需要ios-arm64_x86_64-simulator如果多出来一个mac-catalystslice确认是否需要不需要的话就换构建参数。另外--platform iOS在绝大多数情况下不会构建Catalyst但遇到个别库的scheme配置比较特殊时还是要验证一遍。4.4 没有modulemapSwift侧直接import失败如果交付的是Objective-C库使用方在Swift工程里执行import NetworkKit时报“No such module”这通常不是集成姿势的问题而是framework里缺少Modules/module.modulemap。很多老工程Build Settings里没有开启模块化Carthage构建出来的framework虽然带着Headers目录却没有生成modulemapSwift无法把Objective-C头文件识别成模块。最简单的方案就是在static.xcconfig里加上BUILD_LIBRARY_FOR_DISTRIBUTION YES这个开关会强制Xcode为framework生成完整的modulemap顺带解决一部分符号可见性问题。如果第三方库的工程结构实在太老开启这个开关后反而编译失败那就只能手动在Framework的Headers目录里补一份module.modulemap再重新打包xcframework。这条路能应急但不适合长期维护实在不行就换一个维护更积极的开源库。4.5 分类符号丢失运行期selector not found这是所有静态库方案的经典问题。Objective-C里大量使用Category来扩展系统类和业务类但Category的符号不一定会被编译器自动引用。静态库在链接时只会把主工程里显式引用的目标文件打进去没有被引用的Category方法就会在链接阶段被丢下。表现就是编译一切正常运行到某个方法时突然unrecognized selector。解决方法是在集成端的Other Linker Flags里加上-ObjC强制链接器加载目标文件里的所有Objective-C类和Category。如果还不够有些人会追加-all_load但我不建议随便用因为它会把静态库里所有目标文件都塞进主二进制包体变大也更容易引出重复符号。另外一种更稳妥的思路从源头上减少静态化的依赖数量。如果某个库以Category为核心它的源码形态本身就不适合切成太多碎片优先考虑使用方以源码方式集成或者让库作者提供动态静态两种分发模式。4.6 BUILD_LIBRARY_FOR_DISTRIBUTION缺失引发的隐蔽问题继续聊BUILD_LIBRARY_FOR_DISTRIBUTION YES这个开关在Xcode 12以后变得越来越重要。没有它framework的Public头文件处理、模块稳定性、接口导出都按照普通工程的方式处理构建出的xcframework可能不包含稳定的.swiftinterface。如果被分发的库本身是用Swift写的缺失这个开关的后果尤其明显。使用方在低版本Xcode或不同编译器设置下可能会看到Cannot find interface declaration或者module compiled with newer Swift version这类让人摸不着头脑的报错。在static.xcconfig里加上这个字段能大幅提升产物的可分发性。也正因如此我在第3节准备xcconfig的时候就已经把它写进去了。如果构建过程中因为开启它导致某些头文件重复声明的报错建议回到源工程整理Public和Project头文件的归属这是维护一个长期交付组件的必修课。5. 二进制化思路延伸到自研组件后的一些观察5.1 私有仓库组件如何复用这套流程这套流程不止能处理第三方开源库把依赖地址换成自己团队内部Git平台的私有仓库Carthage同样可以完成从源码到静态xcframework的构建。相比第三方库自研组件有一个好处你可以完全控制工程结构直接在源工程里把Mach-O Type设成Static Library不再依赖xcconfig这种外部注入。我现在的做法是给每个需要二进制交付的组件保留一个Release脚本。脚本做三件事打tag、执行carthage build生成xcframework、把zip上传制品库。业务团队那边完全不感知Carthage的存在他们拿到的是一个标准的xcframework文件拖进工程就能用。这个模式很适合“源码调试二进制发布”双轨制。开发调试阶段直接用本地源码工程联调快速对外交付或CI构建时切到二进制版本编译速度和质量控制都能兼顾。5.2 与CocoaPods二进制方案的取舍CocoaPods的二进制化并非做不到主流的做法是使用二进制插件或者手动维护一份包含vendored_frameworks的podspec。但它有一个绕不开的问题插件和Podfile的联动很容易变成团队里的“黑魔法”。我的观察是CocoaPods二进制化更适合那些依赖关系非常复杂、整个团队都以CocoaPods为唯一依赖入口的大型工程。如果依赖数量不多或者只是想把某几个重点组件二进制化Carthage一条命令直接从源码构建出xcframework链路短、不需要额外维护ruby插件出问题时也更容易定位。5.3 对SPM二进制分发的一点观察SPM的binaryTarget设计确实越来越顺眼可以在Package.swift里声明xcframework的下载地址整合度很高。但对于内部私有组件使用SPM意味着每一份二进制发布都要维护对应的Package.swift版本清单还要考虑Xcode版本兼容性。如果你的团队还没有全面转向SPM那为了一两个组件引入SPM二进制链路可能不如直接用Carthage。如果未来某一天所有工程都切换到SPM二进制分发的主力方式大概率是SPM的binaryTarget届时Carthage会渐渐变成纯构建工具。但在当下这个阶段Carthage作为“产二进制”的中间工具依然是对团队改动最小的方案。把这套流程跑通之后我再处理二进制交付类需求时默认动作就变成了先看依赖能不能用Carthage构建能就开始写xcconfig而不纠结是不是要上CocoaPods二进制插件。对我自己来说最有价值的不是某条命令而是想明白“告诉Carthage我要什么产物”这件事——它只是构建工具但正因为如此它愿意听你指挥。最后给一个团队里的小约定static.xcconfig一定要入库、由固定的人维护别让大家本地各写一份哪怕只差一个空格构建出来的二进制都可能不一样。