这篇讲的是一组私有 Swift Package 在多个 App 之间复用两年多之后,留下来的几条工程判断。包名、仓库地址、宿主名都做了示意化处理;文中代码按仓库里的真实写法整理,未逐行重新编译验证。
你大概会经历这样一个下午。
录屏 App 要出 Mac 版。工程早就是多平台的,你把 destination 切到 My Mac,Cmd+B,等着看录屏那部分报多少错。结果第一个错误跟录屏没关系,是积分包:No such module 'UIKit'。你点进去,积分包自己没有一行 UIKit,错在它依赖的网络包里。网络包是另一个同事两年前建的仓库,你没有改过。
要让这一次编译过去,你需要:给网络包补条件编译并声明 macOS 平台,发一个新分支;给积分包声明 macOS 平台,把它对网络包的锁定改到新分支,再发一个新分支;回到宿主,把两处 pin 改掉,再把 Package.resolved 里两个 revision 往前推。三个仓库,五处改动,为了一个「顺手跑一下 Mac」。
这件事我 2026 年夏天真的做过一遍。它不是事故,所有包都在正常工作。它只是把这套复用方案的成本一次性摊在了桌面上。这篇就从这里讲起。
一、先把现状摆出来
我们有八个私有 Swift Package,放在自建 GitLab 上,被六款 iOS App 复用。按职责分:
| 包 | 职责 | 依赖其他内部包 | 宿主数 |
|---|---|---|---|
| Net | 带签名的 HTTP 请求层、素材接口 | 无 | 1 直接 + 多个间接 |
| Points | 积分、订阅校验、测试账号 | Net | 5 |
| Analytics | 埋点:双通道上报、远程配置、触发式运营 | 无 | 5 |
| AIService | AI 能力接口、多云对象存储上传 | 无 | 6 |
| Ads | Google 广告的五种形态封装 | 无 | 1 |
| Recommend | 应用互推 | 无 | 2 |
| Monitor | 调试用悬浮窗:帧率、内存、网络、电量 | 无 | 2 |
| OwnService | 自有服务接口 | Net | 0 |
先说清楚我的位置。这八个包不是我一个人建的:网络、积分、AI 服务、广告、互推这几个是同事先建的,我后来在里面做接口和跨平台兼容的改动;埋点包是我从零建的,一百多个提交;监控悬浮窗是我写的小工具。宿主这边,六款 App 里的包升级和迁移大多是我做的。所以下面写的,是我同时站在「包作者」和「宿主维护者」两个位置上看到的东西,两边的账都算过。
表里有两个数字值得先看一眼。
第一,「依赖其他内部包」这一列几乎全是「无」。八个包里只有两个依赖了另一个内部包,而且都只依赖 Net。这不是巧合,第三节会讲它为什么必须是这样。
第二,最后一行那个 0。OwnService 从建仓起就没有任何宿主接入,仓库里一共两个提交。它是「先拆个包再说」的产物,到今天还在包列表里,每次有人问「我们有几个包」,它都被数进去。拆包的边际成本看起来是零,但一个没人用的包会一直出现在所有人的认知负担里。
二、版本:我们用分支当版本,零 tag
八个仓库,加起来大约六十个版本分支,git tag 的数量是零。
版本号就是分支名:1.2.1、1.2.2、1.6.8。宿主的 project.pbxproj 里钉的是分支:
ini
/* XCRemoteSwiftPackageReference "Points" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "git@gitlab.internal:ios/Points.git";
requirement = {
branch = 1.1.4;
kind = branch;
};
};
包与包之间也是钉分支:
swift
// Points/Package.swift
dependencies: [
.package(url: "git@gitlab.internal:ios/Net.git", branch: "1.2.2"),
],
这个做法从语义版本的角度看是「错」的:SPM 的 from: / upToNextMajor 全用不上,兼容性承诺变成了人肉承诺。我想先讲它为什么会长成这样,再讲它真实的代价在哪,因为代价跟大多数人想的不一样。
为什么是分支
它来自三个很实际的条件。
一是作者和用户是同一群人。包的提交者和宿主的提交者高度重合,「发版」不需要经过任何对外流程,推一个分支就是发版。 二是热修的频率远高于「新功能」。看一眼 Points 的提交记录,一天内五六个「请求更新」「增加返回字段」是常态。每次打 tag 意味着每个小修都要一个新版本号,而且 tag 推错了不能改。 三是 GitLab 的分支保护比 tag 好用。谁能推 1.2.2,谁不能,一条规则就能管。
所以「分支当版本」不是不懂 semver,是在「没有外部用户」这个前提下,把发版的摩擦压到最低。
真实的代价:不是不可复现,是 bump 的粒度
多数人对分支依赖的第一反应是「不可复现」。这一条其实不成立。SPM 解析分支依赖之后,Package.resolved 里记的是具体 revision:
json
{
"identity" : "net",
"kind" : "remoteSourceControl",
"location" : "git@gitlab.internal:ios/Net.git",
"state" : {
"branch" : "1.2.2",
"revision" : "1ee483e4c0f7d6a2b1e9f5a3d8c7b6a5e4f3d2c1"
}
}
只要 Package.resolved 进了版本控制,任何人在任何时候 clone 下来构建,拿到的都是这个 revision。分支上后来又推了什么,宿主不会自动拿到。这一点反过来还造成了一个高频误操作:改了 pbxproj 里的 branch = 1.1.3 却没动 Package.resolved,Xcode 继续用旧 revision,改动「无效」,排查半天以为是缓存。
真正的代价在于 bump 的粒度。
打 tag 的世界里,1.2.2 和 1.2.3 是两个不可变的点,宿主可以选择只前进到其中一个。分支的世界里,1.2.2 是一条会动的线。当你需要分支上的某一个热修时,Update to Latest Package Versions 会把这条分支上从你锁定的 revision 到最新的所有提交一次性拿进来。你想要的是那一行修复,拿到的是过去两个月的全部改动。
于是出现了一种行为:当一个分支上积累的改动多到宿主不敢整体吃进时,包作者会新开一个分支号,比如从 1.2.1 开 1.2.2,让「老宿主」留在老分支上继续吃热修,新宿主上新分支。分支号于是从「版本」退化成了「一条新的热修通道」。这就是为什么六款 App 里,AIService 这一个包同时钉着五六个不同的分支,从 1.4.7 到 1.7.0。
还有一个副作用:main 分支失去了意义。Points 的 main 上,对 Net 的锁定还停在 1.1.5,而所有活着的宿主都在 1.2.2。谁 checkout 了 main,谁就拿到一个两年前的世界。
如果重来一次
我不会把分支方案整个推翻,前面三个条件没有变。我会加两条纪律:
- 每个版本分支在开新分支号时,在老分支的最后一个 commit 上打一个 tag,仅做归档,不给宿主用。这样至少「
1.2.1最终是什么样」有一个不可变的答案。 main要么跟最新版本分支同步,要么删掉。一个停在两年前的main比没有更糟。
三、钻石依赖:在分支世界里,「内部包依赖内部包」是最贵的一条边
回到开头那个 Mac 版的下午。它的根本原因不是 UIKit,是依赖图里有一条边:Points 依赖 Net。

症状
某个宿主同时直接依赖 Net(它要调素材接口)和 Points(它要积分)。Points 的 manifest 里锁的是 Net 的 1.2.1 分支,宿主自己钉的是 1.2.2。Xcode 解析依赖时直接失败,错误信息大意是:同一个包被要求两个不同的分支,依赖图无解。
这跟 tag 世界的行为完全不同。用 from: "1.2.1" 和 from: "1.2.2" 的两个要求,SPM 可以一起满足:选一个最新的 1.x 就行。而两个不同的分支要求没有「交集」这个概念,SPM 只能拒绝。
根因
分支没有偏序关系。1.2.2 不「大于」1.2.1,它们只是两个名字。所以一旦某个包在 manifest 里钉了另一个内部包的分支,这个分支名就成了整个依赖图的硬约束:所有同时接触这两个包的宿主,必须钉完全相同的分支名。
翻一下 Points 的历史就能看到这条约束的代价。它对 Net 的锁定从 1.1.8 到 1.2.0 到 1.2.1 再到 1.2.2,每一次变更都对应着一轮「通知所有宿主一起改」。Net 自己的一个小改动,经过这条边放大之后,是五个宿主的 pbxproj 提交。
我们的解法:让包尽量是叶子
八个包里六个不依赖任何内部包,这是有意为之的结果,代价是重复代码。
最明显的例子是埋点包。它需要往自家服务器上报,需要 DES 加密和签名,这些 Net 里全有。我建埋点包的时候没有依赖 Net,而是在包内部自带了一个最小的 Http target 和一份 DESTool:
swift
// Analytics/Package.swift(节选)
.target(name: "Http", dependencies: ["Alamofire", "DESToolSwift"], path: "Sources/Http"),
.target(name: "DESToolObjC", path: "Sources/DESTool", sources: ["DesEncryptMgr.m", "GTMBase64Data.m"]),
.target(name: "DESToolSwift", dependencies: ["DESToolObjC"], path: "Sources/DESTool", sources: ["DESEncryption.swift"]),
.target(name: "Analytics", dependencies: ["AnalyticsCore", "Http"]),
几百行加密和请求代码在两个包里各有一份。这笔交易我认为是赚的:埋点包升级永远不需要通知 Net 的任何宿主,反过来也一样。
循环依赖怎么躲
有重复代码还算好办,更麻烦的是两个包互相想要对方。
广告包是个典型。广告的点击、播放完成、加载失败都要上报,最顺手的写法是在广告包里 import Analytics。而埋点包未来如果要做「广告收入归因」,又会想读广告包的数据。谁依赖谁都会成环,SPM 直接拒绝。
我们的做法是:底层包不依赖埋点包,只暴露回调;埋点由宿主做。
swift
// Ads 包内:只定义回调,不知道埋点包的存在
public final class RewardAdManager {
public var onAdClicked: ((_ placement: String) -> Void)?
public var onAdCompleted: ((_ placement: String, _ rewarded: Bool) -> Void)?
// ...
}
// 宿主:把两个包接起来
rewardAd.onAdCompleted = { placement, rewarded in
AnalyticsTool.shared.trackEvent("ad_complete",
properties: ["placement": placement, "rewarded": rewarded])
}
这个模式的要点不是「用闭包」,而是依赖的方向永远从宿主指向包,包与包之间不横向连线。宿主是唯一知道所有包的地方,它来做胶水。每个包多暴露几个回调,比多一条内部依赖便宜得多。
平台也是依赖图上的约束
开头的 Mac 版问题,本质上也是这条边造成的。SPM 里平台声明不会向下传播,但 缺失 会向上传播:链条上任何一个包引用了 UIKit 又没做条件编译,整条链在 macOS 上就断了。Points 自己一行 UIKit 都没有,它只是「碰巧」依赖了一个有 UIKit 的包。
最后的改法是两步:Net 里把所有 UIKit 引用改成条件编译并补 AppKit 等价实现,manifest 加 .macOS(.v12);Points 只需要加平台声明。这两个仓库都不是我建的,但这件事只能由「要出 Mac 版的那个人」来做。如果 Points 当初是叶子,这个下午会短一半。
四、一个包内部的解耦:端口、空实现、真引擎
上面讲的是包与包之间。埋点包内部还有一层解耦,它直接决定了宿主的接入成本,也制造了本文标题里那次「谁都没报错的失效」。
背景
埋点包最初基于一个第三方分析 SDK 改造,带着两个二进制框架:远程配置和触发式运营。去品牌化的时候,我把两个二进制删了,改成 Swift 端口加空实现,后来再分两期用纯 Swift 把两个引擎重写回来。这个过程里形成了现在的 product 结构:
swift
products: [
.library(name: "Analytics", targets: ["Analytics"]), // 门面 + 端口(空实现)
.library(name: "AnalyticsEngines", targets: ["AnalyticsEngines"]), // 一行安装真引擎
.library(name: "ClosedLoopKit", targets: ["ClosedLoopKit"]), // 纯 Foundation,无依赖
],
门面里,端口是协议,默认值是空实现:
swift
public protocol StrategyPort: AnyObject {
func start(appId: String, serverUrl: String, isDebug: Bool)
func setTriggerListener(_ listener: @escaping (StrategyResult) -> Void)
func fetch()
}
public final class AnalyticsTool: NSObject {
public static let shared = AnalyticsTool()
private var strategy: StrategyPort = NoopStrategyEngine()
private var remoteConfig: RemoteConfigPort = NoopRemoteConfigEngine()
private var engineStartArgs: (appId: String, serverUrl: String, isDebug: Bool)?
/// 安装触发式引擎。若 SDK 已初始化则立即启动。
public func register(strategy engine: StrategyPort) {
strategy = engine
if let args = engineStartArgs {
engine.start(appId: args.appId, serverUrl: args.serverUrl, isDebug: args.isDebug)
}
}
}
真引擎在另一个 product 里,宿主一行安装:
swift
import AnalyticsEngines
AnalyticsTool.shared.installDefaultEngines()
installDefaultEngines 必须是真幂等的,这一点在实现时踩过一次:
swift
private enum DefaultEngines {
static let lock = NSLock()
static var remoteConfig: RemoteConfigEngine?
static var strategy: StrategyEngine?
}
public extension AnalyticsTool {
func installDefaultEngines() {
DefaultEngines.lock.lock()
let remoteConfig = DefaultEngines.remoteConfig ?? RemoteConfigEngine()
DefaultEngines.remoteConfig = remoteConfig
let strategy = DefaultEngines.strategy ?? StrategyEngine(remoteConfig: remoteConfig)
DefaultEngines.strategy = strategy
DefaultEngines.lock.unlock()
register(remoteConfig: remoteConfig)
register(strategy: strategy)
}
}
第一版没有这个静态持有者,每次调用都 new 一个引擎再注册。「幂等」在表面上成立:多调几次,门面里最终只有一个引擎。但旧实例被门面释放时,它持有的监听器、自定义请求参数、通知订阅、延迟触发的定时器全部跟着没了,而且在 deinit 真正生效之前还可能多跑一轮轮询。幂等的定义必须是「复用同一个对象」,不是「最终只剩一个对象」。
这个结构的好处很实在:宿主只想要基础埋点时,依赖一个 product,包体不带两个引擎;要运营能力时多挂一个 product,不改任何调用代码。策略引擎和远程配置引擎之间也只通过门面的端口通信,源码里没有一处互相 import,两个引擎可以各自重写而不碰对方。
那次失效
然后它就失效了。

一个宿主升级到带真引擎的分支,代码按文档改完,编译通过,启动正常,埋点上报正常。两周后运营那边问:配置的触发式推送一次都没触发过。
排查下来,宿主在 Xcode 的 Frameworks 里只挂了 Analytics 这个 product,没有挂 AnalyticsEngines,自然也没调 installDefaultEngines()。端口是空实现:setStrategyListener 存了闭包,fetch() 什么都不做,没有报错,没有警告,没有崩溃。运行时的每一步都「成功」了。
最干净的解耦,制造了最安静的失效。 这是我在这组包上学到的最贵的一课。如果当初是硬依赖,宿主漏挂 product 会直接编译失败;换成端口加空实现之后,漏挂变成了一个只有运营数据才能发现的问题,而且发现的时候已经晚了两周。
现在的补救有三层:
- 空实现不再完全沉默。
NoopStrategyEngine.fetch()会打一条 warning 级日志:「触发式引擎未安装,调用被忽略」。 - 包里暴露一个安装契约常量,宿主的单测可以断言它:
swift
public enum AnalyticsEnginesInstallCheck {
public static let installsStrategy = true
public static var installedEngines: (remoteConfig: AnyObject?, strategy: AnyObject?) { ... }
}
- 迁移文档里把「挂第二个 product」写成一个独立步骤,并写明 pbxproj 要改四处:
PBXBuildFile、Frameworks 阶段、packageProductDependencies、XCSwiftPackageProductDependency。少一处,Xcode 不报错,引擎不存在。
更一般地说:端口加空实现的设计,必须配一个「有没有装真实现」的可观测信号,否则它的失败模式就是静默。
五、把 ObjC 引擎塞进 SPM 的两个坑
埋点包的核心引擎是 ObjC,几十个子目录。把它做成 SPM target 有两件事跟 CocoaPods 时代完全不同。
头文件搜索不递归
SPM 的 C 家族 target 不会递归搜索头文件。引擎里 #import "Settings.h" 这种相对引用,在 Pods 里天然能找到,在 SPM 里每一个子目录都得显式列出来:
swift
.target(
name: "AnalyticsCore",
path: "Sources/AnalyticsCore",
sources: ["Core", "Engine"],
publicHeadersPath: "include",
cSettings: [
.headerSearchPath("Core"),
.headerSearchPath("Core/Database"),
.headerSearchPath("Core/Keychain"),
.headerSearchPath("Engine/AutoTrack"),
.headerSearchPath("Engine/EventTracker/Property/Validate"),
// ......四十来行
],
linkerSettings: [
.linkedLibrary("sqlite3"),
.linkedLibrary("z"),
]
),
四十来行 headerSearchPath 很丑,但它是诚实的:每一行对应一个真实的子目录。想省掉它们,就得把几十个头文件平铺到一个目录,或者全部改成 #import <AnalyticsCore/xxx.h> 的伞头形式。我选了列目录,因为它不碰引擎源码,去品牌重命名的时候 diff 干净。
去品牌重命名,和一条必须有白名单的门禁
把第三方 SDK 改造成自己的包,最大的一笔工作是重命名:类前缀、方法名、文件名、存储 key。重命名的难点不是改,是改完之后怎么保证不再漏回来。我写了一个 grep 门禁脚本放在仓库里,CI 和本地提交前都跑:
bash
PATTERN='OV[A-Z_]|OLDVENDOR|OldVendor|oldvendor|\bov[A-Z]|\bov[_-]'
hits=$(grep -rnE "$PATTERN" Sources Tests Demo Package.swift README.md \
| grep -vE "^($ALLOW_FILES):" \
| grep -vE "$ALLOW_LITERALS" || true)
[ -z "$hits" ] || { echo "发现品牌残留:"; echo "$hits" | head -50; exit 1; }
关键是 ALLOW_LITERALS 这个白名单,而且每一条都要写来源注释。原因是:有些带旧品牌的字符串是线上契约,不是品牌残留。 请求签名的盐值、服务端下发 JSON 里的键名、自动采集事件的事件名,这些跟后端和 Android 端共享,改一个就是线上事故。门禁不带白名单,要么永远红,要么有人为了让它绿去改契约。两种都见过。
还有一条跟它配套的规则:旧的存储 key 只允许出现在迁移器一个文件里。升级后 deviceId 和账号不能丢,所以旧 key 必须留着读一次;但它们不能散落在各处,否则下一轮重命名又会漏。
六、二进制依赖:xcframework 进仓库之前先想清楚两件事
AIService 这个包要用某云厂商的对象存储 SDK,对方只给 xcframework。包里的做法是把两个 xcframework 直接放在仓库里,用 binaryTarget(path:) 引用:
swift
.binaryTarget(name: "CloudCore", path: "./Resources/Framework/CloudCore.xcframework"),
.binaryTarget(name: "CloudStorage", path: "./Resources/Framework/CloudStorage.xcframework"),
两件事。
一是仓库体积。两个 xcframework 二十多 MB,没有用 LFS,每次 SDK 升级仓库历史就再长二十多 MB。这个包的 .git 目录已经是其他包的三倍。改法不复杂:binaryTarget(url:checksum:) 指向制品库,仓库里只留 checksum。只是这件事要在第一次放进去之前做,放进去之后历史就洗不掉了。
二是模拟器切片。这两个 xcframework 没有模拟器架构。后果是任何依赖 AIService 的宿主在模拟器上链接失败。这件事本身大家都知道,但它有一个二阶效应:当你在做一次跟 AIService 无关的迁移时,模拟器上的编译失败会被误判为迁移失败。 我在埋点包的迁移文档里专门写了一条「用真机架构验证编译,模拟器失败与迁移无关」,因为真的有人在这上面耗了半天。
七、宿主升级的真实成本,和把它写成 runbook
一组包好不好用,最终要看宿主升级一次要花多少。
正常升级很便宜。翻宿主的提交记录,「统计 SDK 升级」这一类提交是 pbxproj 改一行加 Package.resolved 推一个 revision。
不正常的升级是大版本迁移。埋点包去品牌那一次,宿主要删两个 import,把一个 delegate 实现改成闭包,把四五个方法名换掉,还要别去碰上报地址。某个宿主的改动是一个 helper 文件四十多行。改动不大,但每个宿主都会在同样的地方犯同样的错。所以我把它写成了一份 runbook,核心是一张「旧 → 新」速查表和一张「常见错误 → 后果」表:
| 错误 | 后果 |
|---|---|
只改了枚举名,留着 import 旧二进制模块 |
No such module,二进制已删 |
| 把上报地址「修回」旧接收端 | 事件全部 404 并在本地无限重试 |
顺手把 repositoryURL 从旧 group 改到新 group |
两个 group 都能解析,改错了反而解析失败 |
只改 branch 不更新 Package.resolved |
仍拉旧 revision,改动无效 |
没挂 Engines product 也没调 installDefaultEngines() |
端口全是空实现,触发式永不回调,且没有任何报错 |
| 用模拟器验证编译 | 云存储框架无模拟器切片,误判为迁移失败 |
表里第三条值得多说一句。GitLab 上项目从一个 group 迁到另一个 group 之后,旧地址会重定向,所以六个宿主里有的钉着旧 group 的地址,有的钉着新的,甚至有一个宿主里同一个包的两个引用大小写不同。SPM 按仓库名推导 package identity,这些地址都解析到同一个 identity,所以一直能用。但「能用」和「整齐」之间的差距,会在某一天有人「顺手统一一下」的时候变成一个下午的解析错误。我的建议是:地址一旦能解析,就不要碰。要统一,所有宿主在同一个提交里一起统一。
把迁移写成 runbook 的价值不在于文档本身,而在于它把一个需要理解包内部结构的工作,降级成了一个机械替换的工作。六个宿主,六个维护者,不可能每个人都读过包的源码。
八、如果你的情况不是这样,别学我们
这套方案是在「私有仓库、作者和用户是同一群人、六款 App 共享、热修频繁」这个前提下长出来的。换一个前提,很多选择会反过来。
- 只有一款 App。 不要拆包。一个工程里的多 target 或者单仓
path:依赖足够了,SPM 分支、resolved、钻石依赖这些问题你一个都不会碰到。 - 包要给团队之外的人用。 用 tag 加 semver,别碰分支方案。外部用户没法跟你「同步钉一个分支名」,他们需要
from:能工作。 - 宿主数量远大于包的维护者数量。 同样用 tag。分支方案的前提是「通知所有宿主一起改」这件事便宜,宿主一多就不便宜了。
- 包之间天然有层次(基础层、业务层)。 可以接受内部依赖,但把它们放进同一个仓库用
path:引用,或者只在最底层允许被依赖,而且底层包的发版节奏要跟业务层分开。
反过来,如果你的情况跟我们一样,分支方案是能用的,前提是把第二节末尾那两条纪律加上,并且像第三节那样把依赖图修剪成叶子。
九、三个反直觉的观察
第一,分支依赖的真正问题不是不可复现,是 bump 粒度。 Package.resolved 锁了 revision,复现性是有的。你失去的是「只前进一步」的能力:想要分支上的一个热修,就得吃下这条分支上的全部改动。于是分支号退化成热修通道,一个包在六款 App 里钉着五六个不同分支。
第二,在分支世界里,「内部包依赖内部包」是依赖图上最贵的一条边。 分支之间没有偏序,一条内部边就把那个分支名变成所有宿主的硬约束。宁可两个包里各复制一份加密代码,也不要让它们连线。回调交给宿主,宿主做胶水。
第三,最干净的解耦制造最安静的失效。 端口加空实现是教科书式的设计,它把「漏挂一个 product」从编译错误变成了两周后运营来问的问题。任何可以被「不安装」的实现,都必须配一个「有没有装」的可观测信号。
十、可迁移的原则
把 Swift Package 这层皮剥掉,这篇讲的其实是三件事。
版本方案的本质是 bump 粒度的选择。 tag 给你不可变的点,分支给你会动的线。选哪个取决于你的用户是谁、热修有多频繁,而不是哪个「更规范」。选了线,就要补上归档点。
依赖图里,横向的边比纵向的边贵得多。 宿主依赖包是纵向的,便宜;包依赖包是横向的,每一条都要用「所有宿主同步」来还。能用复制和回调消掉的横向边,就消掉。
可替换的实现必须可观测。 端口、协议、依赖注入、空实现,这些让系统可以在「没装」的状态下运行。那它就必须能回答「现在装了吗」,用日志、用断言、用一个常量,什么都行,但不能是沉默。
这三条我后来在非 iOS 的场景里也用过:一个多服务共享的内部 SDK,和一个插件化的构建流水线。结构完全一样。
这类多 App 共享代码、SPM 依赖解析、包拆分与版本策略的问题,我提供 48 小时内的书面诊断:你给依赖图、Package.resolved 和报错信息,我给根因、改法和一份后续不再踩的规则清单。联系方式见个人主页。