1. 为什么要在项目里用SPM本地库1.1 Swift Package Manager到底解决了什么问题做iOS开发的朋友应该都经历过这种场景项目里功能越堆越多Network、Storage、Utils这些目录越来越大文件之间互相引用改一个工具类可能牵动十几个业务模块。早期大家习惯用CocoaPods管理第三方库自己的代码还是靠拖文件、建文件夹来组织。后来Apple从Xcode 11开始把Swift Package Manager简称SPM内置到工程体系里这一下就改变了依赖管理和模块化组织的方式。SPM的定位其实很清晰它既是包管理器又是模块化构建工具。你可以用它拉取远程的第三方库也可以用来维护自己项目内部的本地代码库。我重点想聊聊后者——用SPM把项目里的通用组件、基础模块抽出来做成一个Local Package然后以本地路径的方式集成到主工程。这种方式在团队协作里非常实用尤其适合中大型项目能让模块边界真正落地而不是靠口头约定“这个目录别乱改”。说得直白一点SPM本地库就是让你能用“正规军”的方式管理自己项目里的代码模块而不是继续当“游击队”。模块与模块之间的依赖关系写清楚哪些是公开API、哪些是内部实现全由SPM的配置文件来管。1.2 对比CocoaPods和手动拖文件优势在哪里经常有人问我现在项目里已经用CocoaPods了为什么还要用SPM本地库这不是重复造轮子吗我当时也犹豫过。用了一段时间之后我的感受是两者解决的问题有重叠但SPM在本地库场景下的体验更轻量、更原生。集成成本CocoaPods需要安装Ruby环境、Gem依赖还要维护Podfile和Podfile.lockSPM是Xcode内置的创建Package不需要额外安装任何工具。构建链路CocoaPods通过生成workspace来管理依赖经常会出现索引刷新慢、偶发找不到头文件的问题SPM直接融入Xcode的构建系统缓存机制友好干净项目clone下来首次构建的成功率明显高。本地调试CocoaPods的本地组件需要Podfile里写:path 参数改动后要重新pod installSPM本地库改完代码直接CommandB就能生效迭代效率高很多。依赖嵌套CocoaPods处理组件依赖第三方库时版本冲突的管理成本比较高。SPM的依赖声明放在Package.swift里依赖图清晰解析策略可控。可见性SPM在Xcode里能看到完整的依赖图谱界面每个库的版本、分支、本地路径都一目了然排查问题比看Podfile更直观。当然CocoaPods也不是一文不值它的资源文件处理方式比SPM成熟有些老牌第三方库只支持CocoaPods那没办法只能继续用。我的建议是新项目优先用SPM老项目可以局部引入SPM本地库逐步把通用模块抽出来。2. 创建本地Package之前的环境准备2.1 开发环境与版本要求这一节来点实际可操作的。先说环境要求我用的是macOS Xcode 15Swift版本已经来到5.9不过这套流程在Xcode 11以上基本都能用只是个别语法和配置字段的兼容性问题需要留意。macOS 11如果是Apple Silicon芯片Xcode版本建议13以上否则构建模拟器库时可能报架构错误Xcode 11及以上版本建议直接升级到最新版Swift工具链随Xcode自动安装命令行里执行swift --version能看到版本号如果你要在真机上调试还需要配置开发者签名但本地库本身不需要签名集成到App时才需要检查环境的命令很简单打开终端执行swift --version输出类似Swift version 5.9 (swiftlang-...)就说明环境正常。这里插一句很多初学者容易把Swift版本和Xcode版本搞混记住Xcode 14.1自带Swift 5.7.1Xcode 15自带Swift 5.9升级Xcode版本会自动更新Swift工具链。2.2 命令行工具不仅仅是凑热闹SPM的创建和管理可以通过Xcode的图形界面操作但我真心建议你掌握命令行方式。为什么因为命令行方式更直观地暴露了SPM的工作机制而且后续写自动化脚本、CI/CD流水线时命令行的用处非常大。Xcode图形界面能做这些事右键工程目录新建Package、在Project设置里添加本地依赖、图形化查看依赖图谱。但命令行能做的更多swift package init快速初始化一个Package工程swift build编译Package检查语法错误swift test运行Tests目录下的单元测试swift package resolve重新解析依赖关系swift package show-dependencies查看当前Package的依赖树日常开发我最常用的组合是swift buildswift test写完代码顺手执行一遍比在Xcode里等编译器转圈快不少。而且命令行构建失败时错误信息更干净没有Xcode那一堆冗余日志。3. 手把手创建本地库初始化与目录结构3.1 初始化一个Library类型的Package现在开始实操。假设我们要做一个登录相关的通用模块名叫LoginModule这个模块要供主App和其他业务模块共同使用。首先在终端里建一个目录并初始化Packagecd ~/Projects mkdir LoginModule cd LoginModule swift package init --type library--type参数有几个选项library、executable、system-module、manifest。创建本地库我们用library类型它生成的Package不包含main入口只产出可被外部引用的动态库或静态库。如果选executable则会生成一个包含main.swift的可执行程序适合做命令行工具不适合当模块库。初始化之后目录结构长这样LoginModule ├── Package.swift ├── README.md ├── Sources │ └── LoginModule │ └── LoginModule.swift └── Tests └── LoginModuleTests └── LoginModuleTests.swift注意Sources目录下的文件夹名默认与Package名相同。这个文件夹名就是模块名module name后面import LoginModule用的就是它。如果你创建目录的时候叫MyLogin那模块名就是MyLogin这个要提前想清楚因为改起来牵一发动全身。3.2 Package.swift清单文件拆解Package.swift是整个SPM的核心配置文件类似CocoaPods里的podspec作用就是声明这个Package叫什么、什么版本、提供哪些库、依赖哪些外部包、支持哪些平台。默认生成的Package.swift内容如下// swift-tools-version:5.9 import PackageDescription let package Package( name: LoginModule, products: [ .library(name: LoginModule, targets: [LoginModule]) ], targets: [ .target(name: LoginModule), .testTarget( name: LoginModuleTests, dependencies: [LoginModule] ) ] )逐个字段来说swift-tools-version声明这个Package要求的最低Swift工具链版本写5.9就意味着只能被Swift 5.9及以上版本的工具链解析。这个字段很重要我们实际测试过工具链版本不匹配时SPM会报warning甚至直接解析失败。namePackage的名字也是默认的模块名来源。products这个Package对外暴露的产物。library表示提供代码库给外部引用。名字可以自定义targets字段绑定实际编译的target。一个Package可以暴露多个product比如同时暴露核心库和扩展库。targets描述编译单元。target是源码模块testTarget是测试模块。这里的dependencies: [LoginModule]是testTarget对主target的引用注意它用的是数组简写形式。如果Package依赖第三方库还会多一个dependencies字段放在products之前这个后面再展开。我当时踩过一个坑默认生成的文件里LoginModule.swift只是简单的结构体声明直接build不会报错但也没实际内容。我们要做的是把真实业务代码塞进去同时保持模块边界清晰。4. 源码组织与业务代码实战4.1 登录模块的业务拆分思路为了演示得有真实感我就以登录模块为例说说我实际怎么组织的。登录功能看起来不复杂但细节很多加载用户本地缓存、校验输入参数、请求后端接口、处理返回token、刷新登录状态等。如果这些都堆在主App里那登录页面会越来越臃肿。抽到SPM本地库后主App只需要关心UI层业务逻辑全在库内部解决。我在LoginModule里规划了这样几个文件LoginService.swift登录主逻辑对外暴露登录方法LoginValidator.swift本地校验用户名、密码格式LoginSession.swift管理登录态和token缓存NetworkClient.swift轻量级网络请求封装LoginModel.swift登录相关的数据模型定义模块内部的引用关系是单向的LoginService依赖LoginValidator、LoginSession和NetworkClient避免循环引用。对外暴露的公共类只在LoginService.swift里其他类全部声明为internalSwift默认就是internal这样外部使用方想碰也碰不到模块边界就严了。4.2 编写模块代码与API设计拿LoginService.swift举例我习惯把对外API设计成结构体static方法或枚举namespace的形式。早期我用class加单例后来发现struct static更Swift风也更好测试。下面是我实际用的代码import Foundation public struct LoginService { private let client: NetworkClient private let validator: LoginValidator private let session: LoginSession public init(client: NetworkClient NetworkClient(), validator: LoginValidator LoginValidator(), session: LoginSession LoginSession()) { self.client client self.validator validator self.session session } public func login(username: String, password: String) async throws - User { try validator.validate(username: username, password: password) let request LoginRequest(username: username, password: password) let user: User try await client.send(request) try session.save(user) return user } public func logout() { session.clear() } public var currentUser: User? { session.load() } }几个细节说一下public关键字必不可少。SPM库编译时所有类、方法、属性默认是internal外部模块根本看不到。这里加public就是明确告诉编译器这部分是开放API。NetworkClient、LoginValidator、LoginSession这些类型如果在初始化方法签名里出现它们自身也必须是public或至少是internal但这样外部不能传参否则外部调用时无法构造参数。async/await是Swift 5.5之后的特性用起来方便但要注意LoginRequest和User类型也需要被外部可见因为它们出现在公开方法的签名里。这其实是一种API设计约束写代码时要想清楚哪些类型进公共接口。4.3 添加本地测试与边界条件SPM初始化的Tests目录已经配好了测试target我们直接用XCTest写几个用例。一个让我印象深刻的例子是密码校验逻辑import XCTest testable import LoginModule final class LoginValidatorTests: XCTestCase { let validator LoginValidator() func testValidateWithEmptyUsername() { XCTAssertThrowsError(try validator.validate(username: , password: abc123)) } func testValidateWithShortPassword() { XCTAssertThrowsError(try validator.validate(username: user, password: 123)) } func testValidateWithValidInput() throws { try validator.validate(username: userexample.com, password: password123) } }注意第一行testable import LoginModule这个关键字的作用是允许测试代码访问模块里的internal成员。如果不用testable测试代码只能访问public暴露的API很多内部细节没法测。这是SPM以及Swift编译器一直保留的机制Xcode里跑测试时默认支持。测试用例我建议至少覆盖正常输入、空输入、边界值比如密码长度刚好等于最小限制、非法格式。别看这些小case上生产环境之后帮我们挡下了好几个低级bug。5. 把本地库集成到主工程5.1 在Xcode中通过Add Local添加依赖本地库代码写好后接下来就是用起来。打开你的主App工程按以下步骤操作在Xcode菜单栏选择File→Add Package Dependencies...在弹窗左下角点击Add Local...选中我们刚才创建的LoginModule文件夹Xcode会自动解析Package.swift显示这个Package包含的product选择LoginModule库添加到Target成员点击Add Package完成Xcode会把你选择的product加入到工程的Frameworks, Libraries, and Embedded Content区域。之后在业务代码里直接import LoginModule就能用了。这里有个细节如果你看到弹窗提示“No packages found”大概率是Package.swift格式有问题或者路径选错了检查一下swift-tools-version和文件是否存在。5.2 通过Package.swift声明依赖的另一种方式如果你的主工程本身就支持SPM还有一种更稳的方式直接在主工程的Package.swift里声明依赖。当然大多数iOS工程的依赖是通过Xcode工程文件管理的但如果你在维护一个多Package的工作区这种方式也很好用。假设主工程的Package.swift长这样let package Package( name: MainApp, products: [ .library(name: MainAppCore, targets: [MainAppCore]) ], dependencies: [ .package(name: LoginModule, path: ../LoginModule) ], targets: [ .target( name: MainAppCore, dependencies: [ .product(name: LoginModule, package: LoginModule) ] ) ] )这个配置优点是显式、可维护缺点是主工程必须也是SPM结构。纯iOS App工程建议还是用Xcode图形方式最省心。5.3 构建验证与命令行调试集成完成后第一件事是构建验证。我习惯先在命令行跑一遍swift build确认模块本身没问题再打开Xcode构建主工程。这样能区分问题是出在库内部还是集成环节。cd ~/Projects/LoginModule swift build输出Build complete!说明库没问题。然后再在主工程目录执行xcodebuild -scheme YourAppScheme -sdk iphonesimulator build如果主工程没有自定义Scheme可以用默认的。Xcode构建成功后模拟器运行一遍登录流程观察控制台有没有异常输出。调试阶段有几个小技巧Products目录下会生成LoginModule.a或LoginModule.swiftmodule这些是编译产物不用手动管理。修改本地库源码后主工程直接CommandB即可增量编译不需要额外的pod install或clean。如果模拟器上运行报“Unable to load standard library”这类错误基本是Xcode版本和模拟器运行时版本不匹配去Settings→Components更新模拟器运行时即可。6. 常见报错与排查技巧实录6.1 “No such module”错误这是SPM本地库最常遇到的坑报错信息就一句话No such module LoginModule。我排查了不下十次总结出三个主要原因Target没有勾选在Xcode的Signing Capabilities或Frameworks, Libraries, and Embedded Content区域没有把LoginModule加到对应的Target。加依赖时务必确认Target Membership里勾选了主App的Target。模块名写错import LoginModule里的名字要和Package.swift中targets的目录名一致。比如Sources下的目录叫LoginModuleCore那import就得写LoginModuleCore跟product name未必一致。构建缓存锅Xcode的构建缓存偶尔抽风明明配置正确还是报这个错。解决办法是Product→Clean Build Folder快捷键ShiftCmdK再重新构建绝大多数情况能解决。6.2 修改Package.swift后工程不生效有段时间我发现改了Package.swift里新增依赖或修改target但主工程里怎么都不生效。后来才明白Xcode会缓存SPM的解析结果修改Package.swift后不会自动重新解析。解决办法是在Xcode里执行File→Packages→Reset Package Caches不同Xcode版本菜单名称可能略有差异让SPM重新解析所有依赖。如果是命令行方式则用swift package resolve这个命令会重新梳理依赖关系并生成Package.resolved文件。日常开发中新增了文件、改了target配置建议同步执行这个命令减少不确定性。6.3 循环依赖与命名冲突SPM的target之间不允许循环依赖这是构建系统的硬性规定。举个例子如果LoginModule依赖了NetworkModule而NetworkModule又反过来依赖LoginModule编译器会直接报错。实际开发中我遇到过更隐蔽的循环依赖主工程的某个target同时依赖了LoginModule和CommonModule而CommonModule本身又依赖了LoginModule。这种间接循环依赖是历史代码重构时容易踩的坑。排查方法是用swift package show-dependencies查看依赖树人工确认有没有环。命名冲突方面最常见的是两个模块定义了同名类型。SPM不会像CocoaPods那样强制类名前缀所以规范团队的代码风格就显得很重要。我倾向于在每个模块的公共类型上加模块前缀比如LoginModule内的类叫LMUser、LMNetworkClient避免冲突时改代码的痛苦。6.4 本地路径在团队协作中的坑最后聊一个团队场景下很现实的问题。本地库通过path方式依赖时如果每个人把仓库clone到不同的目录那么每个人的Package.swift里的路径都得手动改。这个很烦我常用的方案是在团队代码规范中约定统一的目录结构比如所有本地库都放在~/Projects/下。短期可以用~符号展开路径但不要用相对路径加..的方式太脆弱。更长期的办法是给Local Package建立Git仓库集成方式从path切换到url加版本号这是最干净的生产环境方案。下面是几个问题场景的快速速查问题可能原因解决方式No such moduleTarget未添加或模块名不对检查Target Membership和import的模块名Build失败但代码没改Package.swift缓存未刷新Reset Package Caches或执行swift package resolve控制台出现undefined symbol库target没链接对应系统库在Package.swift的target里配置linker设置Git拉取后构建失败本地path路径不同改用Git URL方式依赖版本时好时坏Package.resolved未提交把Package.resolved提交到Git仓库7. 从本地库走向组件化的实践经验7.1 本地开发与远程仓库的灵活切换本地库用顺了以后你会想把它发布到远程Git仓库让团队其他成员也能用。这里有个很实用的切换技巧在依赖声明上预留灵活配置。比如你在调试阶段用本地路径提交代码前改成Git URL// 调试阶段 .package(path: ../LoginModule) // 发布阶段 .package(url: https://github.com/yourteam/LoginModule.git, from: 1.0.0)我个人的工作流是开发新功能时永远用本地路径改完代码本地自测一切通过后再打tag、推远程、切回URL版本。这个流程的好处是迭代速度快坏处是切换后必须swift package resolve一次。团队的CI流水线上统一用URL方式避免本地路径带来的构建不确定性。7.2 版本控制与语义化版本说到远程仓库就绕不开版本管理。SPM版本控制采用的是语义化版本SemVer格式是MAJOR.MINOR.PATCH。MAJOR做了不兼容的API变更比如删除了某个公共方法。MINOR新增功能且向后兼容比如加了新的初始化方法。PATCH修复bug且向后兼容。from: 1.0.0的含义是允许SPM自动解析1.x.x系列的最新版本不跨MAJOR版本。还有一种精确写法1.0.0只匹配精确版本或者.exact(1.0.0)。团队协作时我建议用from:方式这样能及时拿到Patch更新又不会因为MAJOR版本升级导致突然编译失败。版本号由Git的tag来标识Tag打了1.0.0SPM才能解析到。这个点新人最容易忽略推了代码忘了打tag然后远程依赖怎么都解析不到。7.3 个人体会SPM本地库真正改变了代码组织方式说实话我刚接触SPM本地库时觉得无非是另一种放代码的方式。真正实践半年之后最大的改变是思考模式做任何功能前会先问自己这个模块的边界在哪里它应该依赖谁、不该依赖谁这种思考不是SPM强制的但SPM的机制鼓励了这种思考。以前用文件夹分组从技术上讲你可以随意import任意文件只要在同一个target里模块名存实亡。SPM把target边界变成编译器强制约束违反规则直接构建失败。这种“强制”在一开始让人难受适应之后你会发现架构腐化速度明显下降。最后分享一个小习惯每次新建一个SPM本地库我会同步创建README.md写清楚这个模块的作用、使用方式、版本记录。团队里新成员接手时看README比看源码效率高得多。这不算高超技术但长期坚持下来收益很可观。