☰
SPM本地库实战:创建、集成与组件化实践
2026/10/2 9:47:09 网站建设 项目流程

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.lock;SPM是Xcode内置的,创建Package不需要额外安装任何工具。
  • 构建链路:CocoaPods通过生成workspace来管理依赖,经常会出现索引刷新慢、偶发找不到头文件的问题;SPM直接融入Xcode的构建系统,缓存机制友好,干净项目clone下来首次构建的成功率明显高。
  • 本地调试:CocoaPods的本地组件需要Podfile里写:path =>参数,改动后要重新pod install;SPM本地库改完代码直接Command+B就能生效,迭代效率高很多。
  • 依赖嵌套:CocoaPods处理组件依赖第三方库时,版本冲突的管理成本比较高。SPM的依赖声明放在Package.swift里,依赖图清晰,解析策略可控。
  • 可见性:SPM在Xcode里能看到完整的依赖图谱界面,每个库的版本、分支、本地路径都一目了然,排查问题比看Podfile更直观。

当然,CocoaPods也不是一文不值,它的资源文件处理方式比SPM成熟,有些老牌第三方库只支持CocoaPods,那没办法,只能继续用。我的建议是:新项目优先用SPM,老项目可以局部引入SPM本地库,逐步把通用模块抽出来。

2. 创建本地Package之前的环境准备

2.1 开发环境与版本要求

这一节来点实际可操作的。先说环境要求,我用的是macOS + Xcode 15,Swift版本已经来到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.1,Xcode 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 build+swift test,写完代码顺手执行一遍,比在Xcode里等编译器转圈快不少。而且命令行构建失败时,错误信息更干净,没有Xcode那一堆冗余日志。

3. 手把手创建本地库:初始化与目录结构

3.1 初始化一个Library类型的Package

现在开始实操。假设我们要做一个登录相关的通用模块,名叫LoginModule,这个模块要供主App和其他业务模块共同使用。首先在终端里建一个目录并初始化Package:

cd ~/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甚至直接解析失败。
  • name:Package的名字,也是默认的模块名来源。
  • 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里,其他类全部声明为internal(Swift默认就是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: "user@example.com", password: "password123") } }

注意第一行@testable import LoginModule,这个关键字的作用是允许测试代码访问模块里的internal成员。如果不用@testable,测试代码只能访问public暴露的API,很多内部细节没法测。这是SPM(以及Swift编译器)一直保留的机制,Xcode里跑测试时默认支持。

测试用例我建议至少覆盖:正常输入、空输入、边界值(比如密码长度刚好等于最小限制)、非法格式。别看这些小case,上生产环境之后帮我们挡下了好几个低级bug。

5. 把本地库集成到主工程

5.1 在Xcode中通过Add Local添加依赖

本地库代码写好后,接下来就是用起来。打开你的主App工程,按以下步骤操作:

  1. 在Xcode菜单栏选择File→Add Package Dependencies...
  2. 在弹窗左下角点击Add Local...
  3. 选中我们刚才创建的LoginModule文件夹
  4. Xcode会自动解析Package.swift,显示这个Package包含的product
  5. 选择LoginModule库,添加到Target成员
  6. 点击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,这些是编译产物,不用手动管理。
  • 修改本地库源码后,主工程直接Command+B即可增量编译,不需要额外的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(快捷键Shift+Cmd+K),再重新构建,绝大多数情况能解决。

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.0,SPM才能解析到。这个点新人最容易忽略,推了代码忘了打tag,然后远程依赖怎么都解析不到。

7.3 个人体会:SPM本地库真正改变了代码组织方式

说实话,我刚接触SPM本地库时,觉得无非是另一种放代码的方式。真正实践半年之后,最大的改变是思考模式:做任何功能前会先问自己,这个模块的边界在哪里?它应该依赖谁、不该依赖谁?

这种思考不是SPM强制的,但SPM的机制鼓励了这种思考。以前用文件夹分组,从技术上讲你可以随意import任意文件(只要在同一个target里),模块名存实亡。SPM把target边界变成编译器强制约束,违反规则直接构建失败。这种“强制”在一开始让人难受,适应之后你会发现,架构腐化速度明显下降。

最后分享一个小习惯:每次新建一个SPM本地库,我会同步创建README.md,写清楚这个模块的作用、使用方式、版本记录。团队里新成员接手时,看README比看源码效率高得多。这不算高超技术,但长期坚持下来,收益很可观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询