1

Swift 包管理工具,即 Swift Manager Package,简称 SwiftPM,是 Swift 开源项目的一部分,提供了包依赖管理的功能。相对于 CocoaPods、Carthage 等第三方管理工具,SwiftPM 是苹果自己研发,并和苹果平台和 Xcode 高度集成,能提供一些第三方工具无法提供的能力。SwiftPM 从 2018 年开始 release,苹果也在不断地为其添加越来越多的功能,目前已经可以支持 Swift、Objective-C、C++、C 的混编。由于 SwiftPM 的方便易用性,目前有取代其它第三方管理工具之势,越来越多的新项目都开始用 SwiftPM 来组织工程机构,管理依赖。

包是什么?

包(Package)是一个代码和资源的集合,代码可以是源代码,也可以是二进制。包的主要作用就是分发和共享代码和资源,包可以供其它程序调用,有点类似库,包自己本身也可以作为一个可执行程序。相对于库,包提供了一个比较高层的抽象,可以基于包的概念做很多配置,比如依赖、包类型、编译产物等。

在命令行中使用 SwiftPM

当安装好 Xcode 命令行工具后,SwiftPM 也就可以使用了,可以用 SwiftPM 的命令行工具来对包进行各种操作。

新建包

以下命令可以新建一个包:

mkdir Hello
cd Hello
swift package init

在 swift package init 命令之后如果不指定 --type 参数,默认会创建一个库类型的包。可以通过 --type 来创建其它的包。

swift package init --type executable

以上会创建一个可执行的包,可执行的包就是包含 main 函数的包,它可以被操作系统加载并执行。

默认情况下,包名和文件夹的名字一样,如果你希望指定一个别的包的名称,可以加上 --name 参数:

swift package init --name <包名>

创建好的包目录结构如下:

├── Package.swift  
├── README.md  
├── Sources  
│   └── Hello  
│       └── Hello.swift  
└── Tests  
    ├── HelloTests  
    │   └── HelloTests.swift  
    └── LinuxMain.swift 

默认的结构规定 Sources 目录中可以有一个或多个子目录,每个子目录代表一个 target。

编译包

通过 swift build 命令可以将包编译为二进制。

执行包

如果包是一个可执行包,可以通过 swift run 命令来执行包。

执行单元测试

通过 swift test 命令可以执行包中的单元测试。

通过 --parallel 参数可以并行执行单元测试,加快单元测试的执行速度。

通过 --filter 参数可以指定执行一部分单元测试。

swift test --parallel --filter ByteBufferTest

以上这些命令都是调用 libSwiftPM 这个库,libSwiftPM 是 Swift 开源项目的一部分,它提供了 Swift 包的核心能力。

包的 Manifest API(Package.swift)

上面的命令只是做了一些关于包的基本操作,那么如何指定包里具体有什么信息呢,就是通过 Manifest API,其实就是包中的一个 Package.swift 文件。

Package.swift 本身也是一个 Swift 源代码文件,它遵循 Swift 的语法,通过导入 PackageDescription 模块,并通过构造一个 Package 对象的方式,对包做了各种配置,包括生成产物、目标、依赖、环境等。由于其本身也是用 Swift 语言来写,对掌握了 Swift 的开发者非常友好,阅读和编写都非常方便。

下面看一个基本的 Package.swift

// swift-tools-version: 5.7

import PackageDescription

let package = Package(
    name: "BlogDemoPackage",
    products: [
        .library(
            name: "BlogDemoPackage",
            targets: ["BlogDemoPackage"]),
    ],
    dependencies: [
    ],
    targets: [
        .target(
            name: "BlogDemoPackage",
            dependencies: []),
        .testTarget(
            name: "BlogDemoPackageTests",
            dependencies: ["BlogDemoPackage"]),
    ]
)

最上方的是一行注释,它指定了可以编译这个包的最低 Swift 版本。

// swift-tools-version: 5.7

然后是一个导入语句,导入了这个 Manifest API 依赖的模块,可以看到这些 API 是通过 PackageDescription 这个模块提供的。

import PackageDescription

接下来是一个赋值语句:

let package = Package(
    name: "BlogDemoPackage",
    ...
)

这条语句创建了一个 Package 类型的对象赋值给 package 常量,name 参数指定了包的名称。这是 Manifest 的标准写法,只要这一条语句就可以完成包的所有配置。接下来我们一个一个的看一下别的参数,比较重要的包括 products、dependencies 和 targets。

products

products: [
    // 包提供了一个库产品
    .library(
        name: "BlogDemoPackage", // 指定库的名字
        type: .dynamic, // 指定库是静态库还是动态库,默认是静态库
        targets: ["BlogDemoPackage"]), // 指定库中包含了哪些 targets 的产物
],

products 参数定义了包的产物,它要求传入一个元素为 PackageDescription.Product 类型的数组。可以作为 Product 的有库、可执行程序和插件。Product 提供了若干个类方法来便捷地创建这些 Product 对象。

如上面的例子所示,使用 .library 这个 Product 类中的类方法来定义一个库产物,库包含了可以被其它代码导入的模块。name 指定了库的名称,type 是可选的,默认为 static,表示静态库,也可指定为 dynamic 动态库。targets 指定了要把哪些 target 的产物打包到库里。

一个包可以有多个 product,比如输出两个库产品,一个输出 Swift 库另一个输出纯 C 库。或者既能产生库也能产生可执行程序。

dependencies

dependencies: [
    .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.6.1"))
],

dependencies 指定了这个包依赖哪些其它包。每一个依赖包通过 .package 来执行,需要传入一个 url 参数,代表 git 地址,和一个 Range<Version> 类型的要求版本参数。

如果想使用一个第三方库,可以去 GitHub 上把库的 git 地址复制粘贴过来,再通过第二个参数制定版本就可以了,非常方便。

对于版本号,Swift 包严格遵守语义版本规范(Semantic Versioning).package 方法的第二个参数可以有以下几种取值方式:

  • upToNextMajor 代表大版本号不变,可以尽可能更新到最新版本。例如 .upToNextMajor(from: "5.6.1") 表示使用 5.6.1 开始到 5.xx.xx 任何最大数字版本,但是不包含 6.0.0。通常这是首选选项。
  • upToNextMinor 代表大版本号和中间版本号保持不变,小版本号可以尽可能更新。例如 .upToNextMinor(from: "5.6.1") 表示使用 5.6.1 开始到 5.6.x 的任何最大数字版本,但是不包含 5.7.0。如果是对这个库的使用比较保守,可以使用此项。
  • from 则表示从指定版本开始以后的任何版本都可以使用。
  • exact 则制定某个确定的版本,不会更新,在一些特殊的情况下会用到。

另外也可以通过 branch 来指定依赖某个分支,或通过 revision 来指定依赖某个具体的 commit,这两种只能在开发时使用,无法包含在公开发布的包中。

targets

targets: [
    .target(
        name: "BlogDemoPackage",
        dependencies: []),
    .testTarget(
        name: "BlogDemoPackageTests",
        dependencies: ["BlogDemoPackage"]),
]

target 是包中最基本的编译单元,每个 target 都会生成一个独立的编译产物。每个 target 都是一个模块或者单元测试套件。target 可以依赖所在包中的其它 target,也可以依赖所在包依赖的其它包中的 target。

有以下几种类型的 target:

  • .target 普通 target
  • .testTarget 单元测试
  • .executableTarget 可执行程序
  • .binaryTarget 二进制 target
  • .plugin 包插件

二进制包

头三个都已经很清楚了,.binaryTarget 代表这个 target 的产物是一个二进制,从 Xcode 11 开始苹果引入了 XCFramework,让 framework 可以已编译好的二进制形式提供,并支持多种架构。.binaryTarget 的用法如下:

targets: [
    .binaryTarget(
        name: "Emoji",
        url: "https://example.com/Emoji/Emoji-1.0.0.xcframework.zip",
        checksum: "6d988a1a27418674b4d7c31732f6d60e60734ceb11a0ce9b54d1871918d9c194"
    )
]

name 指定了 target 的名字,url 指定了 xcframework 文件的路径,checksum 是一个校验和,SwiftPM 会在下载 xcframework 后通过校验和来校验文件是否被篡改。

.plugin 代表这个 target 的产物是一个插件可以给 Xcode 工程或者其它包使用,关于包插件下面会有小节单独说明。

多平台支持

在 Package.swift 中可以通过 platforms 参数来配置当前的包支持哪些平台和平台版本。

platforms: [
    .macOS(.v10_15), iOS(.v13),
],

如果当前的工具链不支持某个版本的 API,也可以使用基于字符串的 API

.macOS("10.15"), iOS("13")

在代码中如果需要处理一些平台特定任务,可以利用 Swift 的条件编译:

#if os(Linux)
// 这里的代码只会在 Linux 上运行
#endif

#if canImport(Network)
// 这里的代码只会在 Network 模块可用时运行
#endif

在 Xcode 中使用包

从 Xcode 11 开始,Xcode 集成了一系列和包有关的 GUI 工具,可以方便的使用 Xcode 对包进行各种操作,这些 GUI 工具也是通过调用 libSwiftPM 来完成功能。

使用 SwiftPM 来导入第三方库

在一个现有的 App 项目中,可以通过点击 File -> Add Packages... 来添加依赖包。随后会打开一个对话框,将包的 git 地址粘贴到右上角的搜索框中,对话框会显示出包的信息,可以在右边配置要求的版本,和添加到哪个工程,最后点右下角的 Add Package 按钮即可完成添加。

添加包后,Xcode 会进行包的解析,解析成功后左边的导航栏目录树下方会出现一个 Swift Dependencies 区域,里面列出了所有依赖的包。

在这里插入图片描述

创建本地包

点击 Xcode 的 File -> New -> Package... 可以创建一个包,然后用 Xcode 编辑、编译、执行这个包或执行单元测试。

本地磁盘上的一个包,可以双击 Package.swift 文件,Xcode 会识别到这是一个包,然后打开整个包目录结构,非常方便。

如果想要发布包到 GitHub,可以在 Xcode 上登录 GitHub 账号,通过 Xcode 直接自动在 GitHub 下仓库并把包的内容提交上去。

通过本地包来管理工程结构

得益于和 Xcode 的高度集成和方便的配置,现在越来越多的工程会选择用本地包来管理项目结构。

通过本地包来将工程分成多个模块,因为可以通过 Package.swift 进行丰富的配置,比以前使用 target 或者使用多工程要好用的多。

比如需要开发一个 UI 无关的业务模块,在一个打开的工程中,点击 File -> New -> Package... 新建一个新的包添加到工程中。用本地包来分模块效果如下图:

在这里插入图片描述

编辑和调试远程包

我们会把自己的包放到 GitHub 上供大家分享,然后通过包依赖导入到我们的项目中。由于 Xcode 会自动管理依赖包,并锁定了这些包的文件,这些包无法被直接编辑。但是我们需要去开发和调试这些包。或者是某个第三方库可能有问题,需要修改代码并调试一下,这时可以通过本地包覆盖的方式来处理。

在工程中如果存在同名的本地包和远程包,Xcode 会优先使用本地包,这时远程包就被忽略了。

Xcode 怎样管理和更新依赖包的版本

当工程配置了依赖包后,Xcode 会对依赖图进行解析,下载并配置所需依赖包,并决定一个合适的版本。如果通过给定的依赖包的要求版本信息,找不到一个能满足所有依赖需求的版本,Xcode 在解析依赖包过程会报告一个错误,可以通过这个错误来查看时哪些包的版本发生了冲突,从而解决。

当依赖包解析成功后,Xcode 会在 project.xcworkspace/xcshareddata/swiftpm/Package.resolved 这个位置生成一个 Package.resolved 文件,文件中包含了当前使用的所有依赖包和使用的依赖包版本的信息,这个文件可以提交到 git 仓库中共团队成员共享,这样所有团队成员都会有一致的依赖包版本。

Package.resolved 由 Xcode 自动管理,不建议手动修改。

这个机制和 CocoaPods 的 Podfile.lock,和 Carthage 的 Cartfile.resolved 理念是一样的。

包集合

从 Swift 5.5 开始,SwiftPM 支持了包集合,就是可以将多个包打包成一个集合发布。通过集合可以有机会让包有更多的曝光机会,方便开发者找到合适的包,也可以方便教学者或布道者进行展示。

在 Xcode 中点击菜单 File -> Add Package...,在打开的对话框左侧,就是集合的列表,里面默认包含了一个苹果提供的集合名叫 Apple Swift Package,这个集合中包含了 swift-algorithms,swift-nio 等一系列包。可以点击左下角的加号按钮,选择 Add Package Collection 然后将某个集合的 json 文件地址粘贴进来,就可以添加集合。

苹果提供了一个工具 Swift Package Collection Generator 可以把多个包打包成一个集合,只要输入一个以下格式的 JSON 文件:

{
  "name": "WWDC21 Demo Collection",
  "overview": "Packages to be used in our demo app",
  "keywords": ["wwdc21"],
  "author": {
    "name": "Boris Buegling"
  }
  "packages": [
    { "url": "https://github.com/apple/swift-format" },
    { "url": "https://github.com/Alamofire/Alamofire" }
  ],
}

然后通过 package-collection-generate 工具,以及签名工具,可以生成一个 输出文件,Xcode 可以读取这个文件来加载集合和集合中的包。命令如下:

// 生成输出文件
package-collection-generate input.json collection.json
// 对输出文件进行签名
package-collection-sign collection.json collection-signed.json developer-key.pem developer-cert.cer

流程如下图:

在这里插入图片描述

Swift 包插件

从 Xcode 14 和 Swift 5.7 开始,Swift 包支持了一个新的产品:插件。插件是一个 Swift 脚本,可以运行在 Swift 包或 Xcode 工程上,插件可以用来简化和改进开发流程,做一些例如代码检查,生成源代码,自动化 release 任务等工作。

插件也是以 Swift 包的形式实现的,一个包可以包含库和可执行的同时包含插件,也可以只包含插件。插件的代码不会被带入到生产环境,只能在开发时执行。

插件可以扩展 Swift 包管理器的功能。和 Manifest API 类似,苹果也提供了一个 PackagePlugin 模块,包含可供插件调用的 API。

SwiftPM 目前支持以下两种类型的插件:

  • 构建工具插件(BuildToolPlugin),提供了在编译之前或者在编译中执行的命令。构建工具插件可以接收一些输入文件并产出一个或多个输出文件。
  • 命令插件(CommandPlugin),可以通过 Xcode 来执行,也可以通过 swift package 命令来执行。命令插件可以执行一些自定义的 Swift 脚本,也可以使用 Process 类来执行别的脚本和系统命令。

这两种插件的主要区别就是构建工具插件是在构建过程中自动执行了的,命令插件需要人为地主动触发。

每个插件都作为一个独立的进程运行,它被封装在一个沙盒中,阻止了插件进行网络请求或者写入文件系统的任意位置。

WWDC22 上通过一下两个视频介绍了插件和使用。

构建插件

构建插件是在构建过程中自动执行的插件,包含构建前执行和构建中执行两种,构建前执行的插件会在构建开始时执行,可以生成任意数量的名字不可在预测输出文件。

构建中插件就会被并入到构建系统的依赖图中,构建系统会根据预定义的输入输出以及时间戳来选择在合适的时机执行插件。

两种构建插件的主要区别在于他们可以产生的输出不同:

  • 构建前插件必须被用在输出是不可预测的情况。
  • 构建中插件需要指定一组输出文件。

构建插件以包的形式提供,包的 Package.swift 如下:

// swift-tools-version: 5.7

import PackageDescription

let package = Package(
    name: "SwiftLintPlugin",
    platforms: [.iOS(.v16), .macOS(.v10_13)],
    products: [
        .plugin(
            name: "SwiftLintPlugin",
            targets: ["SwiftLintPlugin"]
        )
    ],
    targets: [
        .plugin(
            name: "SwiftLintPlugin",
            capability: .buildTool()
        )
    ]
)

targets 和 products 中都有 .plugin 这个配置,表示存在一个 target 产物为一个插件,包的产品包含了这个 target 的插件产物。

在 targets 的配置中,通过 .plugin 配置一个插件产物,通过 name 指定插件的名称,通过 capability 指定为 .buildTool() 代表这是一个构建插件。

别的包想使用这个插件可以通过以下 Package.swift 配置:

let package = Package(
    name: "my-plugin-example",
    dependencies: [
        .package(url: "https://github.com/example/my-plugin-package.git", from: "1.0")
    ],
    targets: [
        .executableTarget(
            name: "MyExample",
            plugins: [
                .plugin(name: "MyBuildToolPlugin", package: "my-plugin-package")
            ]
        )
    ]
)

首先要依赖这个插件所在的包,然后在 target 中配置 plugins 参数并制定具体包中的插件,这样每次在包构建的时候,这个构建插件就会起作用。

编写插件的实现代码,需要在插件所在包的目录下新建一个 Plugins 目录,在 Plugins 目录中创建一个子目录,目录名字为插件的名称,然后在这个目录中放一个 plugin.swift 文件作为插件的入口点,目录结构如下:

在这里插入图片描述

在 plugin.swift 中,需要导入 PackagePlugin 模块,创建一个符合 BuildToolPlugin 协议的结构体,并标记为 @main

import Foundation
import PackagePlugin

@main
struct MyPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        // 插件开始代码
    }
}

命令插件

命令插件必须人为触发,可以通过在命令行执行上述命令触发,也可以通过 Xcode GUI 菜单触发,当工程中包含了含有命令插件的包,Xcode 成功解析后,会在工程的鼠标右键上下文菜单中添加一个插件的选项,可以通过点击这个选项来执行插件。

命令插件的 Package.swift 定义如下:

// swift-tools-version: 5.7

import PackageDescription

let package = Package(
    name: "CodeGeneratorPlugin",
    platforms: [.iOS(.v16)],
    products: [
        .plugin(
            name: "CodeGenerator",
            targets: ["CodeGenerator"]
        )
    ],
    dependencies: [],
    targets: [
        .plugin(
            name: "CodeGenerator",
            capability: .command(
                intent: .custom(
                    verb: "generate-code",
                    description: "Generates code"
                ),
                permissions: [
                    .writeToPackageDirectory(reason: "Generate Code")
                ]
            )
        )
    ]
)

和构建插件不同的地方在于 targets 中的 .plugin 的 capability 参数指定了这个插件是一个命令插件,命令插件需要 intent 和 permissions 两个参数,intent 代表插件存在的原因,构造 intent 需要一个 verb 和一个 description,这里的 vert 指定了这个命令插件在命令行中被调用时的名字,description 则是一个人类可读的描述。可以通过以下命令来执行插件:

swift package plugin <verb> [args...]

命令插件的入口点必须是一个符合了 CommandPlugin 协议的结构体,并实现了协议中的 performCommand 方法,插件的具体执行代码就是从 performCommand 方法开始运行。如下所示:

import Foundation
import PackagePlugin

@main
struct CodeGenerator: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        // ...
    }
}

列出当前可使用的所有插件

swift package plugin --list

总结

本文依次介绍了包的概念、包管理工具在命令行的使用、Manifest API、包管理工具在 Xcode 中的使用,包集合、插件等内容。由于 SwiftPM 的特性,他正变得越来越流行,我们在新项目中会优先采用 SwiftPM 作为包管理工具。如果在使用 SwiftPM 时遇到什么问题,欢迎留言讨论。

参考资料


SwiftFun
1 声望1 粉丝