八个私有 SPM 包撑起六款 App:分支当版本、钻石依赖,和一次谁都没报错的失效

这篇讲的是一组私有 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,谁就拿到一个两年前的世界。

如果重来一次

我不会把分支方案整个推翻,前面三个条件没有变。我会加两条纪律:

  1. 每个版本分支在开新分支号时,在老分支的最后一个 commit 上打一个 tag,仅做归档,不给宿主用。这样至少「1.2.1 最终是什么样」有一个不可变的答案。
  2. 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 会直接编译失败;换成端口加空实现之后,漏挂变成了一个只有运营数据才能发现的问题,而且发现的时候已经晚了两周。

现在的补救有三层:

  1. 空实现不再完全沉默。NoopStrategyEngine.fetch() 会打一条 warning 级日志:「触发式引擎未安装,调用被忽略」。
  2. 包里暴露一个安装契约常量,宿主的单测可以断言它:
swift 复制代码
public enum AnalyticsEnginesInstallCheck {
    public static let installsStrategy = true
    public static var installedEngines: (remoteConfig: AnyObject?, strategy: AnyObject?) { ... }
}
  1. 迁移文档里把「挂第二个 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 和报错信息,我给根因、改法和一份后续不再踩的规则清单。联系方式见个人主页。

相关推荐
老李IT笔记1 小时前
远程取设备日志:iOS、tvOS 和 macOS 三端的同意方式不一样
macos·ios
茶底世界之下2 小时前
食物照片有点偏绿?用 Awaking 把色调往回调一点
ios
2601_966563522 小时前
分享3个 OpenWrt手机管理客户端 支持安卓+ios 中文开源免费无广告
ios·智能手机
普通人62 小时前
iPhone Duo 介绍
ios·iphone
2501_915921433 小时前
iOS应用从开发到上架App Store的完整步骤详解
android·ios·小程序·https·uni-app·iphone·webview
健了个平_2414 小时前
【iOS】关于传统竖屏 UIKit 项目如何简单适配 iPhone Duo 的第一版
ios·app·swift
iOS开发上架哦18 小时前
抓包怎么修改响应?响应头、响应体、脚本注入实操教程
后端·ios
开心就好202519 小时前
在 Xcode 中运行和调试单元测试的完整指南
后端·ios