WWDC Quick Look 💓 By SwiftGGTeam
Create Swift Package plugins

Create Swift Package plugins

观看原视频

Highlight

Xcode 14 引入 Swift Package Plugin,让开发者用 Swift 代码扩展构建流程:自定义命令插件自动化发布任务,构建工具插件在编译前后生成源代码或资源文件,全部通过 PackagePlugin API 实现。

核心内容

从脚本到插件:为什么需要这个新工具

你的项目里可能有一个 shell 脚本,每次发版前手动跑一遍:生成版本号、提取 Git 贡献者列表、格式化代码。这些脚本散落在各个仓库里,新成员不知道怎么用,CI 环境和本地环境行为不一致。

Swift Package Plugin 把这些工作流也纳入 Swift Package 的体系。插件和库一样,可以版本管理、可以依赖、可以分享。更重要的是,插件直接集成在 Xcode 的右键菜单和 SwiftPM 的命令行里,不需要记住一堆脚本路径。

两种插件类型

自定义命令插件(Command Plugin):由用户手动触发。适合一次性任务,比如生成 CONTRIBUTORS 文件、打标签、发布到 CocoaPods。触发方式有两种:Xcode 项目导航栏右键菜单,或 swift package 命令行。

构建工具插件(Build Tool Plugin):自动在构建过程中运行。适合代码生成、资源处理。又分两种:

  • In-build command:有明确的输入和输出文件。构建系统通过对比输入输出的修改时间判断是否需要重新执行。适合代码生成场景。
  • Pre-build command:没有明确输出,每次构建都执行。适合提取本地化字符串等任务。要谨慎使用,避免拖慢构建速度。

沙盒与权限

插件运行在沙盒中,默认禁止网络访问,只能写入临时目录。如果插件需要写入包根目录(比如生成 CONTRIBUTORS.txt),必须在 manifest 中声明权限并给出理由。用户首次运行时会看到权限请求对话框,类似 macOS 的系统权限提示。

详细内容

创建自定义命令插件:生成贡献者列表

03:40

Package.swift 中声明插件:

// swift-tools-version: 5.6

import PackageDescription

let package = Package(
    name: "MyPackage",
    products: [...],
    targets: [
        .plugin(
            name: "GenerateContributors",
            capability: .command(
                intent: .custom(
                    verb: "regenerate-contributors-list",
                    description: "Generates the CONTRIBUTORS.txt file based on Git logs"
                ),
                permissions: [
                    .writeToPackageDirectory(reason: "This command writes the new CONTRIBUTORS.txt to the source root.")
                ]
            )
        ),
    ]
)

关键点:

  • swift-tools-version 至少 5.6,插件从这个版本开始支持
  • .plugin() 定义插件 target,对应 Plugins/ 目录下的子文件夹
  • capability: .command() 表示自定义命令插件
  • intent: .custom(verb:description:) 定义 SwiftPM 命令行动词和描述
  • permissions 声明需要的权限,.writeToPackageDirectory 允许写入包根目录
  • reason 会显示给用户,决定用户是否授权

插件实现代码(Plugins/GenerateContributors/plugin.swift):

05:06

import PackagePlugin
import Foundation

@main
struct GenerateContributors: CommandPlugin {

    func performCommand(
        context: PluginContext,
        arguments: [String]
    ) async throws {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
        process.arguments = ["log", "--pretty=format:- %an <%ae>%n"]

        let outputPipe = Pipe()
        process.standardOutput = outputPipe
        try process.run()
        process.waitUntilExit()

        let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
        let output = String(decoding: outputData, as: UTF8.self)

        let contributors = Set(output.components(separatedBy: CharacterSet.newlines))
            .sorted()
            .filter { !$0.isEmpty }

        try contributors.joined(separator: "\n")
            .write(toFile: "CONTRIBUTORS.txt", atomically: true, encoding: .utf8)
    }
}

关键点:

  • @main 标记入口点,插件编译成独立可执行文件
  • 实现 CommandPlugin 协议的 performCommand(context:arguments:) 方法
  • context 提供包图信息和插件工作目录
  • arguments 接收用户传入的命令行参数
  • Foundation.Process 调用外部命令(如 Git)
  • 插件执行时的工作目录是包根目录,直接写文件即可

运行方式:

# 命令行
swift package regenerate-contributors-list

# 或在 Xcode 中右键点击 Package,选择插件命令

创建 In-build 插件:从 Asset Catalog 生成类型安全常量

14:56

这个插件需要一个辅助可执行文件来生成代码。结构分为两部分:可执行文件做实际工作,插件把可执行文件接入构建系统。

可执行文件(AssetConstantsExec/main.swift):

15:03

import Foundation

let arguments = ProcessInfo().arguments
if arguments.count < 3 {
    print("missing arguments")
    exit(1)
}
let (input, output) = (arguments[1], arguments[2])

struct Contents: Decodable {
    let images: [Image]
}

struct Image: Decodable {
    let filename: String?
}

var generatedCode = """
    import Foundation
    import SwiftUI

    """

try FileManager.default.contentsOfDirectory(atPath: input).forEach { dirent in
    guard dirent.hasSuffix("imageset") else {
        return
    }

    let contentsJsonURL = URL(fileURLWithPath: "\(input)/\(dirent)/Contents.json")
    let jsonData = try Data(contentsOf: contentsJsonURL)
    let assetCatalogContents = try JSONDecoder().decode(Contents.self, from: jsonData)
    let hasImage = assetCatalogContents.images.filter { $0.filename != nil }.isEmpty == false

    if hasImage {
        let basename = contentsJsonURL.deletingLastPathComponent().deletingPathExtension().lastPathComponent
        generatedCode.append("public let \(basename) = Image(\"\(basename)\", bundle: .module)\n")
    }
}

try generatedCode.write(to: URL(fileURLWithPath: output), atomically: true, encoding: .utf8)

关键点:

  • 可执行文件接收两个参数:Asset Catalog 路径和输出文件路径
  • 遍历 .imageset 目录,解析 Contents.json 判断是否有实际图片
  • 为每个有效图片生成 public let 常量,类型为 SwiftUI.Image
  • bundle: .module 从包的资源 bundle 加载图片

Package.swift 声明:

15:48

.executableTarget(name: "AssetConstantsExec"),
.plugin(
    name: "AssetConstants",
    capability: .buildTool(),
    dependencies: ["AssetConstantsExec"]
),

插件实现:

16:12

import PackagePlugin

@main
struct AssetConstants: BuildToolPlugin {

    func createBuildCommands(
        context: PluginContext,
        target: Target
    ) async throws -> [Command] {
        guard let target = target as? SourceModuleTarget else {
            return []
        }

        return try target.sourceFiles(withSuffix: "xcassets").map { assetCatalog in
            let base = assetCatalog.path.stem
            let input = assetCatalog.path
            let output = context.pluginWorkDirectory.appending(["\(base).swift"])

            return .buildCommand(
                displayName: "Generating constants for \(base)",
                executable: try context.tool(named: "AssetConstantsExec").path,
                arguments: [input.string, output.string],
                inputFiles: [input],
                outputFiles: [output]
            )
        }
    }
}

关键点:

  • 实现 BuildToolPlugin 协议的 createBuildCommands(context:target:) 方法
  • target.sourceFiles(withSuffix: "xcassets") 查找目标中的所有 Asset Catalog
  • context.pluginWorkDirectory 是插件的临时工作目录,生成的文件放这里
  • context.tool(named:) 查找插件依赖的可执行文件路径
  • .buildCommand() 声明 in-build 命令,指定输入输出文件供构建系统追踪
  • 输出文件会自动参与目标编译

使用插件的库 target:

.target(
    name: "IconLibrary",
    dependencies: [],
    plugins: ["AssetConstants"]
),

创建 Pre-build 插件:自动提取本地化字符串

20:19

这个插件作为独立包发布,可以被其他包依赖使用。

Package.swift:

// swift-tools-version: 5.6

import PackageDescription

let package = Package(
    name: "GenstringsPlugin",
    products: [
        .plugin(name: "GenstringsPlugin", targets: ["GenstringsPlugin"]),
    ],
    targets: [
        .plugin(
            name: "GenstringsPlugin",
            capability: .buildTool()
        ),
    ]
)

关键点:

  • .plugin(name:targets:) 产品声明让插件可被外部包依赖
  • 没有 dependencies 的可执行文件,直接调用系统工具 xcrun genstrings

插件实现:

20:44

import PackagePlugin

@main
struct GenstringsPlugin: BuildToolPlugin {

    func createBuildCommands(
        context: PluginContext,
        target: Target
    ) async throws -> [Command] {
        guard let target = target as? SourceModuleTarget else {
            return []
        }

        let resourcesDirectoryPath = context.pluginWorkDirectory
            .appending(subpath: target.name)
            .appending(subpath: "Resources")
        let localizationDirectoryPath = resourcesDirectoryPath
            .appending(subpath: "Base.lproj")

        try FileManager.default.createDirectory(
            atPath: localizationDirectoryPath.string,
            withIntermediateDirectories: true
        )

        let swiftSourceFiles = target.sourceFiles(withSuffix: ".swift")
        let inputFiles = swiftSourceFiles.map(\.path)

        return [
            .prebuildCommand(
                displayName: "Generating localized strings from source files",
                executable: .init("/usr/bin/xcrun"),
                arguments: [
                    "genstrings",
                    "-SwiftUI",
                    "-o", localizationDirectoryPath.string
                ] + inputFiles.map(\.string),
                outputFilesDirectory: localizationDirectoryPath
            )
        ]
    }
}

关键点:

  • .prebuildCommand() 声明 pre-build 命令,每次构建都执行
  • 调用系统 xcrun genstrings 工具提取 NSLocalizedString 调用
  • -SwiftUI 参数支持 SwiftUI 的字符串本地化语法
  • outputFilesDirectory 指定输出目录,不声明具体输出文件列表
  • 生成的 .strings 文件自动加入目标的资源 bundle

外部包使用这个插件:

21:44

// Package.swift
dependencies: [
    .package(path: "../GenstringsPlugin"),
],
targets: [
    .target(
        name: "MyLibrary",
        dependencies: [],
        plugins: [
            .plugin(name: "GenstringsPlugin", package: "GenstringsPlugin"),
        ]
    ),
]

插件类型选择决策

场景插件类型原因
生成 CONTRIBUTORS、CHANGELOGCommand Plugin手动触发,不需要每次构建都跑
从 Proto/GraphQL 生成 Swift 代码In-build Build Tool输入输出明确,构建系统能判断是否需要重新生成
提取本地化字符串Pre-build Build Tool输出不固定,每次构建都要扫描源文件
代码格式化检查Command Plugin开发者主动触发,不适合自动修改构建产物

核心启发

1. 为团队构建代码生成工具链

  • 做什么:把项目中的 Proto、GraphQL、OpenAPI 等代码生成步骤从 Makefile 迁移到 Swift Package Plugin
  • 为什么值得做:插件和项目代码一起版本管理,新成员 clone 仓库后不需要额外安装工具链,Xcode 构建时自动生成代码
  • 怎么开始:把现有的代码生成逻辑封装成可执行 target,写一个 BuildToolPlugin 在 createBuildCommands 中调用它,声明输入的 schema 文件和输出的 Swift 文件路径

2. 自动化版本号和发布流程

  • 做什么:创建一个 Command Plugin,一键完成版本号更新、CHANGELOG 生成、Git 标签打标
  • 为什么值得做:发布流程标准化,减少人为失误。插件可以调用 Process 执行 Git 命令,用 writeToPackageDirectory 权限修改版本文件
  • 怎么开始:实现 CommandPlugin,在 performCommand 中读取 Git 标签历史、生成 CHANGELOG、更新 Package.swift 或 plist 中的版本号、执行 git tag

3. 类型安全的资源引用

  • 做什么:为项目中的图片、颜色、字体等资源生成编译期常量,替代字符串硬编码
  • 为什么值得做:拼写错误在编译期就能发现,Xcode 自动补全资源名,重构时重命名符号即可
  • 怎么开始:参考 Session 中的 AssetConstants 示例,遍历 Asset Catalog 或资源目录,生成 public let 常量。in-build 命令让构建系统只在资源变化时重新生成

4. 本地化工作流自动化

  • 做什么:在每次构建时自动扫描代码中的新字符串,更新 .strings 模板文件
  • 为什么值得做:开发者添加新文案后不需要手动跑 genstrings,CI 构建时自动检查是否有未本地化的字符串
  • 怎么开始:基于 Session 中的 GenstringsPlugin 扩展,添加字符串缺失检查逻辑,或在 pre-build 命令后加一步验证

关联 Session

评论

GitHub Issues · utterances