12.4.7 增量处理

原文链接: https://kotlinlang.org/docs/ksp-incremental.html

12.4.7 增量处理

KSP 支持增量处理:只有当某个文件的一个或多个依赖发生变化时,KSP 才会重新处理该文件。这避免了不必要的重新处理,从而缩短编译时间。

增量处理默认启用。你可以在排查问题或需要强制完整重建时禁用它。要禁用它,请在 gradle.properties 文件中添加以下行:

1
ksp.incremental=false

脏文件

如果某个文件被开发者直接修改,或者受到其他脏文件变化的间接影响,它就被视为_脏_文件(需要重新处理)。

为了确定哪些源文件是脏的,KSP 依赖处理器把生成的输出与其对应的输入源文件关联起来。KSP 使用这些关联来确定发生变更时必须重新处理的源文件。

KSP 只需要一小组根源文件。处理器以这些源文件作为切入点来遍历代码结构。

根源文件是指其符号直接通过以下任一方法获取的源文件:

  • Resolver.getAllFiles()
  • Resolver.getSymbolsWithAnnotation()
  • Resolver.getClassDeclarationByName()
  • Resolver.getDeclarationsFromPackage()

处理器可以通过从根源文件解析信息,从其他源文件获取额外的符号。KSP 会自动跟踪这些依赖。

生成输出时,处理器必须声明为该输出做出贡献的根源文件。KSP 使用这些根源文件及其被跟踪的依赖来判断何时需要重新生成该输出。

提示: 使用 CodeGenerator 接口来创建输出文件,并把输入与输出关联起来。更多信息请参阅源代码中的 CodeGenerator.kt。

聚合输出与隔离输出

KSP 把生成的输出分为两类:聚合型与隔离型。

注意: 与 Gradle 注解处理不同,KSP 把这种分类应用于单个输出,而不是整个处理器。

聚合型

聚合型输出可能受到任何源文件变化的影响,但不包括不影响其他文件的删除操作。

任何输入变化都会触发所有聚合型输出的重建,并重新处理所有相应已注册、新增或已修改的源文件。

例如,收集所有带有某个特定注解的符号的输出就属于聚合型。

隔离型

隔离型输出只依赖于其指定的源文件。

对其他源文件的修改不会影响该输出。多个源文件可以关联到同一个输出。

例如,为它所实现的某个接口专门生成的类就属于隔离型。

脏标记的传播

KSP 通过以下方式传播脏标记:

  1. 通过解析追踪:类型解析是从一个文件遍历到另一个文件的唯一方式。当处理器(显式或隐式地)解析某个类型引用时,KSP 会考虑包含该引用的文件与任何定义了影响该解析的符号的文件之间的依赖关系。因此,被解析符号的变化可能会把引用它的文件标记为脏。

  2. 通过输入输出对应关系:如果某个源文件被修改或受到影响,那么与它共享生成输出的所有其他源文件也会被标记为受影响。这会基于共享的输出把相关文件归入同一等价类。

提示: 规则(1)和规则(2)可以相互反复触发。例如,规则(1)可以触发规则(2),后者又可以再次触发规则(1)。

实现

依赖关系由输入与输出文件之间的多对多关系决定。

KSP 就是这样判断哪些文件需要重新处理的:

  • 如果某个输入文件发生变化,它总是会被重新处理。

为什么? 如果输入发生变化,就可能引入新的信息。处理器需要用该输入再次运行。

  • 如果某个输入文件发生变化并且关联到某个输出,那么与同一输出关联的所有其他输入文件也会被重新处理。这个过程会反复进行,直到不再出现新的脏文件。

为什么? 一个输出由一组输入生成。处理器可能需要所有输入才能重新生成该输出。

  • 如果某个未变化的输入文件不与任何聚合型输出关联,它就不会被重新处理。

为什么? 该文件没有变化,且不与聚合型输出关联,因此不可能影响任何输出。除非适用上述某条规则,否则它不会被重新处理。

例如,考虑一个具有以下结构的项目:

.
├── src
│   ├── sourceA.kt
│   └── sourceB.kt
└── generated
   ├── outputA
   └── outputB

一个处理器:

  1. 读取 sourceA。

  2. 生成 outputA。

  3. 读取 sourceB。

  4. 生成 outputB。

当 sourceA 发生变化时:

  • 如果 outputB 是聚合型的,KSP 会重新处理 sourceA 和 sourceB。

  • 如果 outputB 是隔离型的,KSP 只会重新处理 sourceA。

如果新增了 sourceC:

  • 如果 outputB 是聚合型的,KSP 会重新处理 sourceC 和 sourceB。

  • 如果 outputB 是隔离型的,KSP 只会重新处理 sourceC。

如果 sourceA 或 sourceB 中任意一个被删除,KSP 无需重新处理任何文件。

示例处理器

下面的项目包含类 A 和 B,其中 A 继承自 B:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// A.kt
@Interesting
class A : B()

// B.kt
open class B

// Example1Processor.kt
class Example1Processor : SymbolProcessor {
   override fun process(resolver: Resolver) {
       val declA = resolver.getSymbolsWithAnnotation("Interesting").first() as KSClassDeclaration
       val declB = declA.superTypes.first().resolve().declaration
       // 不需要 B.kt,因为 KSP 可以推断它是依赖
       val dependencies = Dependencies(aggregating = true, declA.containingFile!!)
       // outputForA.kt
       val outputName = "outputFor${declA.simpleName.asString()}"
       // outputForA 依赖 A.kt 和 B.kt
       val output = codeGenerator.createNewFile(dependencies, "com.example", outputName, "kt")
       output.write("// $declA : $declB\n".toByteArray())
       output.close()
   }
   // ...
}

为了生成 outputForA,该处理器:

  1. 通过调用 Resolver.getSymbolsWithAnnotation 获取 A。

  2. 通过对 A 调用 KSClassDeclaration.superTypes 获取 B。

KSP 通过解析追踪记录这一关系,并自动把 B 记录为 A 的依赖。因此,你无需把 B.kt 显式声明为 outputForA 的依赖。

报告缺陷

如果你遇到只在启用增量处理时才出现的任何错误,请在 GitHub 仓库中创建 issue,并附上相关的日志文件。

  1. 通过在 gradle.properties 中添加以下行来启用增量处理日志:
1
   ksp.incremental.log=true
  1. 执行一次能成功完成的干净构建。

  2. 把生成的日志文件复制到其他位置以保存它们:

  • build/kspCaches/<source set>/logs/kspDirtySet.log
  • build/kspCaches/<source set>/logs/kspSourceToOutputs.log
  1. 修改一个会触发该问题的源文件,然后再次运行构建。

  2. 把成功构建和复现该问题的构建的日志文件都附到 GitHub issue 中。

可视化符号依赖图

为帮助调试增量处理,KSP 可以生成一个 Graphviz DOT 文件,用于可视化从指定符号开始的符号依赖图。

启用增量日志,并指定作为图可视化起点的符号的完全限定名:

1
2
ksp.incremental.log=true
ksp.incremental.log.graph.origin=<fully-qualified-name>

如果你从命令行使用 KSP,请添加以下选项:

1
-incremental-log=true -incremental-log.graph.origin=<fully-qualified-name>

DOT 文件会生成在 logs 目录中。