大多数浏览器和
Developer App 均支持流媒体播放。
-
构建 Swift 软件包插件
定制您的开发工作流程,并学习如何使用 Swift 编写软件包插件。我们将介绍如何通过使用 PackagePlugin API 生成源代码或对任务发布进行自动化,从而扩展 Xcode 的功能;此外,我们还将分享有关构建优质插件的最佳实践。
资源
相关视频
WWDC22
WWDC21
WWDC20
WWDC19
-
下载
Boris:嗨 我是 Boris 欢迎来到“创建 Swift 软件包插件”讲座
我们在 Xcode 11 中 引入了对 Swift 软件包的支持 提供一种以源代码形式 直接发布软件库的方法 在 Xcode 14 中 我们希望将这种架构和分享组件的 出色方式带入开发工作流中 例如通过 Swift 软件包插件生成源代码 或自动发布任务 首先 我将简单概述今天的讲座内容 在了解插件的基础信息后 我们将演示如何创建第一个 自定义命令插件 接下来 我们将了解 更多关于创建插件的细节 随后继续演示如何创建构建中 和构建前命令插件
软件包插件是一种使用 PackagePlugin API 的 类似于 软件包清单的 Swift 代码 插件可以通过定义明确的扩展点 扩展 Xcode 或 Swift 软件包管理器的功能
软件包插件是如何运行的呢? Xcode 将编译和运行您的插件 能够使用相关可用可执行文件 和输入文件的信息来创建命令 它将这些命令返回 Xcode 根据需要执行命令
软件包插件可以 帮助完成构建前或构建中 运行的自定义构建任务 比如可以生成源代码或资源文件
它们也可以新增自定义命令 到 SwiftPM 的命令行界面 或将菜单项添加到 Xcode 如想了解更多关于插件的基础信息 我推荐您先观看 “认识 Swift 软件包插件”讲座 如果您此前对软件包没有任何了解 可以观看 WWDC19 的 “创建 Swift 软件包”讲座
我们来看看如何创建 第一个自定义命令插件
我正在 Swift 开放源码中 参与核心支持工具软件包的工作 我想要新增一个 列出项目所有贡献者的文本文件 我还想根据需要从软件包的 Git 历史中重新生成它
从前 我可能会 写一个 shell 脚本 或 makefile 但现在我要创建一个自定义命令插件 让我不需要离开 Xcode 就能重新生成文件
首先 我们必须为插件创建目录架构 打开软件包的上下文菜单 选择“新文件夹” 来创建名为 “Plugins” 的顶层文件夹 与现有的源码与测试文件夹类似
接下来 我们要为插件目标创建 另一个嵌套文件夹 命名为 “GenerateContributors”
在这个文件夹里创建一个新文件 命名为 “plugin.swift”
接下来 我们要对 软件包清单进行一些更改 来声明我们的新目标 但首先 我们必须将 软件包工具版本升到 5.6 因为插件从这一版本开始才可用
接下来就可以插入插件目标了
我们来看一看新的清单 API
我们将创建一个对应于 “Plugins” 文件夹中 某一文件夹的插件目标 与源码模块的目标类似
它得到一个既与命名文件夹有关 又是 Xcode 中菜单项的名称
我们需要指定功能 也就是我们想用什么类型的扩展点 在这个案例中 我们是在创建自定义命令
intent 可以为 SwiftPM 命令行定义一个动词 以及描述该插件的作用 最后 我们可以声明 该插件需要的权限
在这个案例中 我们要将 新文件写到软件包的根目录 所以需要写入该目录的权限 reason 字符串会显示给插件使用者 让他们知道是否已获得权限 和 OS 自身权限的管理方式类似 声明了插件之后 让我们返回进行实施
插件会运行调用 Git 获取提交历史 它会从外部 Git 命令的 标准输出中读取历史 并解析结果 最后将其写出为文本文件
我们需要打开之前创建的插件源文件 导入 PackagePlugin
这是一个内置模块 和 PackageDescription 很像 可以获取实施插件 需要用到的 API
我们来定义一个结构体 GenerateContributors 使其符合 CommandPlugin
接受这里的修订 来获取实施协议所缺的代码片段 我们还需要将 结构体标记为 @main 因为它将是插件 可执行文件的 main 函数
performCommand 是我们命令的进入点 我们会收到两个参数 一个是 context 它让我们 能够获取经过解析的软件包图 和其他关于执行的上下文信息 包括参数 由于是用户发起了自定义命令 用户可以用参数形式提供输入信息 我们来创建一个简单命令 在这个时候不向用户 提供任何选择
因为我们要运行调用 Git 获取关于提交历史的信息 就要导入 Foundation 因为我们需要用到 Process API
接下来 我们将定义一个进程实例 并通过一些格式化参数 使它执行 git log
我们需要创建管道来捕获进程输出值 然后就可以运行并等待它退出
这个过程完成后 我们从管道中读取所有数据 并将它转化为字符串 其中包含所有版本记录输出值
我们可以对字符串进行操作 将输出数据修整为无重复值的列表 最后 我们就可以将它写成文件 命名为 “CONTRIBUTORS.txt” 因为自定义命令是在 软件包的根目录中执行的 我们也将文件存储在那里
现在 如果我们先保存 然后在项目导航器中 右键点击软件包 上下文菜单中就会出现 一条对应我们命令的项目 来执行它吧!
在接下来的对话框中 我们可以选择应为 插件输入项的软件包或目标 以及任何参数 但由于我们的插件并不回应这些选项 我们可以点按“运行”
接下来 我们会被要求授权 因为之前在清单中定义的就是这样 由于插件是我们刚才自己写的 可以直接运行 但请确保您只向 信任的插件进行额外授权
运行后 CONTRIBUTORS.txt 文件出现在项目导航器中
在使用第一个插件 扩展了 Xcode 之后 让我们更深入地探讨插件的运作方式 以及创建插件时的注意事项
软件包插件在沙盒中运行 和软件包清单本身的解释运行类似 网络连接 以及写入 除插件自身运行目录之外的 非临时位置都将被禁止 像之前演示的那样 自定义命令可以 选择性声明是否写入软件包根目录 如果您要包装已有的第三方工具 必须考虑如何将其限制在沙盒模式中 比如说 可以通过配置 生成文件的写入位置来实现
我在介绍中谈到了不同类型的插件 一个问题是由自定义命令 还是构建工具来解决更好 这一点应该已经清楚了 但我们还是来看一看 构建工具插件的结构
这些插件允许您可以通过 描述一次构建中可运行 哪些可执行文件 并指定其输入和输出内容 来扩展构建系统 这可以帮助您在 构建期间合适的时机规划任务
如果您在 Xcode 项目中 创建过 run script phase 就应该对这部分涉及的 基础内容很熟悉了
构建工具插件分为两种不同类型 区分要点在于您的 工具是否定义了的输出集
如果有 您就应该创建构建中命令 如果输出与输入相比已经过时 它将由构建系统自动重新运行 如果您没有明确的输出集 可以创建构建前命令 它将在每次构建开始时运行 正因为如此 您应当 避免在构建前命令中 进行代价高昂的工作 或想一个适合您用例的 自定义策略来缓存结果
在第二项演示中 我打算创建一个新软件库 其中包含了我想在 手头不同的工具中共享的图标
首先通过模板创建新的软件包 命名为 “IconLibrary” 我现在要把一些已有的图标资产 拖到我软件库的目标中 让我们再给软件库加上 基础 SwiftUI 视图和预览 首先 我们要将必需的 最低部署目标添加到清单
接下来 我们来添加基础视图和预览 在这里我们可以使用之前拖入的资产
我觉得 比较理想的做法 是用一种类型安全的方式 来引用这些图片 而不是非得在这里处理字符串 这似乎是构建中 命令插件的绝佳使用案例 因为这一插件考虑资产目录 并以此为基础生成 Swift 代码 让我们在 Finder 中 看一看资产目录 来了解我们应该 如何提取插件需要的信息
每张图片都有自己的图集目录 包含资产名称
还有一个用于描述 基础内容的 JSON 文件
构建中命令 与自定义命令的运行方式有所不同 它们除了提供用于 运行的可执行文件描述 还有提供输入和输出
可执行文件可以由系统或 第三方软件包提供 您也可以专门为插件定制可执行文件 我们选择第三种途径
插件在构建过程开始时运行 以参与构件图的计算
以此为基础 可执行文件 作为构建执行的一部分进行排期
现在回到我们构建的可执行文件 我们要资产目录中的每张图 都有一个编译期常量 这样一来就不需要 记住每张图片的正确字符串 而是可以让它们 作为 Swift 符号自动完成
我们将循环遍历资产目录的内容 来找到所有的图集 我们将解析每个图集的元数据来确定 它是否真的包含图片 以及是否应当为其生成代码
然后我们就可以 生成代码并写入到一个文件 由于我们声明这些文件 为插件的输出内容 它们将自动并入插件 所应用的目标的构建之中
我们需要想办法处理参数 因为这是插件和 可执行文件沟通的方式
第一个参数是指向我们 正在处理的资产目录的路径 第二个是插件为 生成的代码提供的路径
接下来 我们需要模型对象来 对 contents.json 文件进行解码
可以使用 Decodable 来充分 利用 Swift 内置的 JSON 解码功能
我们唯一感兴趣的信息是图像列表 和它们的文件名 但这是可选的 因为可能不是每一种 像素密度都有对应的图像 这里我们以一种极其 简化的方式来生成代码 只需要建立一个字符串 我们首先导入需要的框架 Foundation 和 SwiftUI
我们要循环遍历资产目录的 全部内容 来找到所有的图集 下一步是解析 JSON 文件名使用了输入参数 我们使用 Foundation 的 JSONDecoder API 进行解码
我们感兴趣的主要信息是 是否存在针对 给定图集进行定义的图像 我们可以通过检查是否至少有一个 图像拥有非空的文件名来确认 如果给定图集中有图像 我们就需要生成 SwiftUI 图像 从软件包中载入该图像
实现的方式是通过每个图片 的 basename 创建字符串 从模块包中载入相应的图片 此模块包就是构建系统 为每个带资源的软件包 创建的资源包
我们可以通过将生成的代码 写入文件 为可执行文件的工作收尾 以参数所指定的方式
我们回到 Xcode 中 创建可执行文件
将它命名为 “AssetConstantsExec”
并添加主文件
现在我们要在软件包清单中声明它
我们可以将刚才讨论的 代码添加到它的主文件中
现在有了可以生成代码的可执行文件 我们可以通过插件将它带入构建系统
我们从软件库目标中添加必需的目标 以及插件的使用方法
像之前一样 我们导入 PackagePlugin 软件库 并创建结构体 这一次要使它符合 BuildTool 插件协议
入口点看上去很类似 但这里我们得到的是一个目标 而非用户参数 这就是插件应用的目标 每个使用给定插件的目标 都会对入口点调用一次
这个插件会尤其关注源模块目标 指那些实际携带源文件的目标 区别于与其他目标 比如说二进制目标 为了创建构建命令的数组 我们需要循环遍历 目标中所有 xcasset 包 我们要为之后要显示在构建日志中的 显示名称提取字符串 并建立合适的输入和输出路径 我们也可以使用插件 API 来查找可执行文件 然后将构建命令合在一起
这样 我们就准备好 再次构建这个项目了 我们可以通过构建日志 查看新的构建步骤 看看发生了什么
插件在构建开始时编译和运行 从那里 它将生成的所有 命令都添加到构建图中
再来看看目标 我们的新构建命令已经运行了
最后 生成的源文件作为 Swift 编译文件的一部分出现
现在回到预览 在这里我们可以用新常量 来替代字符串类型的图像创建
其他图像名称也自动补完了
很好 我们几乎没用什么代码 只使用熟悉的 Swift API 没有离开 Xcode 就改善了工作流
到这里 我们已经探讨了如何将插件 作为我们 已有软件库的一部分为自己所用 但插件的另一项强大的属性是 我们可以像软件库那样 直接共享它们
在下一个演示中 我将展示如何 使用随 Xcode 发布的 genstrings 工具 自动化某些构建前的处理工作 这一工具可以从您的代码中 提取本地化字符串 进入本地化目录 供未来使用 由于这个插件看起来很有用 我想让它成为独立的软件包 以便进行单独分享
如果您想了解更多 软件包中的资源和本地化的相关信息 我推荐您观看 WWDC20 中关于此话题的讲座 如果想了解更多本地化的总体信息 请查看 WWDC21 的 “本地化您的 SwiftUI App” 讲座
针对这个插件 我们首先 对用于本地化的输出目录 进行计算 我们需要计算输入文件 它们全都是给定目标中的 Swift 或 Objective-C 源文件 之后我们要创建构建前命令 来执行 Xcode 提供的 genstrings 工具 请注意 构建前和构建中命令的 最大区别在于 我们没有对明确定义的 输出集进行声明 也就是说这些命令 将在每一项构建上运行
工具会从用户的源代码中 提取所有本地化字符串 然后将所有字符串都 写入本地化目录 此目录可以作为用户项目实际的 本地化工作的基础进行使用
开始之前 我已经创建好了整体架构 现在 在软件包清单中 我们像之前一样添加插件目标 但我们还需要添加插件产品
与软件库产品类似 这是一种将插件 作为软件包让客户可用 而不仅仅是自己使用
我们可以写下之前讨论过的代码
现在我们已经构建好了插件 需要将其作为单独 示例软件包进行测试
为此 让我们从模板 创建一个新软件包
我们将添加一个为软件包提供 本地化字符串的 API
再在生成的测试中添加其用法
正如预想的那样 测试成功了 我们的 API 返回了字符串 “World” 让我们为插件包添加 一个基于路径的依赖项
再向软件库目标添加插件的用法
现在我们可以再运行一次
如果我们看一看构建日志 就会发现插件在构建一开始就执行了 生成的文件添加到了我们的目标中 因此我们将资源包构建好 并生成了一个资源存取器 好像资源从一开始 就是我们目标的一部分 现在让我们改写代码 来实际使用资源包
最后 如果我们修改代码
并看一下生成的资源包
可以看到修改反映在这里
现在我们有了插件的测试环境 就可以完善测试套件 并最终将插件包与他人共享 回顾一下 插件可以用于 对开发者工具进行自动化和共享 自定义命令提供了一种 对普通任务进行自动化的方法 构建工具则可以用于 在构建过程中生成文件 感谢您的聆听!
-
-
3:40 - GenerateContributors plugin target
// MARK: Plugins .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 write the new CONTRIBUTORS.txt to the source root.") ] )),
-
5:06 - GenerateContributors plugin implementation
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 <�>%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) } }
-
10:28 - Minimum Deployment Target
platforms: [ .macOS("10.15"), .iOS("12.0"), .tvOS("12.0"), .watchOS("6.0"), ],
-
10:35 - Basic SwiftUI view and preview
import SwiftUI struct ContentView: View { var body: some View { Image("Xcode", bundle: .module) .resizable() .frame(width: 200.0, height: 200.0) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
-
14:56 - AssetConstantsExec executable target
.executableTarget(name: "AssetConstantsExec"),
-
15:03 - AssetConstantsExec implementation
import Foundation let arguments = ProcessInfo().arguments if arguments.count < 3 { print("missing arguments") } 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 asset🐱alogContents = try JSONDecoder().decode(Contents.self, from: jsonData) let hasImage = asset🐱alogContents.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)
-
15:48 - AssetConstantsExec plugin target
.plugin(name: "AssetConstants", capability: .buildTool(), dependencies: ["AssetConstantsExec"]),
-
16:12 - AssetConstantsExec plugin implementation
guard let target = target as? SourceModuleTarget else { return [] } return try target.sourceFiles(withSuffix: "xcassets").map { asset🐱alog in let base = asset🐱alog.path.stem let input = asset🐱alog.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]) }
-
20:19 - GenstringsPlugin target
.plugin(name: "GenstringsPlugin", capability: .buildTool()),
-
20:26 - GenstringsPlugin product
.plugin(name: "GenstringsPlugin", targets: ["GenstringsPlugin"]),
-
20:44 - GenstringsPlugin implementation
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 ] inputFiles, outputFilesDirectory: localizationDirectoryPath ) ]
-
21:10 - Localized string API
import Foundation public func GetLocalizedString() -> String { return NSLocalizedString("World", comment: "A comment about the localizable string") }
-
21:44 - Path-based dependency on GenstringsPlugin
.package(path: "../GenstringsPlugin"),
-
21:52 - Use of GenstringsPlugin in library target
plugins: [ .plugin(name: "GenstringsPlugin", package: "GenstringsPlugin"), ]
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。