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 Catalogcontext.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、CHANGELOG | Command 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
- Meet Swift Package plugins — Swift Package Plugin 的基础概念和入门使用
- Demystify parallelization in Xcode builds — 理解 Xcode 构建系统的并行调度,优化插件对构建速度的影响
- Create Swift Packages — WWDC19 的 Swift Package 创建入门,适合完全新接触 Package 的开发者
评论
GitHub Issues · utterances