Swift Package Plugin 深度实战:从原理到落地,自动生成字体枚举

一、什么是 Swift Package Plugin

  1. 诞生背景

    • Xcode 11 支持 Swift Package 分发源码
    • Xcode 14 把「插件」正式接入构建系统,允许在构建前/中/后执行自定义逻辑
  2. 官方定位

    "一段 Swift 脚本,以独立进程+沙盒的方式对 Package 或 Xcode 工程做自动化任务。"

  3. 两种形态

    (1) Command Plugin → 手动触发(Xcode 菜单 or swift package plugin xxx

    (2) Build Tool Plugin → 自动嵌入构建图,随每次 build 触发,又分

    • Pre-build:输出文件名运行时才能确定,无缓存,需自己实现增量
    • In-build:输入/输出路径静态可知,Xcode 自动缓存

二、插件运行原理与权限模型

  1. 运行环境

    • 独立 sandbox 进程,默认无网络、不可写源码目录
    • 仅可写 context.pluginWorkDirectory(临时目录,每次 build 会被清)
  2. 权限声明

    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"
}
  1. 创建包

可以通过截图中的步骤创建plugin

也可以通过命令行创建

bash 复制代码
mkdir FontEnum && cd FontEnum
swift package init --type library
  1. 声明插件与可执行产物
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"] // 插件依赖可执行产物
        )
    ]
)
  1. 插件入口
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]
        )
    }
    
}
  1. 可执行工具(真正生成代码)
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 }
}
  1. 把字体丢到 工程中的任意文件夹
  1. Xcode 工程:
    • Package Dependencies → 添加本地 FontEnum
  • Target → Build PhasesRun Build Tool Plug-Ins → 勾选 FontEnumGenerator
  1. 编译后自动生成 <pluginWorkDirectory>/YourTargetGeneratedFonts.swift

默认被加入编译列表,直接可用:

swift 复制代码
Text("Hello, world!")
                .font(Font.custom(AppFont.hacknerdfontmonobold.rawValue, size: 23))

六、容易踩的坑

  1. 插件返回的 outputFiles 必须真实生成,否则下次增量会误判

  2. 可执行产物名与 context.tool(named:) 必须一致,大小写敏感

  3. Pre-build 插件不要返回空输出目录,否则 Xcode 15+ 会报 "missing output directory"

  4. 沙盒外写文件会被直接拒绝,报错 "Operation not permitted"

  5. 若字体放在 Resources/Fonts,务必在 Package.swift 标注

    .target(name: "YourTarget", resources: [.process("Resources")])

七、总结与可扩展场景

  1. 插件本质 = "用 Swift 写构建脚本",但享有官方缓存、沙盒、跨平台红利。
  2. 字体枚举只是冰山一角,任何"输入确定→输出确定"的代码都能用 In-Build 插件:
    • 根据 proto 文件生成 Swift 模型(类似 protoc
    • *.lottie 转成 Data 扩展,避免主 bundle 臃肿
    • 扫描 Localizable.strings 生成类型安全的 L10n 枚举
    • 基于 Figma API 拉取最新色板,输出 UIColor 扩展
  3. Command Plugin 更适合一次性/运维场景:
    • 自动给版本号打 tag 并 push changelog
    • 基于 SwiftLint --fix 做全库格式化且直接回写 Git
  4. 未来展望
    • Swift 6 有望把宏(Macro)与插件合并成统一"编译期代码生成"方案
    • 如果 Apple 开放更多沙盒权限(只读网络?),CI/CD 脚本会进一步下沉到 Package 内部
相关推荐
低调小一5 小时前
Swift 语法学习指南 - 与 Kotlin 对比
微信·kotlin·swift
东坡肘子13 小时前
从开放平台到受控生态:谷歌宣布 Android 开发者验证政策 | 肘子的 Swift 周报 #0101
android·swiftui·swift
HarderCoder13 小时前
用 `defer` 管理异步清理:Swift 中的“保险丝”模式
swift
大熊猫侯佩1 天前
冰火岛 Tech 传:Apple Foundation Models 心法解密(上集)
llm·ai编程·swift
HarderCoder1 天前
深入理解 SwiftUI 的 Structural Identity:为什么“换个条件分支”就会丢状态?
swiftui·swift
HarderCoder1 天前
Swift Continuations 完全指南:一口气弄懂 4 种“桥梁”
swift
HarderCoder1 天前
Swift 的 `withoutActuallyEscaping`:借一个 `@escaping` 身份,但不真的逃跑
swift
Swift社区1 天前
Swift 解法详解:LeetCode 371《两整数之和》
开发语言·leetcode·swift
Swift社区1 天前
Swift 解法详解 LeetCode 362:敲击计数器,让数据统计更高效
开发语言·leetcode·swift