Periphery
一个用于识别 Swift 项目中未使用代码的工具。
现在我成为了删除者,代码的毁灭者。
目录
安装
Homebrew
brew install peripheryapp/periphery/periphery
Mint
mint install peripheryapp/periphery
使用方法
scan
命令
scan 命令是 Periphery 的主要功能。要开始引导式设置,只需切换到项目目录并运行:
periphery scan --setup
引导式设置仅适用于 Xcode 和 SwiftPM 项目,要在非苹果构建系统(如 Bazel)中使用 Periphery,请参阅构建系统。
回答几个问题后,Periphery 将打印出完整的扫描命令并执行它。
引导式设置仅用于入门目的,一旦你熟悉了 Periphery,你可以尝试一些更高级的选项,所有这些选项都可以通过 periphery help scan
查看。
要从 Periphery 获得一致的结果,理解你选择分析的构建目标的影响至关重要。例如,假设一个项目由三个目标组成:App、Lib 和 Tests。App 目标导入 Lib,Tests 目标导入 App 和 Lib。如果你在 --targets
选项中提供所有三个目标,那么 Periphery 将能够分析你的整个项目。但是,如果你只选择分析 App 和 Lib,而不分析 Tests,Periphery 可能会报告一些未使用的代码实例,这些实例_仅_被 Tests 引用。因此,当你怀疑 Periphery 提供了不正确的结果时,考虑你选择分析的目标很重要。
如果你的项目由一个或多个独立的框架组成,这些框架不包含消费其接口的应用程序,那么你需要告诉 Periphery 假设所有公共声明实际上都被使用,方法是包含 --retain-public
选项。
对于混合了 Objective-C 和 Swift 的项目,强烈建议你阅读相关影响。
配置
一旦你确定了适合你项目的选项,你可能希望将它们保存在 YAML 配置文件中。最简单的方法是使用 --verbose
选项运行 Periphery。在输出的开头附近,你会看到 [configuration:begin]
部分,下面是格式化为 YAML 的配置。将配置复制并粘贴到项目文件夹根目录的 .periphery.yml
中。现在你只需运行 periphery scan
,YAML 配置就会被使用。
工作原理
Periphery 首先构建你的项目。对于 Xcode 项目,通过 --schemes
选项提供的方案使用 xcodebuild
构建。对于 Swift Package Manager 项目,通过 --targets
选项提供的单个目标使用 swift build
构建。Swift 编译器采用一种称为边构建边索引的技术来填充索引存储,其中包含有关项目源代码结构的信息。
项目构建完成后,Periphery 会执行索引阶段。对于通过 --targets
选项提供的目标中的每个源文件,Periphery 从索引存储中获取其结构信息,并构建项目的内部图形表示。Periphery 还分析每个文件的抽象语法树(AST),以填补索引存储未提供的一些细节。
索引完成后,Periphery 分析图形以识别未使用的代码。这个阶段包括一系列步骤,这些步骤会改变图形,使其更容易识别未使用代码的特定场景。最后一步是从图形的根部遍历,以识别不再被引用的声明。
分析
Periphery 的目标是报告未使用的声明实例。声明包括 class
、struct
、protocol
、function
、property
、constructor
、enum
、typealias
、associatedtype
等。正如您所预期的,Periphery 能够识别简单的未引用声明,例如在代码库中不再使用的 class
。
Periphery 还可以识别更高级的未使用代码实例。以下部分详细解释了这些情况。
函数参数
Periphery 可以识别未使用的函数参数。未使用的参数实例也可以在协议及其遵循声明中识别,以及在重写方法中的参数。这两种情况将在下面进一步解释。
协议
只有当参数在所有实现中都未使用时,协议函数的未使用参数才会被报告为未使用。
protocol Greeter {
func greet(name: String)
func farewell(name: String) // 'name' 未使用
}
class InformalGreeter: Greeter {
func greet(name: String) {
print("嗨 " + name + "。")
}
func farewell(name: String) { // 'name' 未使用
print("再见。")
}
}
提示
您可以使用
--retain-unused-protocol-func-params
选项忽略所有来自协议和遵循函数的未使用参数。
重写
类似于协议,只有当重写函数的参数在基函数和所有重写函数中都未使用时,才会被报告为未使用。
class BaseGreeter {
func greet(name: String) {
print("你好。")
}
func farewell(name: String) { // 'name' 未使用
print("再见。")
}
}
class InformalGreeter: BaseGreeter {
override func greet(name: String) {
print("嗨 " + name + "。")
}
override func farewell(name: String) { // 'name' 未使用
print("拜。")
}
}
外部协议和类
外部模块(如 Foundation)中定义的协议或类的未使用参数始终被忽略,因为您无法修改基函数声明。
fatalError 函数
仅调用 fatalError
的函数中未使用的参数也会被忽略。这样的函数通常是子类中未实现的必需初始化器。
class Base {
let param: String
required init(param: String) {
self.param = param
}
}
class Subclass: Base {
init(custom: String) {
super.init(param: custom)
}
required init(param: String) {
fatalError("init(param:) 尚未实现")
}
}
协议
一个被对象遵循的协议,只有在作为存在类型使用或用于特化泛型方法/类时才是真正被使用的。Periphery 能够识别这种冗余协议,无论它们被一个还是多个对象遵循。
protocol MyProtocol { // 'MyProtocol' 是冗余的
func someMethod()
}
class MyClass1: MyProtocol { // 'MyProtocol' 遵循是冗余的
func someMethod() {
print("来自 MyClass1 的问候!")
}
}
class MyClass2: MyProtocol { // 'MyProtocol' 遵循是冗余的
func someMethod() {
print("来自 MyClass2 的问候!")
}
}
let myClass1 = MyClass1()
myClass1.someMethod()
let myClass2 = MyClass2()
myClass2.someMethod()
在这里我们可以看到,尽管 someMethod
的两个实现都被调用了,但在任何时候对象都没有采用 MyProtocol
类型。因此,协议本身是冗余的,MyClass1
或 MyClass2
遵循它没有任何好处。我们可以移除 MyProtocol
以及每个冗余的遵循,只在每个类中保留 someMethod
。
就像对象的普通方法或属性一样,协议声明的各个属性和方法也可以被识别为未使用。
protocol MyProtocol {
var usedProperty: String { get }
var unusedProperty: String { get } // 'unusedProperty' 未使用
}
class MyConformingClass: MyProtocol {
var usedProperty: String = "已使用"
var unusedProperty: String = "未使用" // 'unusedProperty' 未使用
}
class MyClass {
let conformingClass: MyProtocol
init() {
conformingClass = MyConformingClass()
}
func perform() {
print(conformingClass.usedProperty)
}
}
let myClass = MyClass()
myClass.perform()
在这里我们可以看到,MyProtocol
本身是被使用的,不能被移除。然而,由于 unusedProperty
从未在 MyConformingClass
上被调用,Periphery 能够识别出 MyProtocol
中 unusedProperty
的声明也未被使用,可以与未使用的 unusedProperty
实现一起移除。
枚举
除了能够识别未使用的枚举,Periphery 还可以识别单个未使用的枚举案例。可以可靠地识别不具有原始表示性的普通枚举,即那些没有 String
、Character
、Int
或浮点值类型的枚举。然而,具有原始值类型的枚举可能具有动态性质,因此必须假定它们被使用。
让我们用一个简单的例子来说明这一点:
enum MyEnum: String {
case myCase
}
func someFunction(value: String) {
if let myEnum = MyEnum(rawValue: value) {
somethingImportant(myEnum)
}
}
这里没有直接引用 myCase
案例,所以我们可能会认为它不再需要。但是,如果删除它,我们可以看到当 someFunction
传入 "myCase"
值时,somethingImportant
将永远不会被调用。
仅赋值属性
被赋值但从未使用的属性会被识别为仅赋值属性,例如:
class MyClass {
var assignOnlyProperty: String // 'assignOnlyProperty' 被赋值,但从未使用
init(value: String) {
self.assignOnlyProperty = value
}
}
在某些情况下,这可能是预期的行为,因此你有几个选项可以消除这类结果:
- 使用注释命令保留单个属性。
- 使用
--retain-assign-only-property-types
按类型保留所有仅赋值属性。给定的类型必须与属性声明中的确切用法匹配(不包括可选的问号),例如String
、[String]
、Set<String>
。Periphery 无法解析推断的属性类型,因此在某些情况下,你可能需要为属性添加显式类型注解。 - 使用
--retain-assign-only-properties
完全禁用仅赋值属性分析。
冗余的公共可访问性
被标记为 public
但在其所在模块外部没有被引用的声明会被识别为具有冗余的公共可访问性。在这种情况下,可以从声明中移除 public
注解。移除冗余的公共可访问性有几个好处:
可以使用 --disable-redundant-public-analysis
禁用此分析。
未使用的导入
Periphery 可以检测它已扫描的目标(即用 --targets
参数指定的目标)中未使用的导入。它无法检测其他目标的未使用导入,因为无法获取 Swift 源文件,也无法观察 @_exported
的使用情况。@_exported
存在问题,因为它改变了目标的公共接口,使得目标导出的声明不再必须由导入的目标声明。例如,Foundation
目标导出了 Dispatch
等多个目标。如果任何给定的源文件导入 Foundation
并引用了 DispatchQueue
,但没有引用 Foundation
中的其他声明,那么就不能移除 Foundation
导入,因为这也会使 DispatchQueue
类型不可用。因此,为了避免误报,Periphery 只检测它已扫描的目标中未使用的导入。
对于混合 Swift 和 Objective-C 的目标,Periphery 可能会产生误报,因为 Periphery 无法扫描 Objective-C 文件。因此,建议对具有大量 Objective-C 代码的项目禁用未使用导入检测,或手动从结果中排除混合语言目标。
Objective-C
Periphery 无法分析 Objective-C 代码,因为类型可能是动态类型。
默认情况下,Periphery 不假设 Objective-C 运行时可访问的声明正在使用。如果你的项目混合了 Swift 和 Objective-C,你可以使用 --retain-objc-accessible
选项启用此行为。可以被 Objective-C 运行时访问的 Swift 声明是那些显式注解了 @objc
或 @objcMembers
的声明,以及直接或通过另一个类间接继承 NSObject
的类。
另外,可以使用 --retain-objc-annotated
选项仅保留显式注解了 @objc
或 @objcMembers
的声明。继承 NSObject
的类型不会被保留,除非它们有显式注解。这个选项可能会发现更多未使用的代码,但缺点是如果声明实际上在 Objective-C 代码中使用,部分结果可能不正确。要解决这些不正确的结果,你必须为声明添加 @objc
注解。
Codable
Swift 为 Codable
类型合成额外的代码,这些代码对 Periphery 不可见,可能导致在非合成代码中未直接引用的属性出现误报。如果你的项目包含许多这样的类型,你可以使用 --retain-codable-properties
保留所有 Codable
类型的属性。或者,你可以使用 --retain-encodable-properties
仅保留 Encodable
类型的属性。
如果 Codable
一致性是由 Periphery 未扫描的外部模块中的协议声明的,你可以使用 --external-codable-protocols "ExternalProtocol"
指示 Periphery 将这些协议识别为 Codable
。
XCTestCase
任何继承自 XCTestCase
的类及其测试方法都会被自动保留。然而,当一个类通过另一个类间接继承 XCTestCase
,例如 UnitTestCase
,且该类位于 Periphery 不会扫描的目标中时,你需要使用 --external-test-case-classes UnitTestCase
选项来指示 Periphery 将 UnitTestCase
视为 XCTestCase
的子类。
Interface Builder
如果你的项目包含 Interface Builder 文件(如 storyboard 和 XIB),Periphery 在识别未使用的声明时会考虑这些文件。但是,Periphery 目前只能识别未使用的类。这个限制存在是因为 Periphery 尚未完全解析 Interface Builder 文件(参见 issue #212)。由于 Periphery 的设计原则是避免误报,所以假设如果一个类在 Interface Builder 文件中被引用,那么它的所有 IBOutlets
和 IBActions
都被认为是在使用中,即使实际上可能并非如此。一旦 Periphery 具备解析 Interface Builder 文件的能力,这种方法将被修改以准确识别未使用的 IBActions
和 IBOutlets
。
注释命令
出于某些原因,你可能想保留一些未使用的代码。可以使用源代码注释命令来忽略特定声明,并将其从结果中排除。
可以在任何声明的上一行直接放置忽略注释命令,以忽略该声明及其所有后代声明:
// periphery:ignore
class MyClass {}
你还可以忽略特定的未使用函数参数:
// periphery:ignore:parameters unusedOne,unusedTwo
func someFunc(used: String, unusedOne: String, unusedTwo: String) {
print(used)
}
// periphery:ignore:all
命令可以放在源文件的顶部,以忽略文件的全部内容。请注意,该注释必须放在任何代码之上,包括导入语句。
注释命令还支持在连字符后添加尾随注释,这样你可以在同一行中包含解释:
// periphery:ignore - 解释为什么这是必要的
class MyClass {}
Xcode 集成
在设置 Xcode 集成之前,我们强烈建议你先在终端中使用 Periphery,因为你将通过 Xcode 使用完全相同的命令。
步骤 1:创建聚合目标
在项目导航器中选择你的项目,然后点击目标部分左下角的 + 按钮。选择跨平台并选择聚合。点击下一步。
为新目标选择一个名称,例如 "Periphery" 或 "未使用代码"。
步骤 2:添加运行脚本构建阶段
在构建阶段部分,点击 + 按钮添加新的运行脚本阶段。
在 shell 脚本窗口中输入 Periphery 命令。确保包含 --format xcode
选项。
步骤 3:选择并运行
现在你已准备就绪。你应该能在下拉菜单中看到新的方案。选择它并点击运行。
提示
如果你希望团队中的其他人也能使用该方案,你需要将其标记为_共享_。可以通过选择_管理方案..._并选中新方案旁边的_共享_复选框来完成。现在可以将方案定义提交到源代码控制中。
排除文件
以下两个排除选项都接受 Bash v4 风格的路径通配符,可以是绝对路径或相对于你的项目目录的路径。你可以用空格分隔多个通配符,例如 --option "Sources/Single.swift" "**/Generated/*.swift" "**/*.{xib,storyboard}"
。
排除结果
要从某些文件中排除结果,请向 scan
命令传递 --report-exclude <globs>
选项。
排除索引文件
要从索引中排除文件,请向 scan
命令传递 --index-exclude <globs>
选项。从索引阶段排除文件意味着 Periphery 将看不到这些文件中包含的任何声明和引用。Periphery 的行为就像这些文件不存在一样。例如,此选项可用于排除包含对非生成代码引用的生成代码,或排除所有包含对代码引用的 .xib
和 .storyboard
文件。
保留文件声明
要保留文件中的所有声明,请向 scan
命令传递 --retain-files <globs>
选项。此选项相当于在每个文件顶部添加 // periphery:ignore:all
注释命令。
持续集成
在将 Periphery 集成到 CI 流水线时,如果您的流水线已经完成构建(例如运行测试),您可以跳过构建阶段。这可以通过使用 --skip-build
选项来实现。但是,您还需要使用 --index-store-path
告诉 Periphery 索引存储的位置。此位置取决于您的项目类型。
请注意,使用 --skip-build
和 --index-store-path
时,确保索引存储包含您通过 --targets
指定的所有目标的数据至关重要。例如,如果您的流水线之前构建了 'App' 和 'Lib' 目标,索引存储将只包含这些目标中文件的数据。然后您不能指示 Periphery 扫描额外的目标,如 'Extension' 或 'UnitTests'。
Xcode
xcodebuild
生成的索引存储位于 DerivedData 中,具体位置取决于您的项目,例如 ~/Library/Developer/Xcode/DerivedData/YourProject-abc123/Index/DataStore
。对于 Xcode 14 及更高版本,Index
目录可能表示为 Index.noindex
,这会抑制 Spotlight 索引。
SwiftPM
默认情况下,Periphery 在 .build/debug/index/store
查找索引存储。因此,如果您打算在调用 swift test
后直接运行 Periphery,可以省略 --index-store-path
选项,Periphery 将使用项目构建用于测试时创建的索引存储。但如果不是这种情况,则必须使用 --index-store-path
提供 Periphery 索引存储的位置。
构建系统
Periphery 可以分析使用第三方构建系统(如 Bazel)的项目,但不能像 SwiftPM 和 xcodebuild 那样自动驱动它们。相反,您需要指定索引存储位置并提供文件-目标映射文件。
文件-目标映射文件包含源文件到构建目标的简单映射。您需要使用构建系统的适当工具自行生成此文件。格式如下:
{
"file_targets": {
"path/to/file_a.swift": ["TargetA"],
"path/to/file_b.swift": ["TargetB", "TargetC"]
}
}
提示
相对路径假定相对于当前目录。
然后您可以如下调用 periphery:
periphery scan --file-targets-path map.json --index-store-path index/store
提示
这两个选项都支持多个路径。
平台
Periphery 支持 macOS 和 Linux。macOS 支持 Xcode 和 Swift Package Manager (SPM) 项目,而 Linux 仅支持 SPM 项目。
故障排除
一个或多个文件中的错误结果,如误报和不正确的源文件位置
索引存储可能会损坏或与源文件不同步。例如,如果您强制终止(^C)扫描,就可能发生这种情况。要纠正此问题,您可以向扫描命令传递 --clean-build
标志,强制删除现有的构建产物。
预处理器宏条件分支内引用的代码未使用
当 Periphery 构建您的项目时,它使用默认的构建配置,通常是 'debug'。如果您使用预处理器宏来有条件地编译代码,Periphery 将只能看到被编译的分支。在下面的示例中,releaseName
将被报告为未使用,因为它只在宏的非调试分支中被引用。
struct BuildInfo {
let debugName = "debug"
let releaseName = "release" // 'releaseName' 未使用
var name: String {
#if DEBUG
debugName
#else
releaseName
#endif
}
}
您有几个选项来解决这个问题:
- 使用注释命令显式忽略
releaseName
。 - 过滤结果以删除已知实例。
- 为每个构建配置运行一次 Periphery 并合并结果。您可以在
--
后指定传递给底层构建的参数,例如periphery scan ... -- -configuration release
。
Swift 包是平台特定的
Periphery 使用 swift build
编译 Swift 包,如果 Swift 包是平台特定的(例如针对 iOS),这将失败。
作为解决方法,您可以手动使用 xcodebuild
构建 Swift 包,然后使用 --skip-build
和 --index-store-path
选项来针对 xcodebuild
之前生成的索引存储。
示例:
# 1. 使用 xcodebuild
xcodebuild -scheme MyScheme -destination 'platform=iOS Simulator,OS=16.2,name=iPhone 14' -derivedDataPath '../dd' clean build
# 2. 使用生成的索引存储进行扫描
periphery scan --skip-build --index-store-path '../dd/Index.noindex/DataStore/'
已知问题
由于 Swift 中的一些底层错误,Periphery 在某些情况下可能会报告不正确的结果。
ID | 标题 |
---|---|
56559 | 索引存储未关联通过 Self 引用的构造函数 |
56541 | 索引存储未关联用作下标键的静态属性获取器 |
56327 | 索引存储未关联子类中实现的 objc 可选协议方法 |
56189 | 索引存储应关联字符串字面量中的 appendInterpolation |
56165 | 索引存储未关联通过字面量表示法的构造函数 |
49641 | 索引不包含对具有泛型类型的类/结构体构造函数的引用 |
赞助商
Periphery 是一个需要大量精力维护和开发的热情项目。如果您觉得 Periphery 有用,请考虑通过 GitHub Sponsors 进行赞助。
特别感谢以下慷慨的赞助商:
Emerge Tools
Emerge Tools 是一套革命性产品,旨在为移动应用及其开发团队提供超强动力。