[译] 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
框架中。
评论