SPM 之 混编(OC、Swift)项目保姆级教程(Swift Package Manager)

一、Swift Package Manager(以下简称SPM)简介

1.1、SPM 核心特性

1. 原生集成

  • Apple 官方支持:从 Xcode 11 开始内置,无需额外安装(对比 CocoaPods 需要 Ruby 环境,Carthage 需要 Homebrew)。
  • 与 Xcode 无缝协作 :通过 Package.swift 文件定义依赖,Xcode 可直接解析并下载依赖。

2. 模块化设计

  • 基于 Swift 的模块系统 :每个包(Package)是一个独立的模块,支持 target 划分(如库、测试、可执行文件)。
  • 跨平台兼容:同一套代码可编译到不同 Apple 平台或 Linux。

3. 依赖管理

  • 支持远程依赖:通过 Git 仓库(GitHub/GitLab)引入开源库。
  • 支持本地依赖:直接引用本地路径的模块(适合内部开发)。
  • 版本控制:支持语义化版本(SemVer)、分支、Commit Hash 等。

4. 资源管理

  • 原生支持资源文件 :如 .xcassetsxibstoryboard,通过 resources 字段声明。
  • 访问方式 :使用 Bundle.module 加载资源(无需手动处理路径)。

5. 命令行工具

  • 核心命令

    perl 复制代码
    bash
    swift package init       # 初始化新包
    swift package resolve     # 解析依赖
    swift package update      # 更新依赖
    swift build              # 编译项目
    swift test               # 运行测试

1.2、SPM vs CocoaPods vs Carthage

对比项 SPM CocoaPods Carthage
官方支持 ✅ Apple 官方(Xcode 内置) ❌ 第三方(Ruby 编写) ❌ 第三方(Swift/Go 编写)
安装复杂度 ⚪ 无安装(Xcode 自带) ⚫ 需安装 Ruby 和 CocoaPods ⚫ 需安装 Homebrew 或编译
依赖原理 ✅ 直接编译源码 ⚫ 生成 .xcworkspace 和动态库 ✅ 编译二进制框架(静态库)
版本控制 ✅ 支持 SemVer/分支/Commit ✅ 支持 SemVer ✅ 支持 Git Tag
资源支持 ✅ 原生支持(Bundle.module ✅ 通过 resources 插件 ❌ 需手动处理
跨平台 ✅ 支持 macOS/iOS/Linux ❌ 仅 Apple 平台 ✅ 支持 Apple 平台 + Linux
二进制支持 不直接支持.a(包一下), 支持xcframework ✅ 可生成动态库(.framework ✅ 默认生成静态库(.a
学习曲线 ⚪ 中等(需熟悉 Package.swift ⚪ 中等(需了解 Podfile ⚪ 中等(需了解 Cartfile
适用场景 ✅ 新项目、模块化开发 ✅ 遗留项目、需要动态库 ✅ 追求编译速度、静态库

关键差异分析

  1. 依赖原理

    • CocoaPods :通过修改 project.pbxproj 文件,生成 .xcworkspace,依赖动态库(可能增加启动时间)。
    • Carthage:仅编译二进制框架,不修改项目文件,需手动集成到 Xcode。
    • SPM:直接编译源码到目标模块,不生成额外文件,与 Xcode 深度集成。
  2. 资源管理

    • SPM :原生支持资源文件,通过 Bundle.module 访问。
    • CocoaPods :需通过 resources 插件或手动配置 COPY_RESOURCES
    • Carthage:需手动将资源文件拖入项目。
  3. 二进制支持

    • Carthage 默认生成静态库,适合减小应用体积。
    • SPMCocoaPods 默认编译源码,但 CocoaPods 可通过 use_frameworks! 生成动态库。

1.3、SPM 适用场景

1. 推荐使用 SPM 的情况

  • 新项目开发:从零开始的项目,可充分利用 SPM 的模块化设计。
  • 跨平台库:需要同时支持 macOS/iOS/Linux 的 Swift 库。
  • Apple 生态开发:与 Xcode 深度集成,避免第三方工具的兼容性问题。
  • 追求简洁性:不想处理 Ruby 环境或 Homebrew 依赖。

2. 不推荐使用 SPM 的情况

  • 遗留项目迁移:旧项目已使用 CocoaPods,迁移成本较高。
  • 需要二进制分发 :如第三方 SDK 仅提供 .framework 文件(需通过 CocoaPods 或手动集成)。
  • 复杂依赖冲突:SPM 的依赖解析逻辑较严格,可能不如 CocoaPods 灵活。

1.4、SPM模块引入方式

引入方式有两种:

1、直接通过远程链接导入 2、直接依赖本地模块

1. 远程依赖(通过 Git 仓库导入)

适用场景
  • 依赖的模块是开源库(如 AlamofireSwiftLint)。
  • 模块托管在 GitHub、GitLab 或其他 Git 服务器上。
  • 需要自动获取最新版本或指定版本范围。
配置方法
(1) 在 Package.swift 中声明远程依赖
swift 复制代码
// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "MyApp",
    dependencies: [
        // 方式1:指定版本范围(如 5.6.0 到 6.0.0)
        .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.6.0"),
        
        // 方式2:精确指定版本(如 5.6.1)
        .package(url: "https://github.com/SwiftGen/SwiftGen.git", exact: "6.6.0"),
        
        // 方式3:指定分支(如 main 分支,不推荐生产环境使用)
        .package(url: "https://github.com/example/repo.git", branch: "main"),
        
        // 方式4:指定 Commit Hash(用于临时修复)
        .package(url: "https://github.com/example/repo.git", revision: "a1b2c3d4e5f6"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "Alamofire", package: "Alamofire"),
                .product(name: "SwiftGenKit", package: "SwiftGen"),
            ]
        )
    ]
)
(2) 在 Xcode 中直接添加远程依赖
  1. 打开 Xcode 项目,选择 File > Add Packages
  2. 输入 Git 仓库 URL(如 https://github.com/Alamofire/Alamofire.git)。
  3. 选择版本规则(Up to Next MajorExact VersionBranch)。
  4. 点击 Add Package ,Xcode 会自动生成 Package.swift 并下载依赖。

常见问题
Q1: 依赖下载失败(网络问题或仓库不存在)
  • 解决方法

    • 检查网络连接,确保能访问 Git 仓库。
    • 如果是私有仓库,需配置 SSH 密钥或 GitHub Personal Access Token。
    • 在 Xcode 中重置包缓存:File > Reset Package Caches
Q2: 版本冲突(如多个依赖要求不同版本的同一库)
  • 解决方法

    • Package.swift 中显式指定版本:

      swift 复制代码
      .package(url: "https://github.com/example/repo.git", from: "1.0.0"),
    • 使用 resolution 字段强制解析版本(Xcode 14+ 支持):

      swift 复制代码
      // 在 Package.swift 的顶层添加
      let package = Package(
          // ...
          dependencies: [...],
          // 强制解析版本
          resolutions: [
              .package(url: "https://github.com/example/repo.git", exact: "1.0.0")
          ]
      )
Q3: 如何更新依赖到最新版本?
  • 方法

    • 运行 swift package update 命令。
    • 在 Xcode 中:Product > Clean Build Folder,然后重新编译。

2. 本地依赖(直接引用本地模块)

适用场景
  • 依赖的模块是本地开发的(如公司内部库、未开源的模块)。
  • 需要快速迭代本地代码,无需频繁推送 Git。
  • 模块与主工程紧密耦合,不适合远程托管。
配置方法
(1) 在 Package.swift 中声明本地依赖

假设本地模块位于主工程的同级目录 ../LocalModule

swift 复制代码
// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "MyApp",
    dependencies: [
        // 本地依赖(相对路径)
        .package(path: "../LocalModule"),
        
        // 也可以混合远程依赖
        .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.6.0"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                "LocalModule", // 直接引用本地模块名
                .product(name: "Alamofire", package: "Alamofire"),
            ]
        )
    ]
)
(2) 本地模块的 Package.swift 示例

本地模块也需要是一个有效的 SPM 包,例如:

swift 复制代码
// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "LocalModule",
    products: [
        .library(name: "LocalModule", targets: ["LocalModule"]),
    ],
    targets: [
        .target(name: "LocalModule", path: "Sources"),
    ]
)
(3) 在 Xcode 中添加本地依赖
  1. 打开 Xcode 项目,选择 File > Add Packages
  2. 点击 Add Local ,选择本地模块的 Package.swift 文件。
  3. Xcode 会自动解析依赖并链接到主工程。

常见问题
Q1: 本地模块路径错误
  • 错误示例

    arduino 复制代码
    dependency 'LocalModule' not found at '../LocalModule'
  • 解决方法

    • 确保 .package(path: "../LocalModule") 的路径是相对于主工程 Package.swift 的。

    • 如果路径包含空格或特殊字符,用引号包裹路径:

      lua 复制代码
      swift
      .package(path: "/path/with spaces/LocalModule")
Q2: 本地模块修改后未生效
  • 原因

    • Xcode 可能缓存了旧版本。
  • 解决方法

    • 清理构建缓存:Product > Clean Build Folder
    • 重新运行 swift package update
    • 如果使用 Xcode 的 SPM 集成,尝试 File > Reset Package Caches
Q3: 本地模块如何引用主工程的代码?
  • 问题

    • 本地模块和主工程可能存在循环依赖。
  • 解决方法

    • 避免循环依赖,将共享代码提取到第三个模块中。
    • 如果必须引用,可以使用 Xcode Workspace 结合 SPM(不推荐,复杂度高)。
Q4: 本地模块包含资源文件(如 .xcassets
  • 解决方法

    • 在本地模块的 Package.swift 中声明资源:

      swift 复制代码
      .target(
          name: "LocalModule",
          dependencies: [],
          resources: [.process("Resources")] // 指定资源目录
      )
    • 在代码中通过 Bundle.module 加载资源:

      swift 复制代码
      let bundle = Bundle.module
      let image = UIImage(named: "MyImage", in: bundle, with: nil)

3. 远程依赖 vs 本地依赖:如何选择?

对比项 远程依赖 本地依赖
适用场景 开源库、稳定版本 内部开发、快速迭代
版本控制 通过 Git 标签/分支管理 直接修改代码,无需推送
构建速度 较慢(需下载) 快(本地直接引用)
协作性 适合团队共享 适合单人或本地开发
依赖冲突 可能因版本不兼容报错 路径错误或循环依赖更常见

4. 最佳实践

  • 远程依赖 :通过 .package(url:from:) 引入,适合开源库和稳定版本。
  • 本地依赖 :通过 .package(path:) 引入,适合快速迭代的内部模块。
  • Xcode 集成 :支持通过 GUI 添加依赖,但底层仍依赖 Package.swift
  • 常见问题:路径错误、版本冲突、缓存问题,通常通过清理缓存或调整路径解决。
  • 避免混合管理依赖 :不要在 Package.swiftPodfile/Cartfile 中重复声明同一依赖。
  • 定期更新依赖 :运行 swift package update 或使用 Xcode > Product > Clean Build Folder 保持依赖最新。

5. 彻底清理缓存

⚠️注意:当你在尝试配置模块已经依赖时,如果没有完全清空缓存,可能导致你正确的代码无法正常编译。

5.1.当前模块改动

sh 复制代码
swift package update
swift package clean

5.2.Package依赖缓存清理

Xcode的工具栏:File - Packages - Reset Package Caches

5.3.退出Xcode 工程, 重新打开项目 必要时清理项目缓存 ~/Library/Developer/Xcode/DerivedData/你的工程

二、本地模块实操

实践中通常使用现有项目,此文演示先创建一个空OC项目(Swift也一样, 只不过大部分项目壳工程都是OC,兼容性问题也多,方便演示后面一些问题):

1、纯Swift文件

项目路径下添加模块

sh 复制代码
#本地管理路径
➜  SPMDemo git:(main) ✗ mkdir LocalModules && cd LocalModules

#测试模块1
➜  LocalModules git:(main) ✗ mkdir ModulesA && cd ModulesA

#创建项目模块
➜  ModulesA git:(main) ✗ swift package init --type library
Creating library package: ModulesA
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/ModulesA/ModulesA.swift
Creating Tests/
Creating Tests/ModulesATests/
Creating Tests/ModulesATests/ModulesATests.swift

swift package init --type 参数

--type 参数用于指定初始化包的类型,支持以下选项:

选项 说明
--type library 创建一个 库(Library) 类型的包(默认选项,可省略)。
--type executable 创建一个 可执行文件(Executable) 类型的包(包含 main.swift)。
--type system-module 创建一个 系统模块(System Module) 类型的包(用于 C 语言家族的模块)。
--type manifest 仅创建一个空的 Package.swift 清单文件(不生成其他模板文件)。

生成的library文件结构

bash 复制代码
ModulesA/
├── Package.swift       # 包描述文件
├── Sources/
│   └── ModulesA/      # 库源码目录,这里可以添加你的swift模块
│       └── ModulesA.swift
└── Tests/
    └── ModulesATests/ # 测试目录
        └── ModulesATests.swift

添加:

设置目标工程:

纯swift模块在项目中效果:

⚠️注意:如果你的库源码目录不在Sources/ModulesA下,而是自定义路径(建议使用规范路径),需要设置path:

2、纯OC文件

2.1、模块目录结构

ruby 复制代码
MyOCModule/
├── Sources/
│   ├── MyOCModule/          # OC 代码目录
│   │   ├── include/          # 公开头文件目录(可选)
│   │   │   └── MyOCModule.h # 模块主头文件
│   │   │   └── module.modulemap     # 模块映射文件(关键)
│   │   └── src/             # OC 源文件目录
│   │       └── MyOCClass.m  # OC 类实现
│   │       └── MyOCClass.h  # OC 类头文件
├── Package.swift            # SPM 配置文件
└── README.md                # 项目说明(可选)

2.2、关键文件配置

1. module.modulemap(模块映射文件)

定义 OC 模块的接口,指定头文件路径。示例内容:

swift 复制代码
module MyOCModule {
    header "MyOCModule.h"  // 指向模块主头文件
    export *                       // 导出所有头文件内容
}
  • header :指定模块的主头文件(如 MyOCModule.h),该文件需包含所有对外公开的头文件。
  • export :控制头文件的可见性(* 表示导出所有内容)。
2. Package.swift(SPM 配置文件)

配置 Target 依赖关系及编译路径。示例内容:

swift 复制代码
// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "MyOCModule",
    products: [
        .library(
            name: "MyOCModule",
            targets: ["MyOCModule"]
        ),
    ],
    targets: [
        .target(
            name: "MyOCModule",
            path: "Sources/MyOCModule",  // OC 代码路径
            exclude: ["info.plist"], // 被排除的文件,多个用逗号分隔(如果有就行设置,没有的文件不要写,会有警告文件找不到)
            sources: ["src"],  // OC源代码路径:path 路径下
            publicHeadersPath: "include"      // 公开头文件夹
        ),
    ]
)
  • publicHeadersPath :指定公开头文件目录(如 include),需与 module.modulemap 中的路径一致。
  • sources :指定源文件目录(如 src),包含 .m.h 文件。

⚠️注意:模块集成后,会以库形式在工程中被依赖,假如你要删除此OC模块,需要确保此处同样被移除,否则编译会报错!!!

3、Swift Package模块 依赖 OC Package 模块

1. 在 Swift 代码中导入 OC 模块

⚠️注意:

这里OC Package 是通过配置 include/module.modulemap 实现被Swift 依赖引用的,另外一种方式后面介绍

1、Package 配置:

swift 复制代码
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "ModulesA",
    products: [
        .library(
            name: "ModulesA",
            targets: ["ModulesA"]),
    ],
    dependencies: [
        // 依赖地址或者本地资源文件夹地址.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(path: "../MyOCModule")
    ],
    targets: [
        .target(
            name: "ModulesA",
            dependencies: [
                // 依赖OC模块
                .product(name: "MyOCModule", package: "MyOCModule")
            ]
        ),
        .testTarget(
            name: "ModulesATests",
            dependencies: ["ModulesA"]),
    ]
)

⚠️注意:上面依赖一个模块时,一个当前Package索引的依赖路径,也就是和 targets 评级的, 而每个不同的子target根据自己的需要依赖不同的模块,比如此处设置依赖的MyOCModule:

swift 复制代码
// 依赖OC模块
.product(name: "MyOCModule", package: "MyOCModule")

发现product 的name 和 package name 是一样的,那么就可以简写成如下:

swift 复制代码
.target(
    name: "ModulesA",
    dependencies: [
        "MyOCModule" // 依赖OC模块
    ]
)
2、在 Swift 文件中,通过 import 语句导入 OC 模块
swift 复制代码
import MyOCModule  // 导入 OC 模块
 
class SwiftClass {
    func useOCMethod() {
        let ocObject = MyOCClassPublicA()
        ocObject.test() // OC测试方法调用
    }
}

4、同一个 OC Package 模块中多个 OC target(类似于.podspec中 subspec)

1.配置 Target 依赖关系

若模块间需相互调用,需在 Package.swift 中配置 Target 依赖关系:

swift 复制代码
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyOCModule",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "MyOCModule",
            targets: ["MyOCModule"]),
        .library(
            name: "MyOCModuleB",
            targets: ["MyOCModuleB"]),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "MyOCModule",
            dependencies: ["MyOCModuleB"], // 依赖 MyOCModuleB
            path: "Sources/MyOCModule",  // OC 代码路径
            exclude: ["info.plist"], // 被排除的文件,多个用逗号分隔(如果有就行设置,没有的文件不要写,会有警告文件找不到)
            sources: ["src"],  // OC源代码路径:path 路径下
            publicHeadersPath: "include"
        ),
        .target(
            name: "MyOCModuleB",
            dependencies: [],
            path: "Sources/MyOCModuleB",  // OC 代码路径
            sources: ["src"],
            publicHeadersPath: "include"
        )
        
    ]
)
2.目录结构和调用:

⚠️注意:

从上面截图可以看到,在同一个模块内部代码之间一般是直接引用的 #import "MyOCModuleB.h" 那么,如果需要 #import <MyOCModuleB/MyOCModuleB.h> 方式引入呢?在下一节介绍

5、OC Package 依赖另一个 OC Package 模块

前面提到,OC target 通过配置 include/module.modulemap 是可以实现:

1、直接被Swift Package/target 通过import PackageName or targetName 引用

2、或者 OC target 依赖后通过 #import "FileName.h" 引用的

但是 OC Package 之间是需要通过 #import <MyOCModuleB/MyOCModuleB.h> 方式引用头文件的,接下来通过几个步骤介绍:

1、创建一个OC Package:MyOCHello

swift package init --type library MyOCHello

swift 复制代码
MyOCHello/
├── Sources/
│   ├── MyOCHello/          # OC 代码目录
│   │   ├── include/          # 公开头文件目录
│   │   │   └── MyOCHello.h # 模块主头文件
│   │   └── Core/             # OC 源文件目录
│   │   │   └── MyOCHelloName.m  # OC 类实现
│   │   │   └── MyOCHelloName.h  # OC 类头文件
│   │   └── Private/             # OC 源文件目录
│   │       └── MyOCHelloAge.m  # OC 类实现
│   │       └── MyOCHelloAge.h  # OC 类头文件
├── Package.swift            # SPM 配置文件
2、设置公开头文件目录

MyOCHelloName.h 文件作为 public 头文件,前面示例中,我直接将 MyOCClassPublicA.h 文件拖动到 include 文件夹中。

但是在MyOCHello 模块不准备如此,而是创建MyOCHelloName.h 文件替身 放到include 文件夹中

原因1:此模块我不打算创建 module.modulemap文件,移走后可能导致 MyOCHelloAge.m 无法使用 #import "MyOCHelloName.h"

原因2:不希望移动现有工程的文件的头文件

3、通过命令 ln -s 创建替身

格式 ln -s xxx文件夹目录/**.h xxx文件夹目录/

sh 复制代码
ln -s ~/Documents/SPMDemo/LocalModules/MyOCHello/Sources/MyOCHello/Core/MyOCHelloName.h ~/Documents/SPMDemo/LocalModules/MyOCHello/Sources/MyOCHello/include/MyOCHello

⚠️注意: 创建替身实践中存在以下几个问题,导致我项目加载总是失败(以下3种情况创建的替身在我电脑均索引失败😑,可能只是我的电脑问题,列出来希望能少走弯路)

1、右键.h文件创建替身,然后拖动到 include/MyOCHello/ 文件夹下面,替身在Xcode 15中无法编辑,无法索引

2、使用 ln -s 错误命令:ln -s xx/MyOCHelloName.h xx/MyOCHelloName.h

3、文件路径使用的相对路径,拖动替身到其它文件夹中就可能存在无法正常索引情况,请使用绝对路径。

至此,文件夹目录大致是这样的:

或者在终端通过 tree 命令(安装:brew install tree)生成结构:

js 复制代码
➜  MyOCHello git:(main) ✗ tree
.
├── Package.swift
├── Sources
│   └── MyOCHello
│       ├── Core
│       │   ├── MyOCHelloName.h
│       │   └── MyOCHelloName.m
│       ├── Private
│       │   ├── MyOCHelloAge.h
│       │   └── MyOCHelloAge.m
│       └── include
│           └── MyOCHello
│               ├── MyOCHello.h
│               └── MyOCHelloName.h -> xxx/Core/MyOCHelloName.h //会显示真身路径
└── Tests
    └── MyOCHelloTests
        └── MyOCHelloTests.swift

9 directories, 8 files

如果你想展示模块配置信息: 执行命令 swift package describe

js 复制代码
➜  MyOCHello git:(main) ✗ swift package describe
Name: MyOCHello
Manifest display name: MyOCHello
Path: /Users/xxx/SPMDemo/LocalModules/MyOCHello
Tools version: 5.10
Dependencies:
Platforms:
Products:
    Name: MyOCHello
    Type:
        Library:
            automatic
    Targets:
        MyOCHello

Targets:
    Name: MyOCHello
    Type: library
    C99name: MyOCHello
    Module type: ClangTarget
    Path: Sources/MyOCHello
    Sources:
        Core/MyOCHelloName.m
        Private/MyOCHelloAge.m
    Product memberships:
        MyOCHello
4、编辑模块头文件:

MyOCHello.h

js 复制代码
#ifndef MyOCHello_h
#define MyOCHello_h

// 暴露头文件给外部使用时,使用简括号形式引入,并且对应头文件能在include目录下索引
#import <MyOCHello/MyOCHelloName.h>

#endif /* MyOCHello_h */

MyOCHelloName.h

js 复制代码
@interface MyOCHelloName : NSObject

+ (void)helloSomeOne:(NSString *)name;

@end

MyOCHelloName.m

js 复制代码
#import "MyOCHelloName.h"
#import "MyOCHelloAge.h"
@implementation MyOCHelloName

+ (void)helloSomeOne:(NSString *)name {
    NSLog(@"MyOCHello, name:%@, age:%@", name, [MyOCHelloName showAge]);
}

@end
5、MyOCModule 依赖 MyOCHello 模块
js 复制代码
dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(path: "../MyOCHello"),
    ],
    targets: [
        .target(
            name: "MyOCModule",
            dependencies: [
                "MyOCHello"
            ], // 依赖
            
      等等
6、清理缓存(前面介绍了如何彻底清理缓存)

Xcode的工具栏:File - Packages - Reset Package Caches

重启工程

7、引用并调用
js 复制代码
#import "MyOCClassPublicA.h"
#import <UIKit/UIKit.h>

// 引用其它OC模块
#import <MyOCHello/MyOCHello.h>

@implementation MyOCClassPublicA

/// 调用MyOCHello模块方法
- (void)OCHelloName {
    [MyOCHelloName helloSomeOne:@"Bobo"];
}

@end

6、OC、Swift混编模块

6.1. CocoaPods场景:

CocoaPods 中存在一些老项目是OC代码为主,后来新增部分Swift代码, OC 和 Swift直接是可以直接相互引用的。

1、Pods库中代码文件可以 混编

2、Swift可以直接使用当前模块的公开的OC头文件,podspec中设置public路径,最后在-umbrella.h中查看到:

3、而module中的OC文件可以通过 #import <SHMaasUtils/SHMaasUtils-Swift.h> 依赖模块中 @objc public 的swift文件,而这个-Swift.h头文件是自动生成的,不需要手动创建。

6.2. SPM场景:

1、Package库中混编代码文件 不可以 混装,需要单独设置 target

2、Swift可以通过 dependencies 依赖公开的OC头文件(前面已经介绍了)

3、Package中的OC target文件目前尝试了,无法通过通过 #import <SHMaasUtils/SHMaasUtils-Swift.h> 依赖模块中 @objc public 的swift文件,而这个-Swift.h头文件是不会自动生成的

如果你通过 dependencies 依赖Swift target,那么必然导致依赖循环,可使用拆分公共文件作为独立target,协议解耦, 等方法处理,再使用 @import 引用swift文件即可

下面只介绍最复杂的场景

1、如果存在A.h 文件 和 B.swift 文件相互依赖的情况,那么需要将源码先解耦合 最终:A.h依赖C、 B.swift依赖C

2、将Package拆分: OCTarget、SwiftTarget、AdapterTarget

依赖关系1: OCTarget -> (SwiftTarget -> AdapterTarget)

依赖关系2: SwiftTarget -> (OCTarget -> AdapterTarget)

依赖关系3: SwiftTarget -> AdapterTargetOCTarget -> AdapterTarget

⚠️ 1和2场景的混合模块还是很常见,如果完全做到3的关系可能修改业务比较多,影响过大。

下面就以关系1的场景创建示例模块,并且AdapterTarget为OC代码的场景。

6.3. 创建混合模块

文件目录:

js 复制代码
➜  MixModule git:(main) ✗ tree
.
├── MixModuleAdapter
│   ├── PersonAdapter.h
│   └── PersonAdapter.m
├── MixModuleOC
│   ├── PersonHomeView.h
│   ├── PersonHomeView.m
│   └── include
│       └── MixModuleOC
│           ├── MixModuleOC.h
│           └── PersonHomeView.h -> /Users/xxx/SPMDemo/LocalModules/MixModule/MixModuleOC/PersonHomeView.h
├── MixModuleSwift
│   └── PersonInfoView.swift
└── Package.swift

6 directories, 8 files

Package.swift

js 复制代码
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MixModule",
    products: [
        .library(
            name: "MixModuleOC",
            targets: ["MixModuleOC"]),
        .library(
            name: "MixModuleSwift",
            targets: ["MixModuleSwift"]),
        .library(
            name: "MixModuleAdapter",
            targets: ["MixModuleAdapter"]),
    ],
    targets: [
        .target( // OC业务
            name: "MixModuleOC",
            dependencies: ["MixModuleSwift"],
            path:"MixModuleOC",
            sources: [""], // 需要设置
            publicHeadersPath: "include"),
        .target( // swift 业务
            name: "MixModuleSwift",
            dependencies: ["MixModuleAdapter"],
            path:"MixModuleSwift"
        ),
        .target( // 中间、适配
            name: "MixModuleAdapter",
            dependencies: [],
            path:"MixModuleAdapter",
            sources: [""],
            publicHeadersPath: ""
        )
        
    ]
)
1、OC target调用Swift target文件:

MixModuleOC.h (可以对外暴露)

Swift 复制代码
//
//  MixModuleOC.h
//  
//
//  Created by 聂小波 on 2025/9/10.
//

#ifndef MixModuleOC_h
#define MixModuleOC_h

#import <MixModuleOC/PersonHomeView.h>

#endif /* MixModuleOC_h */

PersonHomeView.m (⚠️ 依赖swift target 使用 @import

swift 复制代码
//
//  PersonHomeView.m
//  
//
//  Created by 聂小波 on 2025/9/10.
//

#import "PersonHomeView.h"

// 引用swift target
@import MixModuleSwift;

@implementation PersonHomeView

- (void)showInfoView {
    NSLog(@"PersonHomeView show");
    PersonInfoView *personInfoView = [[PersonInfoView alloc] init];
    [personInfoView hello];
}

@end
2、Swift target调用OC target文件:
swift 复制代码
//
//  PersonInfoView.swift
//
//
//  Created by 聂小波 on 2025/9/10.
//

import Foundation
import UIKit

// 依赖OC target
import MixModuleAdapter

// 注意:给OC工程或者模块调用需要 @objc public
@objc
public class PersonInfoView: NSObject {
    @objc public override init() {
        super.init()
    }
    
    @objc public func hello() {
        let adapter = PersonAdapter()
        print("PersonInfoView: 我是", adapter.name())
    }
}
3、其它 Package 依赖 MixModule Package 的 MixModuleOC target
js 复制代码
dependencies: [
        // .package(url: /* package url */, from: "1.0.0"),
        .package(path: "../SnapKit"),
        .package(path: "../MixModule") // 本地模块路径
    ],
    targets: [
        .target(
            name: "MyOCModule",
            dependencies: [
                "SHMaasService",
                // 依赖 MixModule Package 的 MixModuleOC target
                .product(name: "MixModuleOC", package: "MixModule")
            ], 
            
     ....

7、依赖 framework 库

如果你的项目必须依赖某个 framework 库

1、如果是 .xcframework 那么不用处理,SPM 直接支持
2、如果是 .framework 需要转 .xcframework
3、如果 .xcframework 放到主工程中,那么可以直接使用
4、如果 .xcframework 在 package 中通过 url 远程加载直接使用
5、如果 .xcframework 在 package 中通过本地文件路径加载,需要先签名

1. MBProgressHUD.framework 转 MBProgressHUD.xcframework
MBProgressHUD文件结构(不含版本):
js 复制代码
➜  MBProgressHUD.framework
.
├── Headers
│   └── MBProgressHUD.h
├── Info.plist
├── MBProgressHUD
└── Modules
    └── module.modulemap

3 directories, 4 files

结构简单,可以直接转xcframework

转换脚本 xcframework.sh
js 复制代码
#!/bin/bash

# 检查是否传入了框架路径参数
if [ $# -eq 0 ]; then
    echo "未输入framework名称"
    exit 1
fi
 

FRAMEWORK_NAME="$1"

# 创建临时目录
mkdir -p ./iOS-Device ./iOS-Simulator

# 提取 arm64(真机)版本
lipo -extract arm64 "${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" -o "./iOS-Device/${FRAMEWORK_NAME}"
cp -R "${FRAMEWORK_NAME}.framework" "./iOS-Device/${FRAMEWORK_NAME}.framework"
mv "./iOS-Device/${FRAMEWORK_NAME}" "./iOS-Device/${FRAMEWORK_NAME}.framework/"

# 提取 x86_64(模拟器)版本
lipo -extract x86_64 "${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" -o "./iOS-Simulator/${FRAMEWORK_NAME}"
cp -R "${FRAMEWORK_NAME}.framework" "./iOS-Simulator/${FRAMEWORK_NAME}.framework"
mv "./iOS-Simulator/${FRAMEWORK_NAME}" "./iOS-Simulator/${FRAMEWORK_NAME}.framework/"

xcodebuild -create-xcframework \
    -framework "./iOS-Device/${FRAMEWORK_NAME}.framework" \
    -framework "./iOS-Simulator/${FRAMEWORK_NAME}.framework" \
    -output "${FRAMEWORK_NAME}.xcframework"


# 如果是本地SPM调试,需要对每个平台(真机/模拟器)的框架进行签名
codesign --force --sign "Apple Development: Dean Nie (XXS7DPMZ77)" \
    --entitlements entitlements.plist \
    --timestamp=none \
    "${FRAMEWORK_NAME}.xcframework/ios-arm64/${FRAMEWORK_NAME}.framework"

codesign --force --sign "Apple Development: Dean Nie (XXS7DPMZ77)" \
    "${FRAMEWORK_NAME}.xcframework/ios-arm64_x86_64-simulator/${FRAMEWORK_NAME}.framework"

 

先授权脚本,再使用: xcframework.sh 和 MBProgressHUD.framework在同一目录下

sh 复制代码
chmod +x xcframework.sh
./xcframework.sh MBProgressHUD
2. FMDB.framework 转 FMDB.xcframework
framework文件结构(含版本):
js 复制代码
➜  FMDB.framework
.
├── FMDB -> Versions/Current/FMDB
├── Headers -> Versions/Current/Headers
├── Modules
│   └── module.modulemap
└── Versions
    ├── A
    │   ├── FMDB
    │   └── Headers
    │       ├── FMDB.h
    │       ├── FMDatabase.h
    │       ├── FMDatabaseAdditions.h
    │       ├── FMDatabasePool.h
    │       ├── FMDatabaseQueue.h
    │       └── FMResultSet.h
    └── Current -> A

7 directories, 9 files

通过文件结构可以发现,framework 真实二进制文件 可以通过 FMDB.framework/Versions/Current/ 路径查找

⚠️ 注意:在 SPM 中xcframework 文件结构测试下来不支持 版本管理结构。

可以自己动手修改脚本将文件重新梳理

3. MBProgressHUD.xcframework 放在主工程中使用

修改 linkerSettings 添加依赖即可:

js 复制代码
targets: [
        .target(
            name: "MixModuleOC",
            dependencies: ["MixModuleSwift"],
            path:"MixModuleOC",
            sources: [""],
            publicHeadersPath: "include", 
            linkerSettings: [
                .linkedFramework("MBProgressHUD")
            ]
        ),
4. MBProgressHUD.xcframework 放在package中

创建一个包含多个库的Package:FMLibs

目录结构如下:

js 复制代码
➜  FMLibs git:(main) ✗ tree
.
├── Package.swift
└── binaryLibs
    └── MBProgressHUD.xcframework

Package 配置如下:

js 复制代码
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "FMLibs",
    products: [
        .library(
            name: "FMLibs",
            targets: [
                "MBProgressHUD",
                // "FMDB"
            ]
        ),
    ],
    targets: [
        .binaryTarget(name: "MBProgressHUD",path: "binaryLibs/MBProgressHUD.xcframework"),
        // .binaryTarget(name: "FMDB",path: "binaryLibs/FMDB.xcframework"),
        
    ]
)
5. MixModule 依赖 FMLibs 并使用

MixModule Package.swift

js 复制代码
dependencies: [
        .package(path: "../FMLibs")
    ],
targets: [
    .target(
        name: "MixModuleOC",
        dependencies: [
            "MixModuleSwift",
            "FMLibs"
        ],
        path:"MixModuleOC",
        sources: [""],
        publicHeadersPath: "include", 
        linkerSettings: [
        ]
    ),

显示HUD(直接调用演示)

js 复制代码
#import "PersonHomeView.h"
@import MixModuleSwift;

//binaryTarget
#import <MBProgressHUD/MBProgressHUD.h>

@implementation PersonHomeView

/// 测试HUD提示
- (void)showHUD {
    [MBProgressHUD showHUDAddedTo:[UIApplication sharedApplication].keyWindow animated:YES];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [MBProgressHUD hideHUDForView:[UIApplication sharedApplication].keyWindow animated:YES];
    });
}

- (void)showInfoView {
    [self showHUD];
    NSLog(@"PersonHomeView show");
    PersonInfoView *personInfoView = [[PersonInfoView alloc] init];
    [personInfoView hello];
}

@end

运行app:

6.包含.bundle 资源的 MJRefresh.framework 库

SPM支持bundle资源文件,直接使用前面脚本转 MJRefresh.xcframework

FMLibs Package.swift

js 复制代码
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "FMLibs",
    products: [
        .library(
            name: "FMLibs",
            targets: [
                "MBProgressHUD",
                "MJRefresh"
            ]
        ),
    ],
    targets: [
        .binaryTarget(name: "MJRefresh",path: "binaryLibs/MJRefresh.xcframework"),
        .binaryTarget(name: "MBProgressHUD",path: "binaryLibs/MBProgressHUD.xcframework")
    ]
)

在ViewController使用:

js 复制代码
import MJRefresh


class RefreshViewController: UIViewController {
    private let tableView = UITableView()
    private var dataSource = [String](repeating: "初始数据", count: 10)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        configureRefreshHeader()
    }
    
    private func setupTableView() {
        tableView.frame = view.bounds
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        view.addSubview(tableView)
    }
    
    private func configureRefreshHeader() {
        // 创建自定义刷新头
        let header = MJRefreshNormalHeader { [weak self] in
            self?.loadNewData()
        }
        guard let header else { return }
        // 本地化文本设置
        header.setTitle("下拉刷新", for: .idle)
        header.setTitle("释放立即刷新", for: .pulling)
        header.setTitle("加载中...", for: .refreshing)
        header.lastUpdatedTimeLabel?.isHidden = true
        
        tableView.mj_header = header
    }
    
    private func loadNewData() {
        // 模拟网络请求
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
            self.dataSource.insert("新增数据 \(Date())", at: 0)
            self.tableView.reloadData()
            self.tableView.mj_header?.endRefreshing()
        }
    }
}

extension RefreshViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return dataSource.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = dataSource[indexPath.row]
        return cell
    }
}

运行:

8、集成 .a 静态库

SPM 中无法直接使用 .a 文件,需要包在 xcframework 形式文件夹中访问

接下来演示如何将 libffi.a 转 ffi.xcframework

1、libffi 的源码文件如下:
js 复制代码
➜  libffi-source git:(main) ✗ tree
.
├── ffi.h
├── ffi_arm.h
├── ffi_arm64.h
├── ffi_i386.h
├── ffi_x86_64.h
├── ffitarget.h
├── ffitarget_arm.h
├── ffitarget_arm64.h
├── ffitarget_i386.h
├── ffitarget_x86_64.h
└── libffi.a
2、将所以的.h 文件都放入 Headers 文件夹中
3. 检查 .a 库支持的架构
js 复制代码
➜  lib git:(main) ✗ lipo -info libffi.a
Architectures in the fat file: libffi.a are: armv7 i386 x86_64 arm64
4. 拆分 .a 库
sh 复制代码
# 提取 arm64(真机)
➜  lib git:(main) ✗ lipo -extract arm64 libffi.a -o ffi-arm64.a

# 检查提取结果是否正确
➜  lib git:(main) ✗ lipo -info ffi-arm64.a
Architectures in the fat file: ffi-arm64.a are: arm64


# 提取 x86_64(Intel 模拟器)
➜  lib git:(main) ✗ lipo -extract x86_64 libffi.a -o ffi-x86_64.a

# 检查提取结果是否正确
➜  lib git:(main) ✗ lipo -info ffi-x86_64.a
Architectures in the fat file: ffi-x86_64.a are: x86_64
➜  lib git:(main) ✗
5.重新组合成 xcframework 文件结构,我命名为 ffi.xcframework

创建文件夹 ffi.xcframework 并按照以下通用格式移动文件:

⚠️ 注意:我在前面 生成的 ffi-arm64.affi-x86_64.a 文件最后都更改名称为 ffi (需要删除 .a 后缀)

6.依赖和使用

使用 ffi 调用一个 乘法的 c 函数:

js 复制代码
#import "PersonHomeView.h"

//binaryTarget
#import <ffi/ffi.h>

@implementation PersonHomeView

/// 乘法功能的C函数
int cFuncMultiply(int a, int b) {
    return a * b;
}

/// 测试 ffi 调用 c 函数
- (void)libffiTest {
    //1.定义参数类型数组(告诉 libffi 参数的类型)
    ffi_type **argTypes;
    argTypes = malloc(sizeof(ffi_type *) * 2);
    argTypes[0] = &ffi_type_sint;
    argTypes[1] = &ffi_type_sint;
    //2.定义返回值类型(告诉 libffi 返回值的类型)
    ffi_type *retType = &ffi_type_sint; // 返回值是 int
    //3.初始化 CIF(Call Interface,调用接口)
    ffi_cif cif; // CIF 存储函数调用的 ABI、参数类型、返回值类型等信息
    ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 2, retType, argTypes);
    //4.准备参数值(实际调用时传入的参数)
    void **args = malloc(sizeof(void *) * 2);
    int x = 3, y = 7;
    args[0] = &x; // 第一个参数是 x 的地址
    args[1] = &y; // 第二个参数是 y 的地址
    int ret; // 存储返回值
    //5.动态调用函数
    ffi_call(&cif, (void(*)(void))cFuncMultiply, &ret, args);
    
    // 打印结果
    NSLog(@"libffi:乘法功能的C函数调用结果: %d", ret);
    
    // 释放内存
    free(argTypes);
    free(args);
}

@end

打印结果:

js 复制代码
SPMDemo[10519:7206102] libffi:乘法功能的C函数调用结果: 21

9、资源加载

资源文件位置

SPM 要求资源文件放置在包的 Resources 目录下(或通过目录结构隐式标记为资源)。例如:

js 复制代码
MyPackage/
├── Sources/
│   └── MyFramework/
│       ├── Resources/  # 显式资源目录
│       │   └── image.png
│       └── MyClass.swift
└── Package.swift

或通过文件扩展名隐式标记(如 .xcassets、.lproj 等),SPM 会自动识别为资源。

Package.swift 配置

在包的清单文件中,需通过 targets 的 resources 字段显式声明资源文件或目录:

js 复制代码
swift
targets: [
    .target(
        name: "MyFramework",
        exclude: ["info.plist"], // 排除的文件
        resources: [
            .process("Resources/**"]), // 递归包含 Resources 目录下所有文件
            .copy() //资源按原样复制
        ]  
    )
]
.process(), // 大图等资源会被复制到构建产物中,并可通过 Bundle 访问。
.copy(), // 资源按原样复制,适用于非文本文件(如二进制数据)

10、如何管理工程依赖

1、GUI界面手动管理

通过GUI界面的方式手动的一个一个的进行添加、删除不是一个明智的选择。

你可以通过脚本管理等方式放开你的双手。

2、模块 SPMManager

也可以创建一个空壳模块专门管理依赖

比如,创建一个模块:SPMManager

主工程手动添加 SPMManager 依赖

本地模块示意:

其它的模块都在 SPMManager 中 Package.swift 里配置。

你也可以通过脚本快速修改 Package.swift 文件,比修改工程可是要方便得多。

相关推荐
我是天龙_绍2 小时前
cdn是个啥?
前端
南雨北斗2 小时前
VSCode三个TS扩展工具介绍
前端
若无_2 小时前
了解 .husky:前端项目中的 Git Hooks 工具
前端·git
ze_juejin2 小时前
前端发送语音方式总结
前端
给月亮点灯|2 小时前
Vue3基础知识-Hook实现逻辑复用、代码解耦
前端·javascript·vue.js
Simon_He2 小时前
一款适用于 Vue 的高性能流式 Markdown 渲染器,源自我们的 AI 聊天机器人
前端·vue.js·markdown
顽强d石头2 小时前
v-model与.aync的区别
前端·javascript·vue.js
Hilaku2 小时前
我为什么认为 CSS-in-JS 是一个失败的技术?
前端·css·前端框架
月下点灯2 小时前
✨项目上线后产品要求把应用字体改大点📏怎么办?一招教你快速解决🔧
前端·vite