ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Swift Package Manager做本地库:SPM组件化完整实操与踩坑指南

用Swift Package Manager做本地库:SPM组件化完整实操与踩坑指南 做独立组件、拆模块这件事我见过太多团队一直在用最原始的方式建一个文件夹把公共代码拖进去然后几个工程各存一份。这种办法看起来省事实际上每次改代码都像开共享文档一样恐怖——这个工程改了那个工程没改线上出了 bug 查半天最后发现根因是两端代码根本不是一个版本。后来我把公共模块改成用 Swift Package ManagerSPM管理成本地库一套代码多处引用编译期直接合入工程不用再靠人肉同步。这篇文章就把我完整的实操过程、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.swiftpodspecCartfile依赖解析速度快只构建本地图慢每次全量解析快但需手动链接源码调试直接断点偶发进不去可行但多一层资源文件Bundle.moduleresource_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 保持一致。如果不写 nameSPM 会默认用 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.swiftXcode 提供了可视化的本地包添加方式。入口是在工程设置里选择项目名切到 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 缓存目录构建缓存是怎么回事除了DerivedDataSPM 还会在~/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重开工程。这个链路走完问题基本解决。如果还不行大概率是你的本地包路径下又有另一个.xcodeprojXcode 在递归索引时解析错了。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.9Xcode 会提示但有时提示不显眼。解决方法是把本地库的最低工具链版本调到主工程能接受的范围内或者在 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 targetSwift 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加上标准的目录结构就能跑通。但真正决定体验好坏的其实是这些细节版本怎么管理、资源怎么放、缓存什么时候该清、依赖关系怎么设计。把这些细节处理好了本地库才能真正承载起组件化的目标。我也还在一边做一边积累如果你们团队在实际集成的过程中碰到过本文没覆盖到的坑建议先从依赖图入手去梳理多半能找出原因。
返回列表