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 文件,比修改工程可是要方便得多。

相关推荐
代码匠心16 小时前
AI 自动编程:一句话设计高颜值博客
前端·ai·ai编程·claude
_AaronWong17 小时前
Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记
前端·javascript·vue.js
cxxcode17 小时前
I/O 多路复用:从浏览器到 Linux 内核
前端
用户54330814419417 小时前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo18 小时前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
恋猫de小郭18 小时前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木18 小时前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮18 小时前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati18 小时前
Vue3 父子组件通信完全指南
前端·面试
是一碗螺丝粉18 小时前
5分钟上手LangChain.js:用DeepSeek给你的App加上AI能力
前端·人工智能·langchain