[译] D8 类库脱糖
原文出自 jakewharton 关于 D8 和 R8 系列文章第 15 篇。
原文链接 : D8 Library Desugaring
原文作者 : jakewharton
至今在这个系列文章中,关于 D8 的文章有 Java 8 language features 脱糖、平台的vendor- and version-specific bugs 以及关于性能的 method-local optimization。在本文中,我们将介绍 D8 即将推出的一个名为“核心库去糖化”的特性,它使较新的 api 可以在较旧版本的 Android 上使用。
Java8 API(如 streams、optional 和 new time API)的库设计在 2019 年Google I/O 开发者大会上宣布支持,并在 2019 年 Android DevSummit 大会上发布了 Android Studio 4.0 的首个金丝雀版本发布支持。这将允许开发人员在其应用程序目标的每个版本上使用API 24 和 26 中引入的这些功能。
这也是 Java 库生态系统的一个好处。许多库早已转向 Java8,但无法使用更新的 API 来保持 Android 兼容性。虽然并非每个新 API 都可用,但 D8 desugaring 应该允许这些库使用最需要的 API。
1. Not a new feature
尽管最近声势浩大,但对 API 进行去糖处理实际上并不是 D8 的一个新特性。自从 D8 成为 dx 的可用替代品以来,它已经取消了对 API19 Objects.requireNonNull 方法的调用。但是,为什么是这个方法呢?
某些代码的固定格式将导致 Java 编译器合成显式的空检查。
当使用 JKD 8 编译的时候,doSomething 方法的字节码中包含 getClass() 的调用,并直接进行了返回。
从上面的字节码中可以看到,在第 5 行位置,将常量 0 直接内联到 doSomething 方法中了。因此,如果你传一个 null 作为 Counter 参数,你会得到 null-pointer exception 空指针异常,所以可以看到,通过对 getClass 的调用,保证了程序的正常运行。
如果用 JDK 9 重新编译这个代码段,字节码就会改变。
JDK-8074306在这个场景中更改了Java 编译器的行为,以产生更好的异常。但是 Android 工具链在 JDK9(以及更新版本)上还不能正常工作,因此您可能想知道这些调用是如何产生的。
主要的代码是 Google’s error-prone 编译器和静态分析器,它与 JDK8 一起工作,但构建在 JDK9 编译器之上。虽然 error-prone 通过引入 off-by-default 标志解决了这个问题,但是 Retrolambda 为 API 添加了 desugaring,这基本上要求 D8 也这样做。
在 Java 字节码上运行 D8(最低 API 级别小于 19 )会将 desugaring getClass() 的调用。
Objects.requireNonNull 是 D8 在很长一段时间内能够设计的唯一 API,它通过简单的重写实现了这一点。但很快,它的 desugaring 能力将不得不扩大,以实现更多功能。
2. Kotlin 中的 Java 8
与 Java 编译器不同,Kotlin 编译器在为其语言特性生成字节码时会发出对许多 API 的引用。数据类是编译器代表您生成大量字节码的示例。
在 Kotlin 的 1.1.60 版本中,当设置为目标编译为 Java 8 时,一个 data class 的 hashCode 方法会被指向为一些 Java 8 APIs。
编译器可以自由调用 Long.hashCode,因为我们告诉它我们的目标是 Java8。
通常这对 Android 来说不是问题,因为 Kotlin 编译器默认以 Java6为目标。不幸的是,社区将 Java8 作为其语言特性的目标,让 Kotlin 编译器 kotlin 1.3 中 Java 编译器与指定目标的决定的交互很差。结果,Android 开发人员开始发现这些 hashCode 调用初出现的 NoSuchMethodError 异常,因为它们只在 API 24 和更新版本中可用。
虽然 Kotlin 编译器的行为在 Android 项目中被还原,但是 Android 项目使用的库仍然有可能以 Java8 为目标并引用这些方法。D8 团队决定介入并通过对 HashCodeAPI 进行分解来缓解这个问题。
在 Java 字节码上运行 D8(最低 API 级别小于 24)显示了 desugaring 的过程。
我不确定您希望 Long.hashCode 如何被 desugaring,但是我猜测肯定不是生成一个名为 $r8$backportedMethods$utility$Long$1$hashCode 的类。不同于 Objects.requireNonNull 被重写为 getClass() 来减少异常,Long.hashCode 有一个实现,它不能通过简单的重写来复制。
3. Backporting Methods
在 D8 项目中,每个 API 都有模板实现,它可以对其进行向后兼容。
这些 API 的代码要么是从方法的 Javadoc 规范编写的,要么是从 googleguava 之类的库改编的。构建 D8 时,这些模板将自动转换为方法体的抽象表示形式。
当 D8 编译字节码时,首先遇到对 Long.hashCode 的调用,它使用 hashCode 方法动态生成一个类,hashCode 方法的主体是通过调用工厂方法创建的。然后重写每个 Long.hashCode 调用以指向这个新生成的类。
这个过程的处理允许 Java8 目标数据类在 API 24 之前的 Android 版本上工作。如果仔细观察,您可能会将每个 Dalvik 字节码映射回抽象表示,然后再映射回模板源代码。
为每个方法生成一个类听起来可能有些过分,但这确保了每个 API 只有一个实现需要 backporting。在使用 R8 时,这些合成类还参与优化,如方法内联和类合并,最终减少了它们的影响。
D8 可以对添加到现有类型中的 Java7 和 Java8 中的 98 个单独 API 进行 desugar。但为什么停在那里?
由于添加这些模板非常容易,D8 还可以在现有类型上从 Java9、Java10 和 Java11 中另外设计 58 个单独的 API。这使得 Java 库可以针对更新版本的 Java,并且仍然可以在 Android 上使用。
你可以在这里找到 desugar 可用的 API 的完整列表。其中大部分已经在 AGP3.6.0 中提供。
4. 向后兼容 Types
如同 Optional、Function、Stream 和 LocalDateTime 这类 Java 8 中的类型,直到 API 24 和 API 26 才被添加到 Android 中。由于一些原因,将这些方法进行向后兼容以确保在较旧的 API 级别上能使用要比将单个方法进行向后兼容复杂得多。
LocalDateTime 是在 API 26 中引入的,只有 minimum API 26 的 App 才能直接使用。
为了在最小 API 低于 26 时启用这些类型,Android Gradle Plugin 4.0或以上版本要求您在其 DSL 中启用 core library desugaring。
重新编译将更改字节码来向后兼容类型。
可以看到 java.time.LocalDateTime 的调用被重写为 j$.time.LocalDateTime,但是,APK 的其他部分发生了巨大的变化。
使用 diffuse tool,我们可以获得更改的高级视图。
通过上面的总结,可以得出两个结论:
我们的
APK大小增长了43.4KB,这完全是由于dex文件引起的。从dex的变化来看,有很多新的类、方法和字段。dex文件的数量从一个增加到了两个,尽管总方法的数量远远没有达到极限。这些是发布版本,所以我们应该得到最少数量的dex文件。让我们把每一个都分解一下。
4.1 APK 大小的影响
历史上,为了在最低支持 API 级别低于 26 的应用程序中使用 java.time API,您需要使用ThreeTenBP 库(或 ThreeTenABP)。这是 org.threeten.bp 包中 java.time API 的独立重新打包,需要更新所有导入。
D8 基本上执行相同的操作,但在字节码级别。它将代码从调用 java.time 重写为 j$.time,如上面字节码 diff 所示。为了与重写一起进行,需要将实现绑定到应用程序中。这就是 APK 大小变化较大的原因。
在本例中,使用 R8 压缩版本的 APK,R8 还压缩了向后兼容的代码。如果禁用压缩,索引大小的增加将跳到 180KB、206 个类、3272 个方法和 713 个字段。
4.2 第二个 Dex 包
发布版本将导致 D8 或 R8 生成所需的最少数量的 dex 文件,实际上这里仍然是这样。D8 和 R8 负责为用户代码和声明的库生成 dex 文件。这意味着只有主类型将出现在第一个 dex 中,我们可以通过转储其成员来确认。
当 D8 或 R8 编译代码并对 j$ 包执行重写时,它们会记录被重写的类型和 API。这将生成一组特定于向后兼容类型的收缩器规则。目前(即,对于 AGP4.0.0-alpha06),这些规则位于build/intermediates/desugar_lib_project_keep_rules/release/out/4,对于本例,仅包含 LocalDateTime.now() 引用。
所有可用的向后类型兼容处理已经被从 OpenJDK 源码预编译为 dex 并作为 Google’s desugar_jdk_libs 中的一部分。dex 文件从 Google 的 maven repo 下载,然后与生成的 keep 规则一起输入到一个名为 L8 的工具中。L8 使用提供的规则独立地收缩这个 dex 文件,以生成最后的第二个 dex 文件。
Dumpling L8 缩小的第二个 dex 文件会显示一组类型和 API,这些类型和 API 除了应用程序正在引用的 LocalDateTime.now() API 外,都已完全混淆。
L8 是专门为处理这个特殊的 dex 文件而构建的。在本系列之前,R8 在这篇文章中被介绍到:
…a version of D8 that also performs optimization. It’s not a separate tool or codebase, just the same tool operating in a more advanced mode.
L8 是 R8 的一个版本,它优化了 JDK desugar dex 文件。它不是一个单独的工具或代码库,只是同一个工具在更高级的模式下运行。
可能还不清楚为什么需要显式额外的 dex,而不是像任何其他库一样使用经过 desugaring 的 JDK 类型,并允许 R8 正常处理它们。首先,谷歌可能不想让我谈论它,这本身应该是一个迹象,为什么需要额外的仪式。有关更多信息,您可以参考 OpenJDK 源代码许可证,特别是最新版本。抱歉,如果这是不够的信息,但我怀疑这是所有我可以说的。
由于总是需要至少一个第二个索引,所以您要么需要至少支持 21 个 API,要么使用 legacy multidex。大多数应用程序应该选择前者,或者使用此功能作为另一个理由,可能会将最小值增加到21。
4.3 向后兼容方法和类型
除了对自 API1 以来就存在的类型(如 Long)的向后兼容方法外,D8 和 R8 还将对这些向后兼容类型(如 Optional)的较新方法进行兼容。它们使用与前面详述的模板机制相同的模板机制,但仅当您的最低 API 级别足够高,可以访问目标类型,或者您启用了核心库 desugaring 时才可用。
对于 Stream 和四种不同的可选类型,D8 和 R8 将从 java9、10 和 11 中备份 18 个方法。
5. 开发者故事
作为一个希望使用这些 API 编写代码的开发人员,您如何知道哪 API 做了向后兼容?目前还没有一个很好的方法来了解他们。
首先,启用 coreLibraryDesugaring 后,IDE 和 Lint 将开始允许您在支持时使用新类型和新 api。在本例中运行 Lint 不会产生任何错误,尽管支持的最低 API 低于LocalDateTime 所需的 26。但是,当库 desugaring 被禁用时,NewApi 检查会像平常一样失败。
这可以确保您不会错误地使用不受支持的类型或 API,但对可发现性没有帮助。
目前,最好的向后兼容类型列表在 Android Studio 4.0 feature list 特性列表中,现有类型的兼容 API 列表是本文中的两个列表(1,2)。不过,希望在未来这些会更容易被发现。
自 D8 和 R8 问世以来,各个 API 的向后兼容一直在改进。随着 Android Gradle plugin 4.0 alphas 提供了核心库去糖功能,应用程序可以从 Java8 访问基础类型,即使它们的最低支持 API 级别低于引入这些类型时的级别。这也意味着 Java 库可以开始利用这些类型,同时保持与 Android 的兼容性。
重要的是要记住,即使有了这些闪亮的新 API 可用性,JDK 和 JavaAPI 也在持续改进,这是他们六个月的发布节奏。虽然 D8 和 R8 可以通过将 Java9、10 和 11 中的一些 API 从 Java9、10 和 11 中删除,从而帮助弥补差距,但必须保持压力,将这些 API 实际运到 Android 框架中。











评论