Room & Kotlin 符号的处理

2022-03-12 13:19:44 浏览数 (1)

△ 图片来自 Unsplash 由 Marc Reichelt 提供

Jetpack Room 库在 SQLite 上提供了一个抽象层,能够在没有任何样板代码的情况下,提供编译时验证 SQL 查询的能力。它通过处理代码注解和生成 Java 源代码的方式,实现上述行为。

注解处理器非常强大,但它们会增加构建时间。这对于用 Java 写的代码来说通常是可以接受的,但对于 Kotlin 而言,编译时间消耗会非常明显,这是因为 Kotlin 没有一个内置的注解处理管道。相反,它通过 Kotlin 代码生成了存根 Java 代码来支持注解处理器,然后将其输送到 Java 编译器中进行处理。

由于并不是所有 Kotlin 源代码中的内容都能用 Java 表示,因此有些信息会在这种转换中丢失。同样,Kotlin 是一种多平台语言,但 KAPT 只在面向 Java 字节码的情况下生效。

认识 Kotlin 符号处理

随着注解处理器在 Android 上的广泛使用,KAPT 成为了编译时的性能瓶颈。为了解决这个问题,Google Kotlin 编译器团队开始研究一个替代方案,来为 Kotlin 提供一流的注解处理支持。当这个项目诞生之初,我们非常激动,因为它将帮助 Room 更好地支持 Kotlin。从 Room 2.4 开始,它对 KSP 有了实验性的支持,我们发现编译速度提高了 2 倍,特别是在全量编译的情况下。

本文内容重点不在注解的处理、Room 或者 KSP。而在于重点介绍我们在为 Room 添加 KSP 支持时所面临的挑战和所做的权衡。为了理解本文您并不需要了解 Room 或者 KSP,但必须熟悉注解处理。

注意: 我们在 KSP 发布稳定版之前就开始使用它了。因此,尚不确定之前做的一些决策是否适用于现在。

本篇文章旨在让注解处理器的作者们在为项目添加 KSP 支持前,充分了解需要注意的问题。

Room 工作原理简介

Room 的注解处理分为两个步骤。有一些 "Processor" 类,它们遍历用户的代码,验证并提取必要的信息到 "值对象" 中。这些值对象被送到 "Writer" 类中,这些类将它们转换为代码。和其他诸多的注解处理器一样,Room 非常依赖 Auto-Common 与 javax.lang.model 包 (Java 注解处理 API 包) 中频繁引用的类。

为了支持 KSP,我们有三种选择:

  1. 复制 JavaAP 和 KSP 的每个 "Processor" 类,它们会有相同的值对象作为输出,我们可以将其输入到 Writer 中;
  2. 在 KSP/Java AP 之上创建一个抽象层,以便处理器拥有一个基于该抽象层的实现;
  3. 用 KSP 代替 JavaAP,并要求开发者也使用 KSP 来处理 Java 代码。

选项 C 实际上是不可行的,因为它会对 Java 用户造成严重的干扰。随着 Room 使用数量的增加,这种破坏性的改变是不可能的。在 "A" 和 "B" 两者之间,我们决定选择 "B",因为处理器具有相当数量的业务逻辑,将其分解并非易事。

认识 X-Processing

在 JavaAP 和 KSP 上创建一个通用的抽象并非易事。Kotlin 和 Java 可以互操作,但模式却不相同,例如,Kotlin 中特殊类的类型如 Kotlin 的值类或者 Java 中的静态方法。此外,Java 类中有字段和方法,而 Kotlin 中有属性和函数。

我们决定实现 "Room 需要什么",而不是尝试去追求完美的抽象。从字面意思来看,在 Room 中找到导入了 javax.lang.model 的每一个文件,并将其移动到 X-Processing 的抽象中。这样一来,TypeElement 变成了 XTypeElementExecutableElemen 变成了 XExecutableElemen 等等。

遗憾的是,javax.lang.model API 在 Room 中的应用非常广泛。一次性创建所有这些 X 类,会给审阅者带来非常严重的心理负担。因此,我们需要找到一种方法来迭代这一实现。

另一方面,我们需要证明这是可行的。所以我们首先对其做了 原型 设计,一旦验证这是一个合理的选择,我们就用他们自己的测试 逐一重新实现了所有 X 类。

关于我说的实现 "Room 需要什么",有一个很好的例子,我们可以在关于类的字段 更改 中看到。当 Room 处理一个类的字段时,它总是对其所有的字段感兴趣,包括父类中的字段。所以我们在创建相应的 X-Processing API 时,添加了获取所有字段的能力。

代码语言:javascript复制
interface XTypeElement {
  fun getAllFieldsIncludingPrivateSupers(): List<XVariableElement>
}

如果我们正在设计一个通用库,这样可能永远不会通过 API 审查。但因为我们的目标只是 Room,并且它已经有一个与 TypeElement 具有相同功能的辅助方法,所以复制它可以减少项目的风险。

一旦我们有了基本的 X-Processing API 和它们的测试方法,下一步就是让 Room 来调用这个抽象。这也是 "实现 Room 所需要的东西" 获得良好回报的地方。Room 在 javax.lang.model API 上已经拥有了用于基本功能的扩展函数/属性 (例如获取 TypeElement 的方法)。我们首先更新了这些扩展,使其看起来与 X-Processing API 类似,然后在 1 CL 中将 Room 迁移到 X-Processing。

改进 API 可用性

保留类似 JavaAP 的 API 并不意味着我们不能改进任何东西。在将 Room 迁移到 X-Processing 之后,我们又实现了一系列的 API 改进。

例如,Room 多次调用 MoreElement/MoreTypes,以便在不同的 javax.lang.model 类型 (例如 MoreElements.asType) 之间进行转换。相关调用通常如下所示:

代码语言:javascript复制
val element: Element ...
if (MoreElements.isType(element)) {
  val typeElement:TypeElement = MoreElements.asType(element)
}

我们把所有的调用放到了 Kotlin contracts 中,这样一来就可以写成:

代码语言:javascript复制
val element: XElement ...
if (element.isTypeElement()) {
  // 编译器识别到元素是一个 XTypeElement
}

另一个很好的例子是在一个 TypeElement 中找寻方法。通常在 JavaAP 中,您需要调用 ElementFilter 类来获取 TypeElement 中的方法。与此相反,我们直接将其设为 XTypeElement 中的一个属性。

代码语言:javascript复制
// 前
val methods = ElementFilter.methodsIn(typeElement.enclosedElements)
// 后
val methods = typeElement.declaredMethods

最后一个例子,这也可能是我最喜欢的例子之一,就是可分配性。在 JavaAP 中,如果您要检查给定的 TypeMirror 是否可以由另一个 TypeMirror 赋值,则需要调用 Types.isAssignable。

代码语言:javascript复制
val type1: TypeMirror ...
val type2: TypeMirror ...
if (typeUtils.isAssignable(type1, type2)) {
  ...
}

这段代码真的很难读懂,因为您甚至无法猜到它是否验证了类型 1 可以由类型 2 指定,亦或是完全相反的结果。我们已经有一个扩展函数如下:

代码语言:javascript复制
fun TypeMirror.isAssignableFrom(
  types: Types,
  otherType: TypeMirror
): Boolean

在 X-Processing 中,我们能够将其转换为 XType 上的常规函数,如下方所示:

代码语言:javascript复制
interface XType {
  fun isAssignableFrom(other: XType): Boolean
}

为 X-Processing 实现 KSP 后端

这些 X-Processing 接口每个都有自己的测试套件。我们编写它们并非是用来测试 AutoCommon 或者 JavaAP 的,相反,编写它们是为了在有了它们的 KSP 实现时,我们就可以运行测试用例来验证它是否符合 Room 的预期。

由于最初的 X-Processing API 是按照 avax.lang.model 建模,它们并非每次都适用于 KSP,所以我们也改进了这些 API,以便在需要时为 Kotlin 提供更好的支持。

这样产生了一个新问题。现有的 Room 代码库是为了处理 Java 源代码而写的。当应用是由 Kotlin 编写时,Room 只能识别该 Kotlin 在 Java 存根中的样子。我们决定在 X-Processing 的 KSP 实现中保持类似行为。

例如,Kotlin 中的 suspend 函数在编译时生成如下签名:

代码语言:javascript复制
// kotlin
suspend fun foo(bar:Bar):Baz
// java
Object foo(bar:Bar, Continuation<? extends Baz>)

为保持相同的行为,KSP 中的 XMethodElement 实现为 suspend 方法合成了一个新参数,以及新的返回类型。(KspMethodElement.kt)

注意: 这样做效果很好,因为 Room 生成的是 Java 代码,即使在 KSP 中也是如此。当我们添加对 Kotlin 代码生成的支持时,可能会引起一些变化。

另一个例子与属性有关。Kotlin 属性也可能具有基于其签名的合成 getter/setter (访问器)。由于 Room 期望找到这些访问器作为方法 (参见: KspTypeElement.kt),因此 XTypeElement 实现了这些合成方法。

注意 : 我们已有计划更改 XTypeElement API 以提供属性而非字段,因为这才是 Room 真正想要获取的内容。正如您现在猜到的那样,我们决定 "暂时" 不这样做来减少 Room 的修改。希望有一天我们能够做到这一点,当我们这样做时,XTypeElement 的 JavaAP 实现将会把方法和字段作为属性捆绑在一起。

在为 X-Processing 添加 KSP 实现时,最后一个有趣的问题是 API 耦合。这些处理器的 API 经常相互访问,因此如果不实现 XField / XMethod,就不能在 KSP 中实现 XTypeElement,而 XField / XMethod 本身又引用了 XType 等等。在添加这些 KSP 实现的同时,我们为它们的实现部分写了单独的测试用例。当 KSP 的实现变得更加完整时,我们逐渐通过 KSP 后端启动全部的 X-Processing 测试。

需要注意的是,在此阶段我们只在 X-Processing 项目中运行测试,所以即使我们知道测试的内容没问题,我们也无法保证所有的 Room 测试都能通过 (也称之为单元测试 vs 集成测试)。我们需要通过一种方法来使用 KSP 后端运行所有的 Room 测试,"X-Processing-Testing" 就应运而生。

认识 X-Processing-Testing

注解处理器的编写包含 20% 的处理器代码和 80% 的测试代码。您需要考虑到各种可能的开发者错误,并确保如实报告错误消息。为了编写这些测试,Room 已经提供一个辅助方法如下:

runTest 在底层使用了 Google Compile Testing 库,并允许我们简单地对处理器进行单元测试。它合成了一个 Java 注解处理器并在其中调用了处理器提供的 process 方法。

代码语言:javascript复制
val entitySource : JavaFileObject //示例 @Entity 注释类
val result = runTest(entitySource) { invocation ->
  val element = invocation.processingEnv.findElement("Subject")
  val entityValueObject = EntityProcessor(...).process(element)
  // 断言 entityValueObject
}
// 断言结果是否有误,警告等

糟糕的是,Google Compile Testing 仅支持 Java 源代码。为了测试 Kotlin 我们需要另一个库,幸运的是有 Kotlin Compile Testing,它允许我们编写针对 Kotlin 的测试,而且我们为该库贡献了对 KSP 支持。

注意 : 我们后来用 内部实现 替换了 Kotlin Compile Testing,以简化 AndroidX Repo 中的 Kotlin/KSP 更新。我们还添加了更好的断言 API,这需要我们对 KCT 执行 API 不兼容的修改操作。

作为能让 KSP 运行所有测试的最后一步,我们创建了以下测试 API:

代码语言:javascript复制
fun runProcessorTest(
  sources: List<Source>,
  handler: (XTestInvocation) -> Unit
): Unit

这个和原始版本之间的主要区别在于,它同时通过 KSP 和 JavaAP (或 KAPT,取决于来源) 运行测试。因为它多次运行测试且 KSP 和 JavaAP 两者的判断结果不同,因此无法返回单个结果。

因此,我们想到了一个办法:

代码语言:javascript复制
fun XTestInvocation.assertCompilationResult(
  assertion: (XCompilationResultSubject) -> Unit
}

每次编译后,它都会调用结果断言 (如果没有失败提示,则检查编译是否成功)。我们把每个 Room 测试重构为如下所示:

代码语言:javascript复制
val entitySource : Source //示例 @Entity 注释类
runProcessorTest(listOf(entitySource)) { invocation ->
  // 该代码块运行两次,一次使用 JavaAP/KAPT,一次使用 KSP
  val element = invocation.processingEnv.findElement("Subject")
  val entityValueObject = EntityProcessor(...).process(element)
  //  断言 entityValueObject
  invocation.assertCompilationResult {
    // 结果被断言为是否有 error,warning 等
    hasWarningContaining("...")
  }
}

接下来的事情就很简单了。将每个 Room 的编译测试迁移到新的 API,一旦发现新的 KSP / X-Processing 错误,就会上报,然后实施临时解决方案;这一动作反复进行。由于 KSP 正在大力开发中,我们确实遇到了很多 bug。每一次我们都会上报 bug,从 Room 源链接到它,然后继续前进 (或者进行修复)。每当 KSP 发布之后,我们都会搜索代码库来找到已修复的问题,删除临时解决方案并启动测试。

一旦编译测试覆盖情况较好,我们在下一步就会使用 KSP 运行 Room 的 集成测试。这些是实际的 Android 测试应用,也会在运行时测试其行为。幸运的是,Android 支持 Gradle 变体,因此使用 KSP 和 KAPT 来运行我们 Kotlin 集成测试 便相当容易。

下一步

将 KSP 支持添加到 Room 只是第一步。现在,我们需要更新 Room 来使用它。例如,Room 中的所有类型检查都忽略了 nullability,因为 javax.lang.modelTypeMirror 并不理解 nullability。因此,当调用您的 Kotlin 代码时,Room 有时会在运行时触发 NullPointerException。有了 KSP,这些检查现在可在 Room 中创建新的 KSP bug (例如 b/193437407)。我们已经添加了一些临时解决方案,但理想情况下,我们仍希望 改进 Room 以正确处理这些情况。

同样,即使我们支持 KSP,Room 仍然只生成 Java 代码。这种限制使我们无法添加对某些 Kotlin 特性的支持,比如 Value Classes。希望在将来,我们还能对生成 Kotlin 代码提供一些支持,以便在 Room 中为 Kotlin 提供一流的支持。接下来,也许更多 :)。

我能在我的项目上使用 X-Processing 吗?

答案是还不能;至少与您使用任何其他 Jetpack 库的方式不同。如前文所述,我们只实现了 Room 需要的部分。编写一个真正的 Jetpack 库有很大的投入,比如文档、API 稳定性、Codelabs 等,我们无法承担这些工作。话虽如此,Dagger 和 Airbnb (Paris、DeeplinkDispatch) 都开始用 X-Processing 来支持 KSP (并贡献了他们需要的东西

0 人点赞