开篇
iOS 工程「变大」之后会遇到三个必然问题:全量编译越来越慢、模块之间互相 import 无法独立测试、依赖升级牵一发动全身。这三个问题的解法都指向同一件事——模块化,而模块化的载体在今天是 SPM(Swift Package Manager)。
SPM 的定位经历过一次转变。早期它只是「CocoaPods 的轻量替代」,用来拉第三方库;现在它是 Xcode 的一等公民,既是依赖管理工具,也是工程拆分的组织方式。很多团队的实际做法是:用 SPM 管理依赖,用 .xcodeproj 管理 App 与模块——但更彻底的方案是把所有模块都做成 local package,.xcodeproj 只剩一个空壳。
本文按「清单 → 依赖解析 → 本地包 → 二进制目标 → 模块边界 → 条件编译 → 工作区与缓存 → 资源 → Swift 6 → 迁移路径」的顺序展开,基于 Swift 5.9/6、Xcode 15/16 与 SwiftPM 5.9+。结论先给:模块按变更频率切、依赖用语义化版本锁、边界靠访问控制守、Swift 6 分批切语言模式。
一、Package.swift 清单
Package.swift 是一个 Swift 脚本,声明包的身份、平台、产品和目标。最小形态:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "NetworkingKit",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [
.library(name: "NetworkingKit", targets: ["NetworkingKit"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
],
targets: [
.target(
name: "NetworkingKit",
dependencies: [.product(name: "Logging", package: "swift-log")],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")]
),
.testTarget(
name: "NetworkingKitTests",
dependencies: ["NetworkingKit"]
),
]
)
几个必须理解的概念:
swift-tools-version决定清单能用的 API 与默认语言模式。它必须是文件第一行的注释,且不能被其他内容抢先。改它要谨慎:5.9 → 6.0 会带来语言模式的默认变化。platforms声明最低支持版本。省略时 SwiftPM 会用一个很低的默认值,导致「本地能编、CI 报错」。products是包对外暴露的东西,targets是内部构建单元。只有products里声明的才对外可见,这是模块化的第一道边界。dependencies的from: "1.5.0"等价于.upToNextMajor(from: "1.5.0"),即>=1.5.0 <2.0.0。
清单里还可以声明 resources、plugins、executableTarget、macro、systemLibrary、binaryTarget。一个实用技巧是把「测试辅助」单独做成一个 product(如 CoreTesting),让 App 目标不引入测试代码,同时让各 feature 的测试共享 Mock。
二、依赖声明与版本解析
SPM 的版本解析基于语义化版本(SemVer)与统一版本(unified version)策略:整个依赖图里同一个包只能有一个版本,冲突时取满足所有约束的最高版本。
dependencies: [
// 主版本内升级
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"),
// 精确范围
.package(url: "https://github.com/onevcat/Kingfisher.git", "7.0.0"..<"8.0.0"),
// 精确版本(不推荐用于库,仅用于 App)
.package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", exact: "1.7.0"),
// 分支/修订(仅调试用,不可发布)
.package(url: "https://github.com/apple/swift-nio.git", branch: "main"),
],
选择规则的实践建议:
- 库(被别的包依赖) 用
from:或范围,给下游留升级空间。 - App 用
from:,并在Package.resolved里锁定实际版本。 - 绝对不要在要发布的产品里用
branch:或.revision——它不可复现,且 SwiftPM 会在打包时警告。
Package.resolved 是版本锁文件,必须提交到版本控制。它记录了解析出的每个包的确切版本与 revision,是「CI 与本地构建一致」的保证。升级依赖用:
只更新指定包:
swift package update --package-url https://github.com/apple/swift-log.git
全部更新到最新可兼容版本:
swift package update
查看依赖树与冲突原因:
swift package show-dependencies --format json
版本冲突是模块化工程最常见的阻塞。当两个包对同一个依赖要求不兼容的版本时,SPM 会报错并列出约束来源。解法通常有三种:升级其中一个包、把冲突依赖降级为内部模块、或者用 swift package resolve 查看具体约束链后手工调整。
三、本地包与远程包
模块化的关键动作是把内部模块做成 local package。在 Xcode 里 File → Add Package Dependencies → Add Local,或者直接在工程目录下建目录:
MyApp/
├── MyApp.xcodeproj
├── MyApp/ # 只有 App 壳
├── Packages/
│ ├── DesignSystem/
│ │ ├── Package.swift
│ │ └── Sources/DesignSystem/
│ ├── CoreModels/
│ ├── NetworkingKit/
│ └── FeatureFeed/
└── Package.swift # 可选:顶层聚合包
本地包与远程包的区别:
| 维度 | 本地包 | 远程包 |
|---|---|---|
| 版本管理 | 无版本,跟随工作区 | 语义化版本 + resolved 锁 |
| 修改方式 | 直接改源码 | fork 或改 resolved 到本地路径 |
| 构建 | 参与主工程构建 | 同样参与,但内容只读 |
| 适用 | 自研模块 | 第三方依赖 |
| 缓存 | 增量编译有效 | 同样有效 |
远程包也可以用本地覆盖调试:在 Package.swift 里用 .package(path: "../MyFork") 临时替换,或者用 Xcode 的 Override 功能。改完记得改回来,否则 CI 上会因为路径不存在而失败。
一个高频需求是私有远程包。SPM 支持 SSH 与 HTTPS 两种鉴权:
SSH(推荐,CI 用 deploy key):
git config --global url."git@github.com:".insteadOf "https://github.com/"
HTTPS + token(Xcode 会弹窗,CI 里需要配 Keychain):
netrc 或 Xcode Accounts
CI 上拉私有包失败的根因通常是 SSH key 没配或 Xcode 的 -scmProvider system 参数缺失,需要在 xcodebuild 命令里加上 -scmProvider system 让 Xcode 使用系统 git 配置。
四、二进制目标与 XCFramework
有些依赖不方便以源码分发:闭源 SDK、编译耗时极长的库、需要按架构分发的二进制。SPM 用 binaryTarget 支持这类场景。
targets: [
.binaryTarget(
name: "AnalyticsSDK",
url: "https://cdn.example.com/AnalyticsSDK-2.1.0.xcframework.zip",
checksum: "a1b2c3d4e5f6..."
),
// 或者本地路径
.binaryTarget(
name: "LocalSDK",
path: "Frameworks/LocalSDK.xcframework"
),
]
checksum 是 zip 的 SHA-256,用 swift package compute-checksum 生成。校验失败会导致解析报错,这是「换了个 zip 但忘了更新 checksum」的经典问题。
XCFramework 是 Apple 推荐的二进制分发格式,一个包内可以包含多个平台/架构的切片,用 xcodebuild -create-xcframework 从各架构的静态库或 framework 合成。
二进制目标的代价必须清楚:
- 无法调试源码,只能看到符号。
- 不参与 Swift 6 语言模式的检查,混用时要靠
@preconcurrency或@unchecked Sendable兜底。 - 架构不匹配时只在链接期报错,错误信息晦涩。
- bitcode 已废弃,不要为新工程配置。
- 模拟器与真机的切片必须都提供,否则 CI 上跑模拟器测试会失败。
一个务实的建议:自研模块一律用源码,只有第三方闭源 SDK 用 binaryTarget。源码模块能享受增量编译、跨模块优化、Swift 6 检查,这些收益远大于编译时间的节省。
五、模块边界与访问控制
模块化的收益不是「文件分目录」,而是访问控制带来的强制边界。Swift 的访问级别在跨模块时表现如下:
| 级别 | 同模块 | 跨模块 |
|---|---|---|
private | 同文件内 | 不可见 |
fileprivate | 同文件 | 不可见 |
internal(默认) | 可见 | 不可见 |
package | 可见(同包内跨模块) | 可见(同包) |
public | 可见 | 可见 |
open | 可见 | 可见且可继承/覆写 |
internal 是默认值,也是模块化的主力。绝大多数类型应该是 internal,只有需要跨模块使用的才标 public。一个包如果所有类型都是 public,那它和没有拆分没有区别。
// NetworkingKit 对外只暴露协议与入口
public protocol HTTPClient {
func send<T: Decodable>(_ request: Request) async throws -> T
}
public final class URLSessionHTTPClient: HTTPClient {
// 实现细节全部 internal
private let session: URLSession
internal let decoder: JSONDecoder
public init(session: URLSession = .shared) { self.session = session }
public func send<T: Decodable>(_ request: Request) async throws -> T { /* ... */ }
}
package 访问级别(Swift 5.9+)是模块化的利器:它让同一个包内的多个 target 互相可见,但对包外不可见。这样可以把一个包拆成「公开 API target + 内部实现 target」,既保证边界,又不需要为了跨 target 使用而把内部类型标成 public。
// 同包内跨 target 使用
package struct InternalPayload: Codable { ... }
依赖方向必须单向。设计模块时按「变更频率」切,而不是按「类型」切:
| 模块 | 内容 | 变更频率 | 依赖 |
|---|---|---|---|
| DesignSystem | 颜色、字号、间距、基础组件 | 低 | 无 |
| CoreModels | 领域实体、协议 | 低 | 无 |
| NetworkingKit | 网络层、鉴权 | 中 | CoreModels |
| PersistenceKit | 数据库、缓存 | 中 | CoreModels |
| FeatureXxx | 页面、ViewModel | 高 | 上面全部 |
| App | 组装、路由、启动 | 高 | 全部 |
反过来(低层依赖高层)会让模块无法单独编译与测试。想在架构层面进一步约束依赖方向,可以配合 Swift 协议与泛型编程 里讲的面向协议设计——低层定义协议,高层提供实现,依赖倒置。
六、条件编译与平台适配
SPM 支持在清单与源码两个层面做条件化。
清单层面用 .when(platforms:):
.target(
name: "PlatformKit",
dependencies: [
.target(name: "UIKitShims", condition: .when(platforms: [.iOS, .tvOS])),
.target(name: "AppKitShims", condition: .when(platforms: [.macOS])),
.product(name: "SwiftSyntax", package: "swift-syntax",
condition: .when(platforms: [.macOS])),
]
)
源码层面用编译条件:
#if os(iOS)
import UIKit
typealias PlatformColor = UIColor
#elseif os(macOS)
import AppKit
typealias PlatformColor = NSColor
#endif
#if canImport(UIKit)
// 只在有 UIKit 的平台编译
#endif
#if DEBUG
let baseURL = URL(string: "https://staging.example.com")!
#else
let baseURL = URL(string: "https://api.example.com")!
#endif
关键约定:
DEBUG条件由 SwiftPM 自动注入(swift build默认 debug 配置),但 Xcode 工程里需要在 Build Settings 的SWIFT_ACTIVE_COMPILATION_CONDITIONS里手工加。- 不要用
#if os(iOS)来判断模拟器,那是#if targetEnvironment(simulator)。 canImport比os更稳,因为它判断的是能力而不是平台名。- 条件编译的分支必须都通过编译,CI 上要跑多平台构建,否则
#elseif os(macOS)的分支可能长期是坏的。
多平台包里最常见的坑是 platforms 声明与实际可用 API 不匹配:声明 .iOS(.v15) 却用了 iOS 16 的 API,本地因为 SDK 新而不报错,用户在旧系统上崩溃。解法是严格声明最低版本,并在 CI 上跑最低版本模拟器。
七、多包工作区与构建缓存
当模块数量到十几个时,工程结构会变成「一个 App target + 十几个 local package」。这时构建性能成为主要矛盾。
Xcode 的构建缓存策略是:每个 package 独立编译,产物缓存在 DerivedData 里。因此:
- 改一个模块只会重编它和依赖它的模块,这是模块化最大的收益。
- 改清单文件(
Package.swift)会触发整个依赖图的重新解析,比改源码慢得多。 Package.resolved变化会触发依赖重新下载与编译。
CI 上的缓存策略:
cache:
paths:
- ~/Library/Caches/org.swift.swiftpm
- ~/Library/Developer/Xcode/DerivedData
命令行构建要注意几个参数:
只构建不跑测试(更快):
xcodebuild build -scheme MyApp -destination 'generic/platform=iOS' \
-scmProvider system -clonedSourcePackagesDirPath .build/spm
单独解析依赖(CI 早期步骤跑,缓存命中率高):
xcodebuild -resolvePackageDependencies -scheme MyApp
-clonedSourcePackagesDirPath 把依赖源码放到可缓存目录,避免每次 CI 都重新 clone。配合 -derivedDataPath 指定输出目录,可以让缓存命中率显著提升。
一个容易被忽略的性能问题:Package.swift 里的 swiftSettings 不一致会导致重复编译。如果 A 模块开了 -strict-concurrency=complete 而 B 没开,两者的产物不能共享,边界处的模块可能被编两次。团队应统一 swiftSettings。
八、资源、本地化与插件
SPM 从 5.3 起支持资源,这是它从「只能放代码」变成「完整模块」的分水岭。
.target(
name: "DesignSystem",
resources: [
.process("Resources/Assets.xcassets"),
.process("Resources/Localizable.xcstrings"),
.copy("Resources/Fonts"), // 不处理,原样拷贝
]
)
.process 会走 Xcode 的资源处理流程(压缩图片、编译 xcassets、编译 strings),.copy 原样拷贝到 bundle。能 .process 就 .process,否则资源不会被打包优化。
在代码里访问资源必须用 Bundle.module:
public extension Color {
static let brand = Color("Brand", bundle: .module)
}
public extension String {
static func localized(_ key: String) -> String {
String(localized: LocalizedStringResource(key), bundle: .module)
}
}
Bundle.module 是 SwiftPM 自动生成的,只在有 resources 的 target 里存在。忘了写 bundle: .module 是最高频的资源 bug——代码在 App 主 bundle 里找资源,结果找不到,图片空白、文字显示成 key。
本地化字符串用 .xcstrings(String Catalog,Xcode 15+)是现代做法,它把多语言合并成一个 JSON,比旧的 .strings + .stringsdict 好维护。SPM 里对 .xcstrings 用 .process 即可。
SwiftPM 插件(Plugin)能做代码生成(buildTool)、lint 检查、资源预处理,比如把 SwiftLint 挂成 .plugin(name: "SwiftLintPlugin", capability: .buildTool())。它的限制是不能修改源码、不能联网(安全沙箱),因此只适合生成与检查,生成物要加进 .gitignore。
九、Swift 6 语言模式迁移
Swift 6 的严格并发检查会带来大量编译错误,而模块化的工程恰好有分批迁移的天然优势:语言模式可以按 target 逐个开启。
.target(
name: "CoreModels",
swiftSettings: [.swiftLanguageMode(.v6)]
),
.target(
name: "FeatureFeed",
// 暂时保持 v5,后续再切
),
推荐的迁移顺序:
- 先切叶子模块(DesignSystem、CoreModels):它们依赖少、并发代码少,切起来最轻松。
- 再切中间层(NetworkingKit、PersistenceKit):这些模块是
Sendable问题的集中地,需要仔细处理URLSession回调和数据库句柄。 - 最后切 feature 模块:
@MainActor标注的 ViewModel 大多能直接通过,问题集中在跨 actor 传递的模型类型。 - App target 最后切:它是组装层,改动最少。
迁移中的实用手段:
// 1. 临时压制来自旧 SDK 或二进制依赖的警告
@preconcurrency import LegacySDK
// 2. 标注「我保证线程安全」
final class Cache: @unchecked Sendable { /* 内部有锁 */ }
// 3. 用 @MainActor 标注整个类型而不是逐个方法
@MainActor final class FeedViewModel { /* ... */ }
// 4. 迁移期逐步开启 upcoming features
swiftSettings: [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InferSendableFromCaptures"),
]
编译慢是 Swift 6 迁移的隐性成本:严格并发检查显著增加类型检查器的工作量。对策是拆小文件、给复杂表达式加显式类型标注,并用 -Xfrontend -warn-long-expression-type-checking=200 找出瓶颈表达式。
迁移完成后,把 CI 的构建脚本同步更新,确保测试与构建跑在同一套语言模式下。CI 侧的完整配置(Fastlane、Xcode Cloud、多 scheme 测试矩阵)可以参考 iOS 测试体系与持续集成
。Swift 语言本身的现代语法特性(属性包装器、Result Builder、some/any)在跨模块设计时的取舍,可以对照 Swift 语言基础与现代语法
。
十、渐进式模块化路径
不要试图一次性把工程拆成二十个模块。推荐的推进顺序:
第一步:抽出零依赖的底座。 DesignSystem(颜色、字号、间距)与 CoreModels(领域实体)不依赖任何东西,风险最低,收益立竿见影(改颜色不再触发全量重编)。
第二步:抽出网络与持久化。 这两个模块依赖底座,被上层广泛使用。抽的时候顺手把「协议在底座、实现在本模块」的依赖倒置做好。
第三步:按 feature 抽页面模块。 一个 feature 一个包,内部包含 View、ViewModel、路由。feature 之间不互相依赖,只通过底座的路由协议通信。
第四步:收敛 App target。 App target 只剩启动、DI 容器、路由注册,代码量应该降到整个工程的 5% 以下。
判断拆分是否成功的三个信号:
- 改一个 feature 的代码,重编时间是否显著下降(只有该模块与其下游重编)。
- 单独跑某个模块的测试是否不需要构建整个 App。
- 模块之间是否存在循环依赖(有就说明切错了)。
循环依赖是模块化最硬的墙。Swift 不允许两个模块互相 import,一旦出现只能通过把公共部分下沉到第三个模块或用协议反转依赖方向来解决。前者更简单,后者更干净。
权衡取舍
| 维度 | 单 target | 多 local package |
|---|---|---|
| 增量编译 | 差(改一行可能全量) | 好(只重编受影响模块) |
| 编译并行度 | 低 | 高(模块间可并行) |
| 边界强制 | 无(全靠自觉) | 强(访问控制 + 依赖方向) |
| 工程复杂度 | 低 | 中高(清单、版本、缓存) |
| 测试隔离 | 难(要起整个 App) | 易(单模块单测) |
| 首次构建 | 快 | 慢(解析 + 编译依赖图) |
| 新人上手 | 快 | 需要理解包结构 |
拆分粒度的经验值:单模块源码控制在 5 万行以内,模块数量控制在 20 个以内。模块太细会让清单维护和依赖解析的成本超过收益;模块太粗则起不到隔离作用。
与 Android 侧的模块化方案对照,Android 模块化与 Gradle 里讨论的「按 feature 切、依赖单向、约定优于配置」三条原则在 SPM 上同样成立,差别只在工具链的表达方式。
常见坑清单
swift-tools-version不在第一行:注释前有内容会导致 SwiftPM 忽略它,用默认版本解析,行为不一致。- 省略
platforms声明:SwiftPM 用极低的默认最低版本,CI 上可能因 API 不可用而失败。 Package.resolved没提交:CI 每次解析到不同版本,构建不可复现。- 发布产品里用了
branch:依赖:不可复现且会被打包警告,一律改语义化版本。 - 所有类型都标
public:模块边界形同虚设,等于没拆。默认internal,只暴露必要面。 - 资源访问忘了
bundle: .module:图片空白、文案显示成 key,是最常见的 SPM 资源 bug。 - 二进制目标只提供真机切片:模拟器测试链接失败,
binaryTarget必须包含 simulator 切片。 checksum与 zip 不匹配:换了二进制但没重算 checksum,解析直接报错。- 改
Package.swift触发全量重编:清单变化会重新解析整个依赖图,尽量批量改、少改动。 swiftSettings各模块不一致:产物无法共享,边界模块被重复编译,构建变慢且行为不一致。- 模块间循环依赖:Swift 不允许互相 import,必须下沉公共部分或用协议反转方向。
#elseif平台分支长期未编译:CI 只跑 iOS 时 macOS 分支会腐烂,应加多平台构建矩阵。
相关阅读
- iOS 测试体系与持续集成 — 模块化后的测试矩阵与 CI 缓存配置
- Swift 协议与泛型编程 — 用协议反转依赖方向、守住模块边界
- Swift 语言基础与现代语法 — 跨模块设计时的访问级别与语言特性取舍
- Android 模块化与 Gradle — 对照另一套移动工程体系的模块化实践
小结
SPM 的价值有两层:作为依赖管理器,它用语义化版本与 Package.resolved 保证构建可复现;作为工程组织方式,它用访问控制与依赖方向把「架构约定」变成「编译期强制」。前者是工具问题,后者是架构问题,很多人只用了前一层。
落地的关键判断是按变更频率切模块:底座稳定、中间层适度、feature 高频。边界靠 internal 默认值与 package 级别守住,依赖方向必须单向,循环依赖只能靠下沉或协议反转解决。构建性能上,模块化带来的增量编译收益远大于依赖解析的开销,但要统一 swiftSettings 并缓存 SPM 目录。Swift 6 的迁移正好可以借模块化的分批能力逐 target 推进。下一步建议把模块化与具体的架构模式结合,验证 feature 模块的边界是否真的能独立开发与测试。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。