先说明一下iOS工程的编译速度优化看起来是个“老生常谈”的话题但每个团队真正动手时面对的坑都不一样。我前前后后负责过几个中型iOS项目的构建效率整改有从单工程几百个文件往组件化过渡的也有从全量重编译耗时近二十分钟压到五分钟内的。这篇文章不打算堆概念就把我在实际排查和改造过程中反复验证有效的东西按一条比较自然的路线写出来。1. 先搞清楚时间到底耗在哪里不要凭感觉优化很多人觉得工程编译慢第一反应是“换台新电脑”或者“把CPU调高”但其实大多数情况下瓶颈根本不在处理器算力上。我不止一次看到团队花了钱升级机器结果编译速度几乎没有变化因为时间被浪费在重复打包资源、脚本阶段串行执行、第三方库反复重新编译这些地方。1.1 从一次全量构建的计时开始在动手改任何东西之前先做一次干净的基线测量。所谓干净构建指的是清空DerivedData之后从零开始完整编译一次工程。为什么强调这一点因为增量构建的数据受改动范围影响太大今天改一行代码和改十个文件耗时完全不具备可比性。只有全量构建的时间曲线相对稳定才能作为后续对比的依据。具体做法不复杂打开终端先用xcodebuild -workspace 你的工程.xcworkspace -scheme 你的Scheme -configuration Debug clean清理再执行一次完整构建同时把输出日志完整保存下来xcodebuild -workspace 你的工程.xcworkspace -scheme 你的Scheme \ -configuration Debug -destination generic/platformiOS Simulator \ -derivedDataPath ./build_benchmark build 21 | tee full_build.log这里加-derivedDataPath是为了不让构建产物污染默认的DerivedData目录后续想删就删比较干净。另外建议用Simulator而不是真机来测因为真机构建还会多出签名、代码生成等环节干扰因素更多。基线记录至少跑两次取平均值。如果两次差距超过15%建议检查一下机器负载或者磁盘状态最好在没有人抢资源的时候测。记录下来三个数字总耗时、编译阶段耗时、脚本与资源处理耗时。这三个数字在后面每一步改动之后都要重新测一遍用来判断改动到底是正向还是反向。1.2 把编译日志拆成“耗时账单”拿到完整日志之后不要一屏一屏地翻那样效率太低。我常用的办法是把日志里属于“编译任务”的行提取出来再按耗时排序。Xcode 15之后可以直接在构建日志里看到每个任务的耗时导出的时候也有对应的结构化数据。但命令行日志一样能看关键字段是CompileSwiftSources、CompileC、ProcessInfoPlistFile、CompileAssetCatalog、Link、PhaseScriptExecution这些标识。我自己会写一个非常简单的解析脚本思路是按构建阶段拆分行识别出每个任务的类型、目标名、耗时。把耗时Top 20的任务单独列出来。再把所有PhaseScriptExecution任务的耗时加起来看看脚本阶段总共占了多少。再把所有CompileAssetCatalog和资源拷贝任务的时间加起来。这套分析做完通常很快就能看出问题。举个例子某个工程全量构建大概是17分钟其中超过6分钟花在一堆Run Script阶段内容还是调用CocoaPods的脚本做资源同步另外有4分钟花在Assets.xcassets的编译上因为里面塞了几百张没有被优化的PNG大图。这两个问题跟CPU核心数一点关系都没有纯粹是工程组织方式的问题。1.3 区分首次构建与增量构建基线除了全量我还建议大家记录一组“典型增量构建”的数据。所谓典型增量指的是你日常工作里最常见的改动比如一个页面文件修改后再编译一次看要多久。这个数字其实才是大多数开发者每天感受到的“编译慢”。增量构建耗时主要由依赖重新编译范围决定。比如Swift里一个被几百个文件引用的公共模型变了那依赖它的模块都会重新编译这比改一个无人引用的私有工具函数要慢几十倍。所以在做优化的时候增量数据比全量数据更有参考价值。我后来的习惯是所有优化项同时看两组数据全量构建时间反映的是“从上手到能跑”的体验增量构建时间反映的是“日常写代码”的体验两个都不能偏废。2. 依赖管理和模块化提速的大头在这里聊完测量进入真正值得投入精力的部分。根据我的经验依赖管理和工程结构对编译速度的影响远大于任何编译参数调整。2.1 库的形态静态、动态、预编译的取舍第三方库在CocoaPods里的常见集成方式有静态库、静态框架、动态框架三种。很多人没有仔细琢磨过它们的编译速度差异实际差别非常大。动态框架Dynamic Framework每个Pod会被编译成一个独立的.framework主工程通过动态链接引入。好处是每个库的编译结果可以复用只要库本身没变增量构建时不会重新编译。坏处是最终App启动时要加载这些动态库启动时间会有一定开销而且库之间如果引用复杂容易产生运行时找不到符号的坑。静态库Static Library和静态框架依赖会被编译进主工程的二进制里链接时打成一个整体。缺点是只要某个底层库变了依赖它的所有代码都要重新链接链接阶段耗时明显上升优点是没有运行时加载开销App启动更快。从编译速度角度看一个比较稳健的做法是稳定且体积大的第三方库优先转成预编译的二进制或者动态框架让它不参与主工程的全量编译自己内部还在频繁改动的模块先保留源码方式等稳定后再慢慢收敛。我见过一个工程第三方依赖有七八十个全部以源码形式参与编译每次全量构建光第三方库就占了大概一半时间。后来把其中最重的一批推送SDK、地图SDK、数据统计SDK换成预编译版本全量构建时间直接从20分钟降到了11分钟左右。这一步几乎不需要改业务代码收益却是立竿见影的。2.2 模块边界怎么切才不后悔组件化拆分的时候大家容易掉进“按页面拆”的思维但其实按变更频率和依赖方向来切才是更合理的。举个典型反例某个团队把“网络层”、“工具层”、“UI组件层”、“业务模块A”、“业务模块B”都拆成了独立Pod听起来很正规。但实际构建时发现“工具层”被几乎所有模块依赖而这个工具层里又放了一个一直在改的实验性工具函数每次一改全工程几千个文件跟着重新编译。这就是依赖边界没切好的典型——低频模块里混入了高频变更的代码。我的建议是分开三块基础能力层网络、存储、日志、埋点、账户态等变更频率很低编译一次后长期复用。通用业务层登录、支付、IM等相对稳定的业务封装变更频率中等。迭代业务层各业务线的页面和流程这部分是改得最快最频繁的。切模块的时候要让高频变更的代码尽可能只被同一层内的文件引用不要向下污染到基础层。如果做不到完美至少要做到“稳定的模块不要依赖不稳定的模块”这是编译速度优化的第一优先级。2.3 依赖树瘦身与隐藏依赖另一个很容易被忽略的点是依赖树本身。很多工程看似只引入了三四个业务模块但通过传递依赖最终参与编译的Pod可能有几十上百个。我之前用一个叫pod deps的思路去梳理发现自己工程里有一个很老的工具库它偷偷依赖了一个早已废弃的图片处理库而那个图片处理库又依赖了好几个系统库整条链条每次构建都会完整跑一遍。瘦身依赖树的动作包括删掉不再使用的Pod要在Podfile里逐个核对注释。把Pod的source路径收紧避免某些库把整个仓库的示例代码也带进编译范围。检查每个Pod的.podspec里声明的依赖如果某行依赖没有被实际使用直接去掉。过于老旧、长期不更新且代码质量差的第三方库考虑用自己维护的简化实现替换。依赖瘦身的效果通常不是线性的。也许你只删掉了一个Pod但是因为删掉之后好几个Pod都不需要再等待那个废弃库编译完整条链路的并行度都变高了。我见过只删了三个无用Pod全量编译时间却缩短了两分钟左右的案例原因就是它们阻塞了后续大量模块的编译。3. Xcode与构建脚本的加速策略工程结构调整到位后剩下的就是Xcode工程配置和构建脚本层面的优化。这一层的好处是改动小、见效快适合作为攻坚阶段的速效药。3.1 调试包编译参数的正确调整先强调一个前提我们优化的主要是Debug包开发体验不要为了提速把Release包的优化也乱改那样会直接影响线上性能。Debug包有几个构建配置可以安全调整调试信息格式把Debug Information Format从DWARF with dSYM File改成DWARF。Debug阶段我们绝大多数时候不需要dSYM符号文件去掉它能让链接阶段节省不少时间而且崩溃堆栈在Debug下用Xcode调试器本来就能直接看到。Release包保持DWARF with dSYM File不变。Swift优化级别Debug下默认是Onone意思是完全不优化、方便调试。这个不要乱改。有些团队为了加快编译把Debug改成O结果调试器里断点乱跳、变量看不到反而更浪费时间。关闭Bitcode新版本Xcode其实已经不太强调Bitcode了但如果你还在用旧工程确认一下Enable Bitcode要关掉。它每次编译都会多做一些中间码生成对本地开发来说纯属负担。还有一个小点很多人会忽略模拟器编译架构。默认Debug包会同时编译真机和模拟器的Architecture如果你平时主要用模拟器调试可以在Debug的Architectures里只保留模拟器需要的架构减少一半编译工作量。真机调试时再切回去或者直接用Release。3.2 Swift编译特性的利用与限制Swift的编译特性和Objective-C差别很大主要体现在编译单元更重Swift取消了OC里那种头文件隔离机制每个源文件导入的模块都会整体参与类型检查和代码生成所以一个文件引用的类型越多整个文件编译越慢。模块化增量不明显Swift的增量编译在一定范围内是有效的但只要有类型定义改变依赖这个类型定义的多个文件都会被标记为需要重新编译。这也是为什么Swift工程里“改动一个公共模型然后全工程重编”的现象特别普遍。针对Swift工程有几个实践我试下来比较有效减少不必要Import。一个文件里import得越多编译期需要考虑的符号越复杂。尽量做到按需导入别图省事把整个UI框架或者网络框架一股脑导入。把公共类型拆到稳定模块。如果某个枚举、常量、协议被很多文件用到它应该放在一个低频变更的基础模块里不要让它在高频迭代的业务模块里频繁变化。谨慎使用宏、全局类型推断特别重的表达式。Swift的类型检查器在某些复杂泛型、链式调用上会消耗极长的编译时间我在项目里遇到过一行正则表达式相关的泛型代码导致单个文件编译两分钟的情况。遇到这种代码拆成明确的类型标注编译时间立刻降下来。3.3 资源目录、脚本阶段和第三方客制化资源处理在编译速度里占据的比例常常被低估。Assets.xcassets如果里面有大量没有压缩的大图编译时会很痛苦。建议用工具对图片做一次压缩和无损瘦身删除未引用的资源尤其要警惕从设计师那边直接拖入工程的PSD大图。脚本阶段Run Script Phase是我排查时最容易发现问题的地方。很多模板工程或者第三方SDK接入文档会要求添加一个脚本阶段比如自动处理资源、更新版本号、生成配置文件。这些脚本本身耗时可能不大但它们会让增量编译的缓存判断失效——只要脚本阶段每次运行都会修改某些生成文件后面依赖它们的编译任务就永远不会被复用导致增量构建退化成全量构建。我遇到过最夸张的例子某工程的Run Script里有一条命令每次构建都强制把某个生成目录里的所有文件更新时间戳重置一遍结果每次改一行代码整个工程的资源编译都要全部重跑。解决方案也很简单给脚本加一个判断只有源文件内容真正变化时才执行更新或者把生成文件放到一个独立目录不要污染主工程的编译目录。另外构建脚本尽量用Internally synced和based on dependency analysis这类选项让Xcode能做增量判断而不是每次无脑执行。所有脚本写完之后可以人为验证一下连续执行两次构建第二次如果没有变化却仍然花费了大量时间说明脚本或者某个编译阶段没有正确处理增量。4. 实测结果与配套习惯优化之后怎么守住成果前面几章讲的是手段这一章说一下我实际做完一轮优化后看到的量级变化以及一些日常很容易毁掉成果的坏习惯。4.1 一组典型的优化前后数据拿我经手的一个中等规模工程举例这个工程大约有几十个内部业务模块三元库二十多个代码量在百万行这个量级上下。优化动作按照“先测量、再拆依赖、再调配置、再清脚本”的顺序推进整轮下来大概花了三周。阶段全量构建典型增量构建优化前19分30秒5分45秒清理无效依赖15分20秒4分50秒核心第三方库转预编译11分10秒3分20秒调整Debug参数与资源9分35秒2分40秒脚本阶段瘦身7分50秒1分55秒这里要说明一下不同工程的差异很大这个数字不保证每个人都能复现但数量级的趋势是一致的工程结构优化带来的收益最大Xcode配置调整次之脚本和资源处理再小一点但积少成多。特别提醒不要只看全量构建的收益。从表里可以看出增量构建从5分多钟降到了不到2分钟这个对日常开发体验的提升其实更明显。因为全量构建一天通常只跑几次而增量构建每改一段代码就要经历一次。4.2 清理、缓存与误伤雷区优化完成后最容易把成果毁掉的操作就是“随手Clean”。很多开发者遇到编译报错第一反应是Clean一下重来。但Clean意味着把所有缓存的编译产物全删掉下一次构建又得全量编译前面优化出来的增量能力瞬间归零。正确的姿势是优先使用Product - Build别动不动Clean。如果遇到编译缓存异常先试着删除单个模块的编译结果或者删DerivedData里对应模块目录而不是全盘清理。把Clean当成最后手段并且理解Clean后第一次构建必然是全量给自己预留时间。另一个常见误伤是把第三方预编译库不小心使用pod install重新改变了配置。每次pod install时会重新生成Pods工程如果你的脚本或配置有“每次重新生成”的逻辑预编译缓存很容易被连带重建。建议在Podfile里把预编译库的源码路径锁定避免每次安装都触发重新编译。4.3 CI构建与本地一致的细节最后讲团队协作里很实在的一点。本地优化做完了CI服务器上的构建配置也得同步调整否则很容易出现“本地两分钟CI十分钟”的割裂情况。几个关键点CI上不要默认打Debug包直接用Release配置构建并把优化参数对齐。CI构建机要保证有足够的存储空间给DerivedData不要每次构建完就自动清空否则增量缓存完全失效。对CocoaPods的缓存目录做好持久化CI可以复用之前下载和编译过的依赖这一项往往能省下三四分钟。如果工程用了生成代码比如自动生成模型、依赖注入代码CI上生成结果也要放进缓存管理不要每次重新生成且不缓存否则会导致后面所有编译步骤失效。我个人对CI的体会是本地优化只解决个人体验CI优化才决定团队的发布节奏。很多工程发布时要在CI上打Release包如果CI构建要十几分钟一旦出包发现问题再改一个下午就没了。把CI构建时间压下来团队的整体发布效率会得到实实在在的提升。最后再分享一个每次优化结束后我都会做的小事情把优化前后的基线和关键配置整理成一份简单的文档放在工程仓库里注明哪些参数能改、哪些不能动以及为什么。这样做不是为了搞流程而是防止过几个月之后有人“好心”把Debug的dSYM打开或者把某条Run Script从“基于依赖分析”改成“每次都执行”让之前的工作白费。编译速度优化不是一次性项目它更像一种需要持续维护的工程习惯。