查看原文
其他

Room & Kotlin 符号的处理

Android Android 开发者 2021-11-05

△ 图片来自 Unsplash

由 Marc Reichelt 提供

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


  • Room
    https://developer.android.google.cn/training/data-storage/room


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

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



认识 Kotlin 符号处理


  • Kotlin 符号处理
    https://github.com/google/ksp


随着注解处理器在 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 包) 中频繁引用的类。


  • Auto-Common
    https://github.com/google/auto/tree/master/common

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

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



认识 X-Processing



  • X-Processing

    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-compiler-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 类


  • 原型
    https://android-review.googlesource.com/c/platform/frameworks/support/+/1362062

  • 逐一重新实现了所有 X 类
    https://android-review.googlesource.com/c/platform/frameworks/support/+/1362102


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

interface XTypeElement { fun getAllFieldsIncludingPrivateSupers(): List<XVariableElement>}

  • 更改
    https://android-review.googlesource.com/c/platform/frameworks/support/+/1362165/6/room/compiler-xprocessing/src/main/java/androidx/room/processing/javac/JavacTypeElement.kt

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


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


  • 1 CL
    https://android-review.googlesource.com/c/platform/frameworks/support/+/1361181/21/room/compiler/src/main/kotlin/androidx/room/preconditions/Checks.kt


改进 API 可用性


保留类似 JavaAP 的 API 并不意味着我们不能改进任何东西。在将 Room 迁移到 X-Processing 之后,我们又实现了一系列的 API 改进。
例如,Room 多次调用 MoreElement/MoreTypes,以便在不同的 javax.lang.model 类型 (例如 MoreElements.asType) 之间进行转换。相关调用通常如下所示:
val element: Element ...if (MoreElements.isType(element)) { val typeElement:TypeElement = MoreElements.asType(element)}


  • MoreElements.asType
    https://github.com/google/auto/blob/master/common/src/main/java/com/google/auto/common/MoreElements.java#L131


我们把所有的调用放到了 Kotlin contracts 中,这样一来就可以写成:
val element: XElement ...if (element.isTypeElement()) { // 编译器识别到元素是一个 XTypeElement}


  • Kotlin contracts
    https://kotlinlang.org/docs/whatsnew13.html#contracts


另一个很好的例子是在一个 TypeElement 中找寻方法。通常在 JavaAP 中,您需要调用 ElementFilter 类来获取 TypeElement 中的方法。与此相反,我们直接将其设为 XTypeElement 中的一个属性。
// 前val methods = ElementFilter.methodsIn(typeElement.enclosedElements)// 后val methods = typeElement.declaredMethods


  • ElementFilter
    https://docs.oracle.com/javase/7/docs/api/javax/lang/model/util/ElementFilter.html


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


  • Types.isAssignable
    https://docs.oracle.com/javase/8/docs/api/javax/lang/model/util/Types.html#isAssignable-javax.lang.model.type.TypeMirror-javax.lang.model.type.TypeMirror-


这段代码真的很难读懂,因为您甚至无法猜到它是否验证了类型 1 可以由类型 2 指定,亦或是完全相反的结果。我们已经有一个扩展函数如下:
fun TypeMirror.isAssignableFrom( types: Types, otherType: TypeMirror): Boolean
在 X-Processing 中,我们能够将其转换为 XType 上的常规函数,如下方所示:
interface XType { fun isAssignableFrom(other: XType): Boolean}

为 X-Processing 实现 KSP 后端


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


  • AutoCommon
    https://github.com/google/auto/tree/master/common


由于最初的 X-Processing API 是按照 avax.lang.model 建模,它们并非每次都适用于 KSP,所以我们也改进了这些 API,以便在需要时为 Kotlin 提供更好的支持。
这样产生了一个新问题。现有的 Room 代码库是为了处理 Java 源代码而写的。当应用是由 Kotlin 编写时,Room 只能识别该 Kotlin 在 Java 存根中的样子。我们决定在 X-Processing 的 KSP 实现中保持类似行为。
例如,Kotlin 中的 suspend 函数在编译时生成如下签名:
// kotlinsuspend fun foo(bar:Bar):Baz// javaObject foo(bar:Bar, Continuation<? extends Baz>)
为保持相同的行为,KSP 中的 XMethodElement 实现为 suspend 方法合成了一个新参数,以及新的返回类型。(KspMethodElement.kt)


  • KspMethodElement.kt
    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspMethodElement.kt;l=108?q=KspSuspendMethodElement&ss=androidx


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

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


  • KspTypeElement.kt
    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspTypeElement.kt;l=144


注意: 我们已有计划更改 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


  • X-Processing-Testing
    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-compiler-processing-testing/


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


runTest 在底层使用了 Google Compile Testing 库,并允许我们简单地对处理器进行单元测试。它合成了一个 Java 注解处理器并在其中调用了处理器提供的 process 方法。
val entitySource : JavaFileObject //示例 @Entity 注释类val result = runTest(entitySource) { invocation -> val element = invocation.processingEnv.findElement("Subject") val entityValueObject = EntityProcessor(...).process(element) // 断言 entityValueObject}// 断言结果是否有误,警告等
  • Google Compile Testing
    https://github.com/google/compile-testing


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


  • Kotlin Compile Testing
    https://github.com/tschuchortdev/kotlin-compile-testing


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

  • 内部实现
    https://android-review.googlesource.com/c/platform/frameworks/support/+/1779266


作为能让 KSP 运行所有测试的最后一步,我们创建了以下测试 API:
fun runProcessorTest( sources: List<Source>, handler: (XTestInvocation) -> Unit): Unit

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


因此,我们想到了一个办法:
fun XTestInvocation.assertCompilationResult( assertion: (XCompilationResultSubject) -> Unit}


每次编译后,它都会调用结果断言 (如果没有失败提示,则检查编译是否成功)。我们把每个 Room 测试重构为如下所示:
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 集成测试便相当容易。


  • 集成测试
    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/integration-tests/
  • Kotlin 集成测试
    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/integration-tests/kotlintestapp/build.gradle


下一步


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


  • b/193437407
    https://issuetracker.google.com/issues/193437407

  • 改进
    https://android-review.googlesource.com/c/platform/frameworks/support/+/1844471


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


  • Value Classes
    https://kotlinlang.org/docs/inline-classes.html



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


答案是还不能;至少与您使用任何其他 Jetpack 库的方式不同。如前文所述,我们只实现了 Room 需要的部分。编写一个真正的 Jetpack 库有很大的投入,比如文档、API 稳定性、Codelabs 等,我们无法承担这些工作。话虽如此,Dagger 和 Airbnb (ParisDeeplinkDispatch) 都开始用 X-Processing 来支持 KSP (并贡献了他们需要的东西🙏)。也许有一天我们会把它从 Room 中分解出来。从技术层面上讲,您仍然可以像使用 Google Maven 库一样使用它,但是没有 API 保证可以这样做,因此您绝对应该使用 shade 技术。


  • Paris
    https://github.com/airbnb/paris

  • DeeplinkDispatch
    https://github.com/airbnb/DeepLinkDispatch

  • Google Maven 库
    https://maven.google.com/web/index.html#androidx.room

  • shade
    https://github.com/johnrengelman/shadow



总结


我们为 Room 添加了 KSP 支持,这并非易事但绝对值得。如果您在维护注解处理器,请添加对 KSP 的支持,以提供更好的 Kotlin 开发者体验。


特别感谢 Zac SweersEli Hart 审校这篇文章的早期版本,他们同时也是优秀的 KSP 贡献者。


  • Zac Sweers
    https://medium.com/@ZacSweers

  • Eli Hart
    https://medium.com/@konakid



更多资源


  • 关于 Room 对于 KSP 支持的 Issue Tracker

    https://issuetracker.google.com/issues/160322705

  • X-Processing 源码

    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-compiler-processing/

  • X-Processing-Testing 源码

    https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:room/room-compiler-processing-testing/

  • KSP 源码

    https://github.com/google/ksp


欢迎您通过下方二维码向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!






免费中文系列课程下载

系统地学习使用 Kotlin 进行 Android 开发


☟ 即刻了解课程详情 ☟




推荐阅读

如页面未加载,请刷新重试

 点击屏末 | 阅读原文 | 即刻使用 Room 将数据保存到本地数据库




: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存