一、什么是 Swift Package Plugin
-
诞生背景
- Xcode 11 支持 Swift Package 分发源码
- Xcode 14 把「插件」正式接入构建系统,允许在构建前/中/后执行自定义逻辑
-
官方定位
"一段 Swift 脚本,以独立进程+沙盒的方式对 Package 或 Xcode 工程做自动化任务。"
-
两种形态
(1) Command Plugin → 手动触发(Xcode 菜单 or
swift package plugin xxx
)(2) Build Tool Plugin → 自动嵌入构建图,随每次 build 触发,又分
- Pre-build:输出文件名运行时才能确定,无缓存,需自己实现增量
- In-build:输入/输出路径静态可知,Xcode 自动缓存
二、插件运行原理与权限模型
-
运行环境
- 独立 sandbox 进程,默认无网络、不可写源码目录
- 仅可写
context.pluginWorkDirectory
(临时目录,每次 build 会被清)
-
权限声明
Command Plugin 可在
Package.swift
里显式申请.writeablePackageDirectory(reason: "Format code")
.allowNetworkConnections(scope: .localhost(8080), reason: "Download schema")
Build Tool Plugin 不允许写源码与联网,只能生成中间文件再由编译链消费。
三、Command Plugin 快速回顾
功能:随时执行,常做格式化、报表、发版等一次性任务。
核心协议:CommandPlugin
(Package 场景)+ 可选 XcodeCommandPlugin
(Xcode 场景)。
代码模板:
swift
import PackagePlugin
@main
struct MyCommandPlugin: CommandPlugin {
/// 入口函数,参数来自命令行或 Xcode UI
func performCommand(context: PluginContext, arguments: [String]) async throws {
print("Hello, World!")
}
}
#if canImport(XcodeProjectPlugin) // 条件编译,仅在 Xcode 里生效
import XcodeProjectPlugin
extension MyCommandPlugin: XcodeCommandPlugin {
func performCommand(context: XcodePluginContext, arguments: [String]) throws {
print("Hello, World! (Xcode)")
}
}
#endif
四、Build Tool Plugin 深度拆解
4.1 生命周期
In-build 插件被集成到 Swift 驱动图:
→ 解析 Package.swift → 计划任务 → 执行插件 → 对比输入输出时间戳 → 决定复用 or 重跑
因此必须在 createBuildCommands
里返回完整的输入/输出文件列表,否则缓存失效。
4.2 协议总览
BuildToolPlugin
(Package)+ 可选 XcodeBuildToolPlugin
(Xcode)。
返回两类命令:
.buildCommand(...)
→ 普通命令,有输入输出,可缓存.prebuildCommand(...)
→ 预构建命令,输出目录由插件指定,构建系统不缓存
4.3 文件布局规范(官方约定)
arduino
PackageRoot/
├── Package.swift
├── Sources/
│ └── YourTarget/
└── Plugins/
└── YourPluginName/
└── YourPluginName.swift // 插件入口
五、实战:自动生成字体枚举(In-Build 版)
需求:把 Resources/Fonts/*.ttf/*.otf
变成
swift
enum AppFont: String {
case openSans = "OpenSans"
case robotoBold = "Roboto-Bold"
}
- 创建包
可以通过截图中的步骤创建plugin


也可以通过命令行创建
bash
mkdir FontEnum && cd FontEnum
swift package init --type library
- 声明插件与可执行产物
swift
// Package.swift
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "FontEnum",
products: [
.plugin(name: "FontEnumGenerator", targets: ["FontEnumGenerator"])
],
targets: [
.target(name: "FontEnum"), // 业务库,可空
.executableTarget(name: "FontEnumGeneratorExc"), // 真正干活的 CLI
.plugin(
name: "FontEnumGenerator",
capability: .buildTool(),
dependencies: ["FontEnumGeneratorExc"] // 插件依赖可执行产物
)
]
)
- 插件入口
swift
// Plugins/FontEnumGenerator/FontEnumGenerator.swift
import PackagePlugin
import class Foundation.FileManager
import struct Foundation.URL
enum PluginError: Error {
case noSource
case noFonts
}
@main
struct FontEnumGenerator: BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
// 过滤字体
guard let sourceModule = target.sourceModule else { throw PluginError.noSource }
let sourceFiles = sourceModule.sourceFiles
// 找到 CLI 工具
let targetName = target.name
let generatorTool = try context.tool(named: "FontEnumGeneratorExc")
let fontsCommand = try createFontsBuildCommand(for: sourceFiles,
tool: generatorTool,
targetName: targetName,
pluginWorkDirectoryURL: context.pluginWorkDirectoryURL)
return [fontsCommand]
}
}
#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin
extension FontEnumGenerator: XcodeBuildToolPlugin {
func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
let sourceFiles = target.inputFiles
// 找到 CLI 工具
let targetName = target.displayName
let generatorTool = try context.tool(named: "FontEnumGeneratorExc")
let fontsCommand = try createFontsBuildCommand(for: sourceFiles,
tool: generatorTool,
targetName: targetName,
pluginWorkDirectoryURL: context.pluginWorkDirectoryURL)
return [fontsCommand]
}
}
#endif
extension FontEnumGenerator {
func createFontsBuildCommand(for sourceFiles: PackagePlugin.FileList ,
tool: PackagePlugin.PluginContext.Tool,
targetName: String,
pluginWorkDirectoryURL:URL) throws -> Command {
// 过滤字体
let fonts = sourceFiles
.filter { $0.url.pathExtension == "ttf" || $0.url.pathExtension == "otf" }
.map(\.url)
if fonts.isEmpty {
Diagnostics.error("❌ 找不到任何字体文件")
throw PluginError.noFonts
}
// 找到 CLI 工具
let generatorTool = tool
// 3. 准备输出目录(必须在 sandbox 内)
let outDir = pluginWorkDirectoryURL
.appending(path: targetName)
.appending(path: "Generated")
try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
let outFile = outDir.appending(path: "\(targetName)GeneratedFonts.swift")
// 4. 构造 buildCommand
return .buildCommand(
displayName: "Generating Font enum for \(targetName)",
executable: generatorTool.url,
arguments: [outFile.path()] + fonts.map(\.path),
inputFiles: fonts,
outputFiles: [outFile]
)
}
}
- 可执行工具(真正生成代码)
swift
// Sources/FontEnumGeneratorExc/FontEnumGeneratorExc.swift
import struct Foundation.URL
@main
struct FontEnumGeneratorExc {
static func main() throws {
// 参数:output 路径 + 任意数量字体路径
let args = CommandLine.arguments.dropFirst()
guard args.count >= 2 else { throw Err.invalidArgs }
// 第一个是输出路径
let outPath = args.first!
// 第二个是所有的字体
let fontPaths = args.dropFirst()
// 生成 case 语句
let cases = fontPaths.map { path -> String in
// 取去掉后缀的文件名
let name = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
// 将短横和空格替换成 空字符串,最后小写
let caseName = name.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: " ", with: "")
.lowercased()
return "case \(caseName) = \"\(name)\""
}
let content = """
import UIKit
enum AppFont: String {
\(cases.joined(separator: "\n"))
}
"""
try content.write(toFile: outPath, atomically: true, encoding: .utf8)
}
enum Err: Error { case invalidArgs }
}
- 把字体丢到 工程中的任意文件夹

- Xcode 工程:
Package Dependencies
→ 添加本地 FontEnum


- Target →
Build Phases
→Run Build Tool Plug-Ins
→ 勾选 FontEnumGenerator


- 编译后自动生成
<pluginWorkDirectory>/YourTargetGeneratedFonts.swift


默认被加入编译列表,直接可用:
swift
Text("Hello, world!")
.font(Font.custom(AppFont.hacknerdfontmonobold.rawValue, size: 23))
六、容易踩的坑
-
插件返回的
outputFiles
必须真实生成,否则下次增量会误判 -
可执行产物名与
context.tool(named:)
必须一致,大小写敏感 -
Pre-build 插件不要返回空输出目录,否则 Xcode 15+ 会报 "missing output directory"
-
沙盒外写文件会被直接拒绝,报错 "Operation not permitted"
-
若字体放在
Resources/Fonts
,务必在Package.swift
标注.target(name: "YourTarget", resources: [.process("Resources")])
七、总结与可扩展场景
- 插件本质 = "用 Swift 写构建脚本",但享有官方缓存、沙盒、跨平台红利。
- 字体枚举只是冰山一角,任何"输入确定→输出确定"的代码都能用 In-Build 插件:
- 根据
proto
文件生成 Swift 模型(类似protoc
) - 把
*.lottie
转成Data
扩展,避免主 bundle 臃肿 - 扫描
Localizable.strings
生成类型安全的L10n
枚举 - 基于 Figma API 拉取最新色板,输出
UIColor
扩展
- 根据
- Command Plugin 更适合一次性/运维场景:
- 自动给版本号打 tag 并 push changelog
- 基于
SwiftLint --fix
做全库格式化且直接回写 Git
- 未来展望
- Swift 6 有望把宏(Macro)与插件合并成统一"编译期代码生成"方案
- 如果 Apple 开放更多沙盒权限(只读网络?),CI/CD 脚本会进一步下沉到 Package 内部