PAYATHON 2026

从 CocoaPods 迁移到 SwiftPM 后,如何解决测试缺符号和重复类?

数据小陈

结论

建议让应用和测试共同引用同一个生产代码动态库,测试辅助模块也通过明确的 product 依赖使用它,避免两套 umbrella 分别吸收同一批生产代码。

将所有 library product 设为 .dynamic 不能保证解决问题。.dynamic 指定的是 product 的链接形式,同一 package 内的 target 依赖不一定因此变成对该动态 product 的引用。

现有日志确认 GADOMIDWindowProcessor 在两个二进制中重复实现,但不足以证明 FeatureA 的全部代码都重复,也不能单凭这些日志认定后续崩溃只有这一个原因。

为什么 @testable import MyApp 没有解决链接错误?

导入和链接有不同的作用:

  • import:让编译器访问模块声明。
  • @testable import:在模块启用 testing 的前提下,允许访问该模块的 internal 声明。
  • 链接:为实际引用的符号找到对应实现。

@testable import MyApp 不会自动让 MyApp 的所有依赖成为测试的可链接依赖,也不会开放 FeatureA 的 internal 声明。@testable 的定义属于 Swift 访问控制规则,参见 Swift Access Control。

如果需要测试 FeatureA 的内部接口,应先为该模块启用 testing,再使用:

@testable import FeatureA

这仍然要求链接配置正确。对于以 MyApp 为宿主的单元测试,还应检查 TEST_HOST、BUNDLE_LOADER 和实际链接命令;宿主应用的符号解析机制不能替代动态依赖的正确配置。

原日志显示,AppTestModules.framework 中的 FeatureATestSupport 引用了一个未能解析的 FeatureA 符号。这说明相关链接步骤没有找到可用实现,不能因为 import 成功就推断链接也应成功。

原来的依赖结构有什么隐患?

两个 product 的依赖路径存在交集:

AppModules → FeatureA
AppTestModules → FeatureATestSupport → FeatureA

测试 umbrella 原本就能通过传递依赖到达 FeatureA。给 AppTestModules 增加直接依赖后行为发生变化,并不能证明 SwiftPM 通常要求手动补齐传递依赖。这个变化需要结合具体 Xcode 版本、真实依赖图和链接输入来解释。

下面的声明也没有强制指定静态链接:

.library(name: "FeatureA", targets: ["FeatureA"])

未指定 type 时,SwiftPM 根据使用方选择链接形式。要确定 FeatureA 的对象代码是否合入 AppModules,需要检查构建产物,仅看 manifest 不够。Product.Library.type 文档

如果同一份静态代码确实进入两个会加载到同一进程的二进制,就可能产生重复类、类型转换异常或全局状态重复等问题。当前日志明确指出的重复位置是:

AppModules.framework/AppModules
MyAppTests.xctest/MyAppTests

因此,排查时需要检查测试 bundle 本身,也要检查两个 umbrella framework。

一种清晰的改法:生产动态库和测试支持分属两个 package

可以保留两个入口名称,同时明确代码归属:

MyApp ───────────────────────────────→ AppModules(动态 product)
MyAppTests ──────────────────────────→ AppModules(同一个 product)
MyAppTests → AppTestModules
                  └─ FeatureATestSupport → AppModules(product 依赖)

生产代码放在 MyModules,测试辅助代码放在另一个 package。测试支持模块可以通过 .product(...) 明确依赖生产动态库。

SwiftPM 的 target 可以依赖同一 package 的 target,也可以依赖其他 package 提供的 product;这两种依赖关系应分别表达。SwiftPM Target 文档

1. 生产 package

下面是针对题中最小结构的重构示例,假定保留 Sources/AppModules 和 Sources/FeatureA 的源码目录:

// swift-tools-version: 5.10
import PackageDescription

let package = Package(
    name: "MyModules",
    platforms: [.iOS(.v16)],
    products: [
        .library(
            name: "AppModules",
            type: .dynamic,
            targets: ["AppModules", "FeatureA"]
        )
    ],
    targets: [
        .target(name: "FeatureA"),
        .target(
            name: "AppModules",
            dependencies: ["FeatureA"]
        )
    ]
)

这里的动态 product 提供两个模块。Product 可以由多个 target 组成,但它们仍然是独立模块。测试可以明确写 import FeatureA,不必假定 import AppModules 会自动重新导出所有声明。SwiftPM Product 文档

这个方案不再额外提供可被其他路径单独链接的 FeatureA product,以减少同一实现被重复引入的机会。

2. 测试支持 package

假设两个 package 目录并列,将 FeatureATestSupport 和 AppTestModules 的源码移到 MyTestSupport:

// swift-tools-version: 5.10
import PackageDescription

let package = Package(
    name: "MyTestSupport",
    platforms: [.iOS(.v16)],
    products: [
        .library(
            name: "AppTestModules",
            targets: ["AppTestModules", "FeatureATestSupport"]
        )
    ],
    dependencies: [
        .package(path: "../MyModules")
    ],
    targets: [
        .target(
            name: "FeatureATestSupport",
            dependencies: [
                .product(
                    name: "AppModules",
                    package: "MyModules"
                )
            ]
        ),
        .target(
            name: "AppTestModules",
            dependencies: ["FeatureATestSupport"]
        )
    ]
)

测试辅助源码仍然可以使用:

import FeatureA

测试辅助模块改为通过生产 package 的动态 product 获取 FeatureA,避免通过同一 package 的 "FeatureA" target 依赖建立第二条代码打包路径。

AppTestModules 没有必须采用动态链接的理由;如果它只进入一个测试 bundle,可以先保留自动链接形式。这里的配置是最小结构示例,第三方 SDK、资源和其他业务依赖需要按真实项目补充。

3. 调整 Xcode 链接关系

配置为:

  • MyApp 链接生产 package 的 AppModules。
  • MyAppTests 链接同一个 AppModules,以及测试支持 package 的 AppTestModules。
  • 移除旧的 FeatureA 独立链接项和旧 AppTestModules.framework 引用。

需要使用测试辅助模块时,显式写 import FeatureATestSupport。Product 名称、module 名称和声明是否重新导出是不同的概念。

对于宿主测试,应让 AppModules 由宿主正确嵌入,并检查测试的运行时搜索路径,使应用和测试最终使用同一份生产动态库。

怎样确认重复实现来自哪里?

先在 Xcode 的 Report navigator 展开 AppModules、AppTestModules 和 MyAppTests 的链接步骤,检查对象文件、静态库、framework 及响应文件输入。可以启用 Write Link Map File,追踪重复类对应的对象文件来源。

也可以在构建产物中检查符号。以下路径需要替换为实际文件,命令没有在该项目中执行:

xcrun nm -gU /actual/path/AppModules.framework/AppModules | xcrun swift-demangle
xcrun nm -gU /actual/path/MyAppTests.xctest/MyAppTests | xcrun swift-demangle

xcrun nm -m /actual/path/AppModules.framework/AppModules | rg 'GADOMIDWindowProcessor'
xcrun nm -m /actual/path/MyAppTests.xctest/MyAppTests | rg 'GADOMIDWindowProcessor'

xcrun otool -L /actual/path/AppModules.framework/AppModules
xcrun otool -L /actual/path/AppTestModules.framework/AppTestModules
xcrun otool -L /actual/path/MyAppTests.xctest/MyAppTests

nm -gU 用于查看外部已定义符号。检查同名类时,还要区分该二进制是在定义它,还是仅引用它。otool -L 用于查看动态依赖,不会列出所有已合入的静态代码。

如果链接后进行了符号裁剪,应结合 Link Map 判断,不能因为搜索不到符号就认定代码不存在。

迁移项目还应检查 CocoaPods 遗留的链接配置,以及同一 SDK 是否同时通过 CocoaPods、SwiftPM 或手动 framework 引入。关注 -all_load、-force_load、-ObjC 等会影响对象文件装载的设置,但不要未经核对就删除 SDK 必需的参数。

全部改为 .dynamic 是否可行?

要通过动态链接解决这类重复问题,各使用方需要真正链接同一个动态实现,底层依赖也不能再次被静态合入其他二进制。

逐个把 product 标记成 .dynamic,不会把包内的 target 依赖自动改写成动态 product 依赖;预编译静态 SDK 也不会因此自动变成可共享动态库。应先明确生产代码的动态边界,再检查共享的第三方依赖。

纯 FeatureA 单元测试也可以放进 package 的 .testTarget,在独立的包测试环境中运行,应用宿主测试则保留给集成场景。两个独立进程分别使用同一静态库,不属于当前这种同进程重复加载问题。

调整后,先确认重复类警告消失,再重新检查 failed to demangle superclass 和协议一致性异常。如果它们仍然出现,需要根据新的堆栈继续定位,不能直接归结为同一个原因。

备注:内容仅供参考。