做独立组件、拆模块这件事,我见过太多团队一直在用最原始的方式:建一个文件夹,把公共代码拖进去,然后几个工程各存一份。这种办法看起来省事,实际上每次改代码都像开共享文档一样恐怖——这个工程改了,那个工程没改,线上出了 bug 查半天,最后发现根因是两端代码根本不是一个版本。后来我把公共模块改成用 Swift Package Manager(SPM)管理成本地库,一套代码多处引用,编译期直接合入工程,不用再靠人肉同步。这篇文章就把我完整的实操过程、manifest 的写法、目录规范以及踩过的坑全部整理出来,给正在纠结怎么落地组件化的朋友一个可以直接抄作业的参考。
1. 本地库这个需求,为什么我建议优先用 SPM 解决
1.1 组件化的本质是"依赖边界",不是"文件拷贝"
很多人对组件化有个误解,觉得把代码从主工程里拎出来放到一个单独目录就是组件化了。其实真正的组件化要解决的是依赖边界问题:A 模块能用 B 模块的哪些接口,完全由 B 模块自己暴露的public/open级别决定,而不是靠团队纪律来约束"这个文件你别乱 import"。
用 SPM 做本地库,最大的变化就是它强制你从"文件思维"切换到"包思维"。你在Package.swift里声明products和targets,然后主工程通过依赖声明引用这个包。这个时候,模块内部的所有实现细节都被藏着,只有标记成public的接口才对外可见。编译器就是你的监督员,任何越界访问都会直接报错,比 code review 里口头叮嘱一百遍都管用。
1.2 什么时候选本地库,什么时候该上远端仓库
我见过不少团队一上来就想搭远端私有仓库,结果架设 Git 服务、配 CI、发 tag、维护版本号这一套流程走完,一个月过去了,业务代码一行没写。这里我给一个比较务实的判断标准:
- 如果公共代码只在这一个工程里用,或者两个工程都躺在同一个仓库里,建议直接用本地库,路径依赖,改完立刻生效,不用考虑版本同步。
- 如果公共代码要被多个独立仓库的工程引用,且这些工程可能是不同团队维护的,那才需要上远端仓库 + tag 版本管理。本地库解决不了多仓库协作时的版本一致性问题。
说白了,本地区分依赖更适合"单体仓库内的模块化"这个场景,先把依赖边界划清楚,等真的出现多仓库协作的需求,再迁到远端也不迟。
1.3 和 CocoaPods 私有库、Carthage 的取舍对比
我早期也用过 CocoaPods 搭私有库,podspec的s.source_files、s.dependency写起来不难,但每次pod install都要跑一边依赖解析,而且 Pods 工程和你主工程之间隔了一层抽象,断点调试偶尔会出现"进不去源码"的情况。Carthage 更偏二进制分发,不编译进工程,调试要自己管理 framework,本地开发用起来很别扭。
SPM 的方案是直接在主工程的编译体系里跑,点开工程就能进源码断点,依赖关系写在一个Package.swift里,一眼看全。以下是我当时做选型时的对比记录:
| 维度 | SPM 本地库 | CocoaPods 私有库 | Carthage |
|---|---|---|---|
| 配置入口 | Package.swift | podspec | Cartfile |
| 依赖解析速度 | 快,只构建本地图 | 慢,每次全量解析 | 快,但需手动链接 |
| 源码调试 | 直接断点 | 偶发进不去 | 可行但多一层 |
| 资源文件 | Bundle.module | resource_bundles | 需手动处理 |
| 版本管理 | Git tag 或路径 | 必须上传 spec 仓库 | Git tag |
| 多仓库协作 | 不擅长 | 比较成熟 | 一般 |
真要做组件化,我现在的首选几乎都是 SPM,原因很简单:它和 Xcode 工程是同族工具,不用额外引入 Ruby/Gem 那套环境依赖,新同事 clone 完代码打开工程就能编译,少了很多环境问题。
2. Package.swift 拆开看:manifest 里的每一行都是约束
2.1 manifest 文件的结构与版本语义
SPM 的核心配置全在包根目录的Package.swift里,这个文件以// swift-tools-version:5.9这样的注释开头,注意它不是普通注释,而是告诉编译器你要用哪个版本的 Swift 工具链来解析这个包。这个字段很关键:如果写太高,老 Xcode 直接打不开包;写太低,一些新语法就用不了。我的建议是:比团队最低 Xcode 版本自带的 Swift 低一个小版本,稳妥优先。
一个最基础的 manifest 长这样:
// swift-tools-version:5.9 import PackageDescription let package = Package( name: "AppNetwork", platforms: [ .iOS(.v14), .macOS(.v12) ], products: [ .library(name: "AppNetwork", targets: ["AppNetwork"]) ], targets: [ .target(name: "AppNetwork") ] )这里面platforms里的最低版本不是随便写的,它会被主工程 build setting 里的 Deployment Target 约束住。如果本地库写了.iOS(.v15)而你主工程最低支持 iOS 14,直接把包拉进工程的那一刻它就会报"最低部署版本冲突"。这个错误很常见,而且是编译前就报出来的,处理办法就是重新审视库里到底用没用 iOS 15 的 API,没用就把声明降下来。
2.2 products 和 targets 的对应关系
很多人把products和targets搞混,以为它们是一回事。其实targets是编译单元,products是暴露给外部消费的入口。一个包可以有好几个 target,但不需要全部暴露出去,只有写进products的 target 才允许被主工程import。
比如下面这种结构:
products: [ .library(name: "AppNetwork", targets: ["AppNetwork", "AppNetworkSSL"]), ], targets: [ .target(name: "AppNetwork"), .target(name: "AppNetworkSSL"), .target(name: "AppNetworkTests", dependencies: ["AppNetwork"]), ]主工程只能import AppNetwork,而AppNetworkSSL是内部共享组件,对外不可见。这种设计帮我堵住过不少"内部实现类被外部滥用"的问题,比代码规范里的命名约定靠谱得多。
2.3 dependencies 里的 path 参数怎么用
本地库最常见的使用方式是在主工程的Package.swift里通过.package声明一个 path 依赖:
dependencies: [ .package(name: "AppNetwork", path: "../AppNetwork") ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "AppNetwork", package: "AppNetwork") ]) ]这里有一个容易踩的细节:.package(name:path:)里的 name 建议和包内部的 name 保持一致。如果不写 name,SPM 会默认用 path 最后一个路径段作为包的身份标识,这时候如果你的目录名和 Package.swift 里的 name 不一致,后面在主工程引用时容易出现 "Cannot find 'AppNetwork' in scope" 这类奇怪的错误。我自己就吃过这个亏,目录叫appnetwork,包名却叫AppNetwork,导致 Xcode 界面里显示的包名和代码里 import 的名字对不上,排查了半天。
3. 目录骨架和命名规范:先定结构再写代码
3.1 标准的 Sources/Tests 结构
SPM 的目录结构约定非常严格,不按约定走,编译阶段就会找不到源文件。标准的骨架是这样:
AppNetwork/ ├── Package.swift ├── Sources/ │ ├── AppNetwork/ │ │ ├── HTTPClient.swift │ │ └── Response.swift │ └── AppNetworkSSL/ │ └── SSLConfig.swift └── Tests/ └── AppNetworkTests/ └── HTTPClientTests.swift注意Sources下每一层目录名必须和 target 名完全一致,SPM 会自动把同名目录里的.swift文件归入对应 target。如果你把文件放到了没有匹配 target 的目录里,Xcode 不会主动报错,但这个文件会直接被忽略,等你代码里调用它的函数时才会弹出一堆"Undefined symbol"之类的提示。
3.2 多模块怎么拆:按依赖方向分层
本地库的内部目录可以拆多个 target,但拆之前要想清楚依赖方向。我曾经把一个库拆成四个 target,结果它们之间互相依赖,形成了一个环,SPM 直接拒绝编译,报错信息大意是"cyclic dependency detected"。后来我按"低层不依赖高层"的原则重新设计:
AppNetworkCore:只包含最基本的 HTTP 请求逻辑,不依赖任何其他业务代码。AppNetworkSSL:依赖AppNetworkCore,负责 SSL 相关的配置。AppNetwork:依赖前两者,给外部提供统一入口。
这样依赖关系就是单向的,SPM 构建的时候能明确知道先编译谁的依赖。拆分粒度上我的经验是:不要为了拆而拆。如果某个子模块没有任何独立的复用场景,拆出去只会增加配置成本。至少想清楚"未来谁会单独用它",再决定要不要单独建 target。
3.3 Tests 目标要不要暴露
SPM 会把Tests目录下的 target 识别为测试目标,但默认这个 target 不会被任何products暴露,所以外部永远不可能 import 到测试代码。这个设计我是很满意的,它从机制上保证了测试代码不会泄漏到生产环境。
写测试代码时唯一要注意的是依赖声明:
.testTarget( name: "AppNetworkTests", dependencies: ["AppNetwork"] )这里dependencies数组里写的 target 名不需要加.product包装,直接字符串写 target 名就行。另外,如果你的测试代码里要用 XCTest,不需要像 CocoaPods 那样额外声明依赖,SPM 会自动链接 Swift 工具链里的 XCTest。
4. 两种本地集成方式:Xcode 可视化操作与 path 依赖
4.1 Xcode 图形界面添加本地包
如果你不想动主工程的Package.swift,Xcode 提供了可视化的本地包添加方式。入口是在工程设置里选择项目名,切到 Package Dependencies 页签,点加号,然后选 "Add Local...",找到本地库所在目录即可。
这种方式的背后其实也是 path 依赖,只是 Xcode 帮你写好了。添加之后,左侧导航栏会出现这个包,你展开就能看到包里所有源码,双击文件可以直接编辑,断点、单步调试都跟原生代码一样。这个体验比 CocoaPods 强太多,Pods目录里的代码虽然也能看,但有时候调试器会诡异地在.h头和源码之间跳来跳去。
4.2 在 Package.swift 里手写 path 依赖
如果主工程本身已经是 SPM 体系(有自己完整的Package.swift),我更推荐直接在 dependencies 里声明本地路径。这么做的好处是依赖关系是显式的、可被版本控制的。你把它提交到 Git 仓库后,同事 clone 下来不需要任何额外操作,只要路径相对关系还在,open工程就能编译。
一个典型的相对路径写法:
.package(name: "AppNetwork", path: "../Modules/AppNetwork")这里路径是相对于主工程的Package.swift所在目录的。注意 Xcode 识别本地包的路径,跟你从哪个.xcodeproj文件打开工程没关系,它只看Package.swift的相对位置。所以如果你把工程文件挪了位置,或者多人 clone 后目录层级不同,这个路径就会失配。我一般建议团队里约定统一的仓库布局,比如所有模块放在Modules/目录下,路径就不会乱。
4.3 Package.resolved 与显式管理的差异
Xcode 在解析包依赖后会在工程根目录生成一个Package.resolved文件。本地 path 依赖也会被记录进去,但记录的方式和远端包不同,它只记录一个本地路径引用,版本信息通常是空的。这个文件我建议提交到 Git 仓库,这样团队所有成员锁定同一份依赖解析结果,避免"我机器上能编译,你机器上不行"的尴尬。
有一点值得注意:本地路径依赖和远端 URL 依赖解析策略不一样。远端 URL 依赖会按照 tag 或 branch 去拉取;本地 path 依赖没有任何版本校验,它永远指向你磁盘上当前的文件状态。这意味着只要有人改了本地库的代码,不需要发版、不需要改动 resolved 文件,重新编译就生效。好处是迭代速度快,坏处是团队协作时有人改了但不提交,别人的编译结果就和你不一致。这个问题的解法我放在下一节详细说。
5. 版本号、缓存和 clean build:本地库日常开发最磨人的地方
5.1 本地库要不要打 tag
本地 path 依赖天然不关心 tag,它就是指哪打哪。但如果你本地库同时也在远端仓库里维护,我建议还是按正式流程打 tag。原因很简单:未来某一天,本地区分依赖可能因为跨仓库复用而被替换成 URL 依赖,到时候没有 tag,整个工程就直接编译不过了。
打 tag 的操作很简单:
git tag 1.2.0 git push origin 1.2.0版本号我用的是语义化版本规范:major.minor.patch。破坏性的接口变更升 major,新增功能向后兼容升 minor,修 bug 升 patch。这套规范本身不复杂,但它能告诉依赖方哪些版本是安全的升级,哪些是要小心适配的。
5.2 改了本地库代码,主工程不生效的排查方法
这是我碰到最多的一个困惑,明明改了本地库的源码,回到主工程一键编译,行为却还是旧逻辑。出现这个问题,先别急着怀疑 Xcode 缓存,按下面顺序排查:
- 确认你改的文件确实属于被主工程引用的那个 target。如果一个文件在 Xcode 导航栏里是灰色或带斜体,说明它没有被当前 scheme 纳入编译,改一百遍也没用。
- 检查本地库的
Package.swift是否改过products或targets名称。如果改过,主工程里的引用可能还指向旧名字,解析失败后 Xcode 会静默回退到缓存版本。 - 执行一次
Product -> Clean Build Folder,快捷键是Shift + Command + K的组合键更深层的清理。这个操作会删除DerivedData里对应工程的构建产物,SPM 的本地缓存也会被强制刷新。 - 如果还不行,关掉 Xcode,手动删除工程根目录下的
Package.resolved,重新打开工程让 Xcode 重新解析。
大多数情况下到第三步就能解决。清理构建产物是最直接的,别嫌它慢,该 Clean 的时候别手软。
5.3 缓存目录构建缓存是怎么回事
除了DerivedData,SPM 还会在~/Library/Caches/org.swift.swiftpm/目录下缓存已解析的包。远端 URL 依赖的源码会被下载到这里,本地 path 依赖的缓存机制比较薄,但如果遇到本地库文件没变化、主工程怎么都不重新编译的情况,可以考虑清这个目录。
我实际测试下来,org.swift.swiftpm缓存对 path 依赖的影响不大,真正影响大的是DerivedData。所以如果你改了本地库不生效,优先清理DerivedData即可。注意清理DerivedData会让你整个工程重新全量编译,首次会慢,但能解决大量诡异问题。
6. 资源文件与 Bundle.module:题材冷门但实际天天踩
6.1 SPM 的资源目录声明
纯代码库一般遇不到资源文件问题,但只要你的轮子库里有图片、JSON、XIB、字体,就绕不开 SPM 的资源机制。和源文件不同,SPM 不会自动把Sources/AppNetwork/下的所有非 Swift 文件当成资源,你必须在 target 声明里显式指定:
.target( name: "AppNetwork", resources: [ .process("Resources/Images") ] ).process会按 Xcode 的编译规则处理资源,比如图片会被压入 Asset Catalog 逻辑,.copy则是原样拷贝,不做任何处理。如果你只想打包一个 JSON 配置文件,用.copy更保险,因为.process有时候会对未知类型资源做奇怪的处理。
6.2 在代码里如何拿到资源路径
SPM 会自动为每个 target 生成一个Bundle.module静态变量,这个变量在编译期被解析成当前包对应的资源 bundle 路径。获取资源的写法是:
let configURL = Bundle.module.url(forResource: "AppConfig", withExtension: "json") let image = UIImage(named: "logo", in: .module, compatibleWith: nil)注意UIImage(named:in:compatibleWith:)这种写法,直接在in:参数里传.module就行。千万别用Bundle.main,因为 SPM 包的资源会被打进主 App 的 bundle 或独立的 framework bundle 里,用Bundle.main永远找不到。
6.3 xib 和 storyboard 的坑
如果你在包里有 xib 文件,使用Bundle(for:)或者Bundle.module都可以,但有一个更隐蔽的问题:xib 里关联的 class 必须是public的,并且 IBOutlet 的访问级别也要够。原因是 Xcode 在编译 xib 时会在运行时动态查找类符号,如果类是internal的,主工程链接时可能访问不到,直接抛异常。
我的经验是:SPM 包里能不用 xib 就不用 xib,用纯代码写 UI 最省心。不是 xib 不好,而是 SPM 的资源和编译模型对它支持得不够顺手,每次改 xib 后清理缓存那一下真的很浪费时间。
7. 问题复盘:四个典型案例的完整排查链路
7.1 "Missing package product 'AppNetwork'" 的根因与解法
这个报错我遇到过两次,第一次是无解的,后来统计了一下触发时机,基本可以分成两类:
- 改了本地库的
products名称,但主工程仍然引用旧名称。 - 主工程里存在两份同名但内容不同的包声明,比如 Xcode 可视化添加的本地包和
Package.swift里手写的 path 依赖指向了不同目录。
排查顺序建议:
1. 看报错出现在哪个 target 的编译阶段,确认是主工程还是扩展 target。 2. 打开主工程 Package Dependencies 页签,删除旧的包引用,重新 Add Local。 3. 检查主工程 Package.swift 里的 `.product(name:package:)`,是否和本地库 products 完全一致。 4. 删掉 DerivedData 和 Package.resolved,重开工程。这个链路走完,问题基本解决。如果还不行,大概率是你的本地包路径下又有另一个.xcodeproj,Xcode 在递归索引时解析错了。
7.2 终端 swift build 正常,Xcode 里却 "No such module"
这个问题第一次遇到时我懵了很久,因为终端明确能编译,就说明代码本身没问题。后来定位到根因是 Xcode 的 target membership 问题。SPM 包在 Xcode 里会被解析成底层的 framework target,但如果你主工程的某个 target 没有把它加到 "Frameworks, Libraries, and Embedded Content" 里,Xcode 的编译环境里根本没有这个模块,自然就No such module了。
另一个隐藏原因是你主工程和本地库的 Swift 版本不匹配。比如主工程用 Swift 5.0 模式编译,本地库却要求的 swift-tools-version 是 5.9,Xcode 会提示但有时提示不显眼。解决方法是:把本地库的最低工具链版本调到主工程能接受的范围内,或者在 Build Settings 里统一 Swift Language Version。
7.3 Bundle.module 打出来的图片是 nil
我自己写轮子库时就踩过这个坑。资源文件已经放在Sources/AppNetwork/Resources/Images/下,target 声明里也写了.process,但运行的时候图片还是 nil。排查后发现问题出在:UIImage(named:)在 iOS 系统里默认会到Bundle.main里找,它不认Bundle.module创建的独立 bundle,你必须显式用.module。
正确的写法以及一个常见误区:
// 正确的写法 UIImage(named: "logo", in: .module, compatibleWith: nil) // 错误的写法,虽然不报错,但总是 nil UIImage(named: "logo")另外要注意资源文件命名,SPM 在 Copy Bundle Resources 阶段会做一些文件名处理,如果你的资源名带空格或者中文,在 module bundle 里访问时的路径和原始文件名可能不一致,导致找不到。稳妥的做法是:资源文件一律用英文小写加下划线,避免特殊字符。
7.4 编译通过但是运行时 dyld: Symbol not found
这个比较吓人,编译好好的一跑就崩。说一个我真实遇到过的场景:本地库 A 依赖另一个本地库 B,主工程直接 import A 的顶层 API,结果 A 内部调用 B 的某个函数时崩了。排查后发现原因是对executable这个 target 的定义不明确,以及在链接静态库的时候产生了重复符号。
在 SPM 里,如果一个 target 既被主工程依赖,又被库 A 依赖,而且它不是显式的 executable target,Swift 5.4 之前容易出现链接混乱。解决办法是升级 swift-tools-version,并且在 target 前显式声明:
.executableTarget( name: "AppNetworkCLI", dependencies: ["AppNetwork"] )如果你不想暴露一个可执行入口,只想让所有 target 都是库,那就要检查主工程和本地库之间是否存在"同一个源码文件被两份 target 同时编译"的情况。这种重复编译在最终链接时会产生重复符号,且只在运行期以Symbol not found的形式浮现出来。解决方式是从依赖图上保证每个源文件只属于一个 target。
7.5 复盘总结:本地库排错的通用方法
把上面四个案例放在一起看,其实所有问题都可以归结为两个维度:依赖关系解析和资源 bundle 定位。遇到 SPM 本地库的问题,我现在的排查顺序固定是:先看 Package.swift 和依赖关系,再看目标 target 是否链接了包,再看缓存,最后才是代码层面的怀疑。按这个顺序走,90% 的问题都能在十分钟内定位到根因,不至于像无头苍蝇一样到处乱改。
使用 Swift Package Manager 做本地库这件事,门槛其实不高,一个Package.swift加上标准的目录结构就能跑通。但真正决定体验好坏的其实是这些细节:版本怎么管理、资源怎么放、缓存什么时候该清、依赖关系怎么设计。把这些细节处理好了,本地库才能真正承载起组件化的目标。我也还在一边做一边积累,如果你们团队在实际集成的过程中碰到过本文没覆盖到的坑,建议先从依赖图入手去梳理,多半能找出原因。