随着狐友App业务迭代加速,iOS工程增量编译峰值达200秒平均耗时120秒,严重制约开发效率。本文提出轻量化治理方案:通过自研接口-实现-服务三层隔离架构实现编译期依赖阻断,并基于Swift Package Manager(SPM)重构依赖管理体系。方案落地后,100+个模块完成SPM迁移(95%代码Swift化),增量编译时间平均值降至50秒(降幅58%),峰值同步显著下降,每日节省开发工时3.1小时。全部人力投入在9个月内回收,为同量级移动应用提供了可复制的编译优化实践。

关键词:编译优化;Swift Package Manager;接口隔离;依赖注入;模块化治理

架构演进与技术痛点

在软件开发的实践中,分层设计和模块化是提升代码质量和效率的关键策略。狐友App在2021年已经基本完成了架构的优化,实现了初步的模块化。

简单依赖-黑

在狐友App的早期阶段,业务功能相对简单,模块之间的依赖关系也较为直接,通常是上层模块依赖底层模块,形成一种垂直依赖关系。这种依赖关系相对简单,有助于保持构建过程的稳定性和高效性。由于模块数量相对较少,缓存编译的优势得以充分发挥,不会因为模块的增加而导致编译时间显著增长,从而确保了总体构建时间的可控性。

依赖复杂-黑

但是,单靠模块化不足以防止构建时间变慢,随着狐友App业务的拓展与新模块的引入,其依赖关系图逐渐呈现出复杂化的趋势,这些模块间的相互依赖复杂性呈指数级增长。纵向依赖与横向依赖关系同时存在,

在增量构建过程中,理想情况下只需重新编译改动的模块,但实际并非如此。任何对特定模块的更改不仅会导致该模块本身失效并触发重新编译,而且还会牵一发而动全身,迫使所有依赖该模块的其他模块也进入失效状态,进而进行重新编译。

编译传递-黑

这一连锁反应造成了严重的后果:高度模块化的应用程序,尤其是那些具有错综复杂依赖关系的,很快就会面临构建时间的急剧增加。

以图中示例为例,我们假设位于基础层的最右侧的黄色模块被修改,会导致这条传递依赖链上的所有模块重新编译,也就是所有标记为Rebuild的模块,即使模块的代码没有任何修改,都将被重新编译。

当一个App是只有十几个模块的小项目时,即使存在上述问题,编译时间相对而言也是可控的;但随着我们的应用程序增长到数百个模块,所有这些重新编译,对构建时间的影响变得越来越明显和不可持续。

在当时,团队成员常会不由自主地抱怨:“就(只是)修改几行代码,怎么编译这么久还没完成?”,平均增量编译时间当时在120秒左右,也就是说可能你改几行代码吗,可能要等2分钟才能编译完成;

由此可见,依赖关系的复杂性不仅严重影响了编译速度,而且降低了开发效率,增加了维护成本,甚至可能导致项目延期。因此,优化模块间的依赖关系,减少不必要的重新编译,是提高软件架构质量和开发效率的关键所在。

代码百万行时,架构的核心矛盾就从“如何实现”变成“如何高效协作且持续演进”。因此设计决策必须考虑长期可维护性。

方案调研

现有的成熟优秀架构,如MVC (Model-View-Controller),MVVM(Model-View-ViewModel),VIPER(View-Interactor-Presenter-Entity-Router)等,都在狐友App中有广泛使用,这些架构主要聚焦于模块内部的代码组织和分层设计,不考虑架构设计对编译时间的影响;

以抖音为代表的超大型App,采用完全自研bazel 为核心的 iOS 构建系统,采用深度定制开发的模式,解决类似问题;但是这种方案的特点是投入巨大,需要公司级别的基础架构团队持续开发和维护。

JoJo 是字节自研的以 bazel 为核心的 iOS 构建系统,提供了从 CI/CD 到本地构建开发所需要的一整套解决方案;实现编译缓存机制的核心问题是构建任务的依赖计算。与一般的构建系统不同,JoJo 将远端缓存、远端执行和依赖计算结合了起来。JoJo 在本地构建时,实现了类似 Xcode 的增量构建方案——通过上次构建的 C 系或 Swift 系源码后编译器生成的.d 文件来获取构建需要用到的所有文件,从而进行依赖计算。这里的.d 文件是一种依赖描述文件,在编译器完成一次构建后,就会生成.d 文件,用以描述本次构建过程中所涉及到的所有文件。

舒彪《超级 App 构建效能提升 40%!JOJO,字节自研 iOS 构建系统》 https://mp.weixin.qq.com/s/dFkGjCgZeXbYxsu3F40L-Q

同时还要结合深度修改包依赖管理工具CocoaPods,降低依赖决议消耗时间;

  • 采用 CocoaPods 本身自带的版本依赖决议进行版本分析会消耗大量的时间;
  • Podfile.lock 过于繁琐,可读性很差,难以解决 Podfile.lock 的冲突;
  • 隐式依赖被动/不符合预期地升级,难以确定性地声明所有依赖,防止隐式依赖被升级;
  • 依赖版本在 Podfile/Podfile.lock 重复声明,增加了解决冲突的成本;
  • Podfile.lock 参与依赖版本决议流程比较复杂,会出现不符合预期的情况。

从技术角度看,bazel自研涉及编译链改造和缓存集群建设;CocoaPods深度定制则要解决ruby生态的历史包袱。都是投入巨大,且需要持续维护,从ROI(投入产出比)角度,并不适合狐友团队。

因此,我选择 轻量化整合SPM与自研新的架构设计模式,预期用20%的投入达成90%的编译优化效果,实现团队效能与资源投入的最优平衡。

解决方案

分为两个部分,一是使用Swift Package Manager(SPM)作为包管理工具替代CocoaPods;二是自研了一套针对iOS的架构设计方案;

使用SPM替代CocoaPods

维度 *CocoaPods SPM 决策依据
依赖解析效率 全局锁决议(O(n²)) 拓扑排序(O(n)) 模块超100时,速度差达10倍
模块隔离性 弱(头文件全局可见) 强(target-based) 根治隐式依赖
编译缓存支持 支持,缓存粒度较粗(针对整个 pod) 原生支持,细粒度缓存(单个文件/模块) 增量编译优化的基石
苹果生态整合 第三方维护(常需处理Xcode兼容性问题 苹果官方支持,无兼容性问题 长期技术红利
生态系统 拥有庞大的 pod 库,第三方库全面 逐步完善(主流库已支持,C++/OC需桥接) 自研转换工具补齐能力(转Pod到SPM)
  1. 依赖决议效率——差10倍(O(n²) vs O(n))

    • CocoaPods依赖解析需遍历全量Spec仓库
    • SPM依赖图采用有向无环图(DAG)拓扑排序,复杂度仅O(n)
  2. 模块隔离性的本质差异

    • CocoaPods将所有头文件暴露给主工程(HEADER_SEARCH_PATHS全局传播)
    • SPM强制通过public关键字显式导出接口(访问控制规则)
  3. 编译缓存机制的本质差异

    指标 CocoaPods SPM
    最小缓存单元 整个Pod(如Alamofire全库) 单个Swift文件
    增量编译触发条件 Pod内任意文件修改 仅修改的文件+接口未变的依赖模块
    技术原理 无原生增量支持,依赖Xcode 基于LLVM -incremental编译
  4. 构建流程的稳定性差距

    • CocoaPods需生成Pods.xcodeproj再调用`xcodebuild,易因Xcode升级失效
    • SPM被Xcode原生集成(直接调用swift-build),无中间层故障点

自研架构设计方案

重点针对增量编译慢的问题,提出一种新的模块之间依赖设计模式:

  • 使用Swift Package作为包管理用具,建立本地依赖关系
    • 强物理隔离、target机制强制模块边界,天然阻止了隐式依赖
  • 借鉴了后端Java Spring框架的“依赖注入”和“接口与实现分离”的思想,自研了一套针对iOS的架构设计方案;

    • 具体来说,在每个模块内部,按照分层设计理念,最多分为三层:

      • 接口层(Interface Layer):对外向其他模块提供功能API;
        • 实现层(Implementation Layer):实现具体功能,但是不需要暴露给外部其他模块,当外部模块使用当前模块时,接口层调用实现层;
      • 服务层(Service Layer):负责管理模块间的依赖关系。它通过注册和提供依赖关系,确保模块能够通过服务层获取所需的其他模块或组件的实例,而无需直接创建或管理这些依赖。

依赖关系处理三层

接下来进一步详细介绍每一层的技术实现要点和核心价值:

  • 1. 接口层(Interface Layer)— 契约屏障

    • 技术实现:
      • 模块名 + Protocol 命名空间(如 NetworkProtocol
      • 仅暴露 public protocol 和关联的 public struct/enum
      • 禁止包含任何具体实现或属性存储
    • 核心价值:依赖方仅能访问协议抽象,物理隔绝对实现细节的编译期依赖。当 Implementation 内部逻辑修改时,因接口层二进制接口(ABI)未变,所有依赖方无需重编

    2. 实现层(Implementation Layer)— 闭包实现

    • 技术实现:
      • 实现类命名为 协议名 + Impl(如 NetworkProtocolImpl
      • 访问权限设为 internal(SPM 禁止跨模块访问)
      • 禁止直接暴露给外部模块
    • 核心价值:开发者可在本模块内任意重构实现逻辑(包括替换基础库、算法优化),只要遵守接口契约,所有变更对依赖方零影响,从机制上保障了模块的正交演进能力

    3. 服务层(Service Layer)— 动态胶水

    • 技术实现:
      • 基于 Factory 2.0 轻量DI容器(开源框架—大量调研测试后选定
    • 模块名 + Injection.swift 中注册协议与实现类的映射关系
  • 核心价值:调用方通过 Factory.resolve(UserProtocol.self) 动态获取实例,完全解耦协议与具体实现的编译期绑定。这使得:

    • 实现类可热替换(如测试替身、灰度实现)

    • 模块依赖关系显式声明,消除隐式耦合

这三层架构本质是将模块化原则从设计规范升级为编译约束

  1. 接口层用Swift协议机制建立ABI防火墙,是编译优化的物理基础;
  2. 实现层internal封装赋予模块内部重构自由度,屏蔽变更传导;
  3. 服务层通过轻量DI实现依赖倒置,终结了模块间的编译期绑定。

三者形成闭环:SPM保障模块物理隔离 → 接口层建立协议契约 → 服务层运行时动态组装 → 实现层变更被严格封装

最终达成:修改任意非接口代码,0触发依赖方重编。这是狐友在百万行代码量下仍能保持敏捷迭代的核心架构支柱。”

依赖注入框架选型

选择Factory 2.0 这个轻量的DI容器,经过了大量调研与测试,这里列出我们思考和关注的要点,形成了下面的对比表格;

评价维度 CarbonGraph百度 Needle Uber Resolver 知名开源 Swinject知名开源 Factory2
API使用复杂度:应接近 Swift 标准 API ,保持可读性好、学习曲线低
代码的入侵程度:入侵程度低,耦合度低更佳
编译时安全:首选以防止隐藏的崩溃。如果应用程序构建,我们知道所有依赖项都已正确配置
是否需要强制展开 需要解包 不需要 手动控制 不需要 不需要
性能指标:内存占用、解析效率 扫码,内存占用大不扫描,快 编译慢运行低 不扫描,手动注册 不扫描手动注册 不扫描手动注册
对象自动释放 是实例支持autorelease 否没有实例缓存没有删除机制 手动控制 是,有作用域 有scope概念支持单例不持有对象
Mock测试 容易 容易 容易 容易 容易
二次开发维护成本
更新频率(最后更新日期) 2023.6 不再更新 2024.7 低 2024.4 低 2024.7 2025.5,稳定
是否支持Obj-C 支持
注册限制:时机、耗时 需手动注册支持自动扫描启动注册 启动注册 需手动注册 最佳启动时,动态注册虽然可行,但应谨慎使用 可以任何位置注册,需要暴露出给调用模块

实例验证

为定量验证三层接口隔离架构对编译效率的切实提升,我设计并执行了以下对比测试:

  1. 测试模型构建:(参见下图)

    • 对照组 (传统依赖模式): 创建测试应用 MainApp1。其包含模块 A1A2,其中 A1 直接依赖 A2 的具体实现(即直接导入并调用 A2 的公开类/方法)。
    • 实验组 (三层接口隔离模式): 创建测试应用 MainApp2。其包含模块 B1B2B2 严格遵循接口层 (B2Protocol)、实现层 (B2ProtocolImpl) 和服务层 (B2Injection) 的分层设计。B1 仅依赖 B2 的接口层 (B2Protocol) 并通过服务层 (Factory.resolve) 动态获取实例。B1 不包含任何对 B2 实现层的直接引用。

    编译结果展示

  2. 测试方法与观测点:

  • 分别修改 A2B2 模块实现层的代码(例如,修改text字段文字)。
  • 使用 Xcode Build Timeline 工具精确记录增量编译过程。
  • 核心观测指标: 修改依赖模块 (A2 / B2) 后,上游模块 (A1 / B1) 是否被触发重新编译?整个增量编译耗时变化如何?
  1. 测试结果与分析:
  • 对照组 (MainApp1) 结果:修改A2后,Xcode Build Timeline 清晰地显示:

    • A2 自身被重新编译 (符合预期)。
    • 依赖 A2A1 模块也被标记为失效并触发了重新编译。这是因为 A1 直接依赖 A2 的具体实现细节,编译器无法判断 A2 的修改是否会影响 A1 的二进制兼容性,因此保守地触发 A1 重编。

    修改A2

  • 实验组 (MainApp2) 结果:修改B2的实现层后:

    • B2 的实现层被重新编译 (符合预期)。
    • 关键点:依赖B2的B1模块未被触发重新编译!在 Build Timeline 中,仅观察到B2实现层的编译任务,没有出现B1的编译任务。这是因为:
      • B1 仅依赖 B2 的接口层 (B2Protocol)。
      • B2 接口层的公共协议 (B2Protocol) 本身未被修改,其二进制接口 (ABI) 保持稳定。
      • 编译器能够准确地判断出 B1 的代码无需因 B2 内部实现的修改而重新编译。

修改B2

  • 量化效果: 在此特定测试场景下(包含约3个文件,2个模块的简单应用),采用三层接口隔离方案后,仅修改依赖模块实现层所触发的增量编译耗时减少了约 600 毫秒,优化幅度达 35%这 35% 的减少,几乎完全来自于避免了上游模块 B1 的不必要重新编译。
  1. 结论与意义:
  • 该测试结果有力证实了三层接口隔离架构的核心机制:将模块间的依赖严格限制在接口层,并隔离实现层的变动,能有效阻止修改实现细节向依赖模块传递编译影响。
  • 该机制的价值在于其普适性:在一个模块的修改不涉及接口层变更(如内部逻辑优化、Bug 修复、依赖库升级但不改变对外契约)时,所有依赖该模块的上游模块均无需重新编译。这对于大型工程尤为重要:
    • 在狐友这样拥有数百模块、复杂依赖关系的应用中,一个底层模块的修改常常会引发漫长的依赖链重编(如前文所述)。
    • 应用三层接口隔离后,这种连锁反应被从机制上切断。修改仅限于发生变动的模块自身(或接口发生变化的模块及其直接依赖者)。这正是支撑狐友 App 将增量编译峰值时间从 200 秒降至 50 秒的关键微观基础之一。
    • 避免了大量不必要的编译任务,直接缩短了开发者的等待时间,显著提升了本地开发和持续集成的效率。

量化收益

研发效能

  • 增量编译:120s → 50s
  • 每日节省:3.1人时/日 → (120秒-50秒=70秒) X 20次(单日人均编译次数) X 8 人(团队人数) => 11200 秒 => 3.1 小时
  • 分支合并:2-3小时 → 30分钟(耗时下降75%-83%
  • 组件化率:38 → 115个SPM模块(95%代码是Swift代码)

投入产出比

在投入成本方面,我们将整个项目分为调研、基建、迁移和推广四个阶段,经过估算,总投入成本约为95人/天。这笔投入是一次性的。

而在收益方面,这是持续性的。我们主要从两个方面来衡量:

  1. 日常开发效率:仅编译时间一项,我们每天就能为8人团队节省 3.1个工时
  2. 代码集成效率:过去令人头疼的分支合并,时间从平均2.5小时降至30分钟以内,冲突率下降90%,每周又能节省约 4个工时

综合来看,新架构每天能为我们团队净赚回 将近半个人/天 (0.5人/天) 的高效产出。

最后,我们来算一笔账:用95人/天的一次性投入,换取每天0.5人/天的持续收益,我们的‘投资回本周期’大约是193个工作日,也就是9个月左右。

这意味着,从第10个月开始,这次架构升级带来的所有效率提升,都将成为团队的‘净利润’。 这不仅加速了我们的业务迭代,更重要的是,它为狐友App未来3-5年的健康、高速发展扫清了技术障碍,这是一项具有深远战略价值的投资。

结语

狐友iOS这次架构升级,核心就解决了一个大难题:编译实在太慢了,改几行代码就得等上好几分钟,太耽误事了!

我们摸索出来的方案:

  1. 换工具:用苹果自家的 Swift Package Manager (SPM) 替代 CocoaPodss。SPM 更快(依赖处理快10倍!)、更稳(苹果亲儿子兼容性好)、隔离性更强(模块不乱串门),给整个工程打了稳地基。
  2. 改架构:搞了个“三层隔离”的设计(接口层、实现层、服务层)。简单说,就是让模块之间通过“合同”(接口)说话,而不是直接粘在一起。这样,里面怎么改,只要“合同”不变,外面用它的模块就不用跟着重编译,省了大把时间。
  3. 算明白账:前前后后投入了大约95个人天。效果呢?非常值! 编译时间从最慢200秒降到50秒,每天能给团队省下3个多小时;分支合并那种烦人的冲突也少了92%,省心多了。算下来,这点投入9个月就能全赚回来,后面都是“净赚”的效率。

总结一下: 这次折腾,不是追求什么高大上的理论,就是为了实实在在让开发更快、更爽。工具用对(SPM),设计想好(三层隔离),花点力气改改,效果立竿见影。狐友的经验说明,面对大项目编译慢的老大难问题,不用非得大动干戈搞自研基建,​用好现有生态,做聪明的设计调整,也能花小钱办大事。​​ 希望这个接地气的法子,也能帮到有类似烦恼的团队。