Android(安卓)的 Java 8 支持
本文原文出自 jakewharton 关于 D8 和 R8 系列文章第一篇。
- 原文链接 : Android’s Java 8 Support
- 原文作者 : jakewharton
- 译者 : 小伟
我在家办公已经有几年了,在此期间,我听到周围的人抱怨 Android 对 Java 不同版本的支持力度。在每年的 Google I/O 大会上,你都会发现我针对这个问题在 fireside chats 环节提问或直接问负责人。但是这是一个复杂的话题,因为讨论 Android 对 Java 能支持到什么程度我们也不清楚,每一个 Java 版本中涉及到:语言特性(the language features)、字节码(the bytecode)、工具(the tools)、APIs、JVM 以及其它方面。
当人们谈论起 Android 对 Java 8 的支持通常指的是语言特性,所以接下来让我们一起开始看看 Android 的工具链是如何处理支持 Java 8 语言特性的。
1. Lambda
Java 8 中最大的语言特性变动是增加了 lambda
,相比以前使用更冗长的构造(如匿名类),lambda
带来了一个更简洁的代码格式。
class Java8 { interface Logger { void log(String s); } public static void main(String... args) { sayHi(s -> System.out.println(s)); } private static void sayHi(Logger logger) { logger.log("Hello!"); }}
通过 javac
指令编译为字节码后,然后通过 dx
工具编译打包为 dex
文件,但是出错了。
$ javac *.java$ lsJava8.java Java8.class Java8$Logger.class$ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.classUncaught translation error: com.android.dx.cf.code.SimException: ERROR in Java8.main:([Ljava/lang/String;)V: invalid opcode ba - invokedynamic requires --min-sdk-version >= 26 (currently 13)1 error; aborting
这是因为 lambda
使用了 invokedynamic
字节码指令,invokedynamic
是在 Java 7 中引入的。上面的错误信息提示,Android 支持这种字节码的最低版本是 26。与此同时 Android 使用 desugaring
(脱糖)兼容所有 API 版本上使用 lambda
表达式。
2. Desugaring(脱糖)的历史
脱糖工具的发展史非常出彩,但是它的核心目标却是一致的:让所有的 Java 语言新特性都能运行在所有设备上。
Retrolambda 是最初支持 lambda
表达式的第三方工具库,它通过在编译时利用 JVM
指令将 lambda
转换为内部类来实现。然而生成的类会使方法数激增,但是随着时间的推移,使用该工具的成本降低到了合理的水平。
然后,Android 工具团队宣布了一个新的编译器,它将提供 Java 8 语言特性的支持,以及更好的性能。该工具是建立在 Eclipse Java 编译器上的,而不是 Dalvik Java 字节码之上的。虽然处理 Java 8 效率很高,但是它的体验很差以及无法与别的工具兼容。
最终新的编译器被舍弃,同时在 Android Gradle plugin 中引入了谷歌定制的字节码构建系统,因为脱糖是增量式的,所以脱糖的输出效率仍然不是很理想,与此同时,正在进行的工作有了更好的方案。
D8 编译工具问世了。D8
编译工具用来替代老的 dx
工具,同时在 D8
中集成了脱糖,以此取代脱糖作为一个独立的字节码转换模块的方式。D8
相比较 dx
有很大的提升,带来了更有效率的字节码转换。同时在 Android Gradle Plugin 3.1
中作为默认 dex
编译器,然后在 3.2 版本中 D8
又集成了脱糖。
3. D8
通过 D8
工具编译上面的例子成功了。
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class$ lsJava8.java Java8.class Java8$Logger.class classes.dex
同时我们可以通过 Android 提供的 dexdump
工具来查看 dex
文件内容,看看 D8
是如何脱糖的,由于 dexdump
会产生很多代码,我们只截取一部分。
$ $ANDROID_HOME/build-tools/28.0.2/dexdump -d classes.dex[0002d8] Java8.main:([Ljava/lang/String;)V0000: sget-object v0, LJava8$1;.INSTANCE:LJava8$1;0002: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V0005: return-void[0002a8] Java8.sayHi:(LJava8$Logger;)V0000: const-string v0, "Hello"0002: invoke-interface {v1, v0}, LJava8$Logger;.log:(Ljava/lang/String;)V0005: return-void…
在 main
方法中,对应 0000
位置创建了一个 Java8$1
类对象 INSTANCE
实例,但是我们的源文件中并不包含这个类,所以猜测这个类是由脱糖产生的。同时 main
方法的字节码中也没有包含任何 lambda
的实现,所以很可能是在 Java8$1
中实现的。在 0002
位置,INSTANCE
调用了 sayHi
方法,同时可以看到 sayHi
方法的参数是 LJava8$Logger
,所以基本可以确定 Java8$1
类实现了 lambda
中的接口。我们可以输出字节码进行验证。
Class #2 - Class descriptor : 'LJava8$1;' Access flags : 0x1011 (PUBLIC FINAL SYNTHETIC) Superclass : 'Ljava/lang/Object;' Interfaces - #0 : 'LJava8$Logger;'
SYNTHETIC
字节码标签代表着这个类是由系统产生,通过 Interfaces
可以看到 LJava8$1
类实现了 LJava8$Logger
接口。
现在 LJava8$1
的实现已经替代了 lambda
,我们可以通过查看 sayHi
方法的字节码实现。
…[00026c] Java8$1.log:(Ljava/lang/String;)V0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V0003: return-void…
在 sayHi
的字节码实现中,它调用了 Java8
类中的静态方法 lambda$main$0
,但是我们并没有在类中定义这个方法,所以我们只能查看下 Java8
类对应的字节码。
… #1 : (in LJava8;) name : 'lambda$main$0' type : '(Ljava/lang/String;)V' access : 0x1008 (STATIC SYNTHETIC)[0002a0] Java8.lambda$main$0:(Ljava/lang/String;)V0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;0002: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V0005: return-void
在这里我们通过 SYNTHETIC
标签可以确定 lambda$main$0
方法是由系统自动生成的,并且看到了 lambda
实现的方法体 System.out.println
。
通过上面的流程分析,我们可以推测出:lambda
的实现保持在原来的主类中,并且是私有的,别的类无法直接访问。
4. Source Transformation(源码模拟实现)
为了更好的理解脱糖是如何工作的,我们可以在源码的层面模拟实现,注意这里的模拟仅仅是为了加深理解,脱糖实际工作比这个要复杂的多。
class Java8 { interface Logger { void log(String s); } public static void main(String... args) { sayHi(s -> System.out.println(s)); } private static void sayHi(Logger logger) { logger.log("Hello!"); }}
第一步将 lambda
表达式移到同级的包私有方法。
public static void main(String... args) {- sayHi(s -> System.out.println(s));+ sayHi(s -> lambda$main$0(s)); }++ static void lambda$main$0(String s) {+ System.out.println(s);+ }
第二步生成一个内部类实现 Logger
接口,并且它的方法体调用刚才实现的 lambda
方法。
public static void main(String... args) {- sayHi(s -> lambda$main$0(s));+ sayHi(new Java8$1()); }@@ }++class Java8$1 implements Java8.Logger {+ @Override public void log(String s) {+ Java8.lambda$main$0(s);+ }+}
最后,因为 lambda
方法并没有依赖外部的任何类,所以我们在 Java8$1
内部创建一个单例对象来避免每次调用 lambda
方法都生成一个新对象。
public static void main(String... args) {- sayHi(new Java8$1());+ sayHi(Java8$1.INSTANCE); }@@ class Java8$1 implements Java8.Logger {+ static final Java8$1 INSTANCE = new Java8$1();+ @Override public void log(String s) {
最终我们经过脱糖生成的文件适用与所有 APIs
。
class Java8 { interface Logger { void log(String s); } public static void main(String... args) { sayHi(Java8$1.INSTANCE); } static void lambda$main$0(String s) { System.out.println(s); } private static void sayHi(Logger logger) { logger.log("Hello!"); }}class Java8$1 implements Java8.Logger { static final Java8$1 INSTANCE = new Java8$1(); @Override public void log(String s) { Java8.lambda$main$0(s); }}
实际上你在查看 lambda
表达式生成的 Dalvik
字节码时可能看到不是类似 Java8$1
的名称,而是像这样的 -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY
名称,这是由于命名规范不恰当引起的。
5. Native Lambdas
在上面我们通过 dx
工具编译 dex
文件时,错误信息提示我们最低的支持版本是 API 26。
$ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.classUncaught translation error: com.android.dx.cf.code.SimException: ERROR in Java8.main:([Ljava/lang/String;)V: invalid opcode ba - invokedynamic requires --min-sdk-version >= 26 (currently 13)1 error; aborting
所以如果我们在使用 D8
的时候指定 --min-api 26
版本,应该就不会报错了。
$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --min-api 26 \ --output . \ *.class
同样为了查看 D8
如何工作,我们还是查看 Java8
类的字节码。
$ javap -v Java8.classclass Java8 { public static void main(java.lang.String...); Code: 0: invokedynamic #2, 0 // InvokeDynamic #0:log:()LJava8$Logger; 5: invokestatic #3 // Method sayHi:(LJava8$Logger;)V 8: return}…
为了阅读方便我只截取了部分代码,但是我们同样可以在 main
方法中看到这里使用了 InvokeDynamic
指令,在 Code
表的 0
位置上,我们可以看到第二个参数是 0
,对应着 bootstrap method(引导方法)
。 bootstrap method(引导方法)是当字节码第一次执行时首先被执行的一小段代码。
…BootstrapMethods: 0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:( Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String; Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType; Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;) Ljava/lang/invoke/CallSite; Method arguments: #28 (Ljava/lang/String;)V #29 invokestatic Java8.lambda$main$0:(Ljava/lang/String;)V #28 (Ljava/lang/String;)V
在上面的代码中,bootstrap method
(引导方法)对应的是 java.lang.invoke.LambdaMetafactory 类中的 metafactory
方法。LambdaMetafactory
类在运行时为 lambda
表达式生成匿名类,而 D8
是在编译时生成。
如果我们查看 Android documentation for java.lang.invoke 和 AOSP source code for java.lang.invoke 的文档,我们可以注意到这个类在 Android Runtime
中不存在,这也是为什么脱糖在编译时要求最小版本的原因。VM 环境支持 invokedynamic
指令,但是 JDK 在编译 LambdaMetafactory 中却不可用。
6. Method References(方法引用)
除了 lambda
表达式,方法引用也是 Java 8 的语言特性,当 lambda
的实现是一个已经存在的方法,此时使用方法引用会很方便。
public static void main(String... args) {- sayHi(s -> System.out.println(s));+ sayHi(System.out::println); }
这与 javac
和 dexes
与 D8 的编译是相同的,与 lambda
版本有一个显著的区别。在编译为 dalvik
字节码时,生成的 lambda
类的主体已更改。
[000268] -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM.log:(Ljava/lang/String;)V0000: iget-object v0, v1, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;.f$0:Ljava/io/PrintStream;0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V0005: return-void
不是通过生成 Java8.lambda$main$0
方法然后调用 System.out.println
的方式实现,而是直接调用 System.out.println
方法。lambda
表达式调用类也不是一个静态单例,而是直接使用 PrintStream
类实例引用,即 System.out
,它的调用如下。
[0002bc] Java8.main:([Ljava/lang/String;)V0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;0003: new-instance v0, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;0004: invoke-direct {v0, v1}, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;.<init>:(Ljava/io/PrintStream;)V0008: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V
同样我们也可以在源码级层面进行模拟。
public static void main(String... args) {- sayHi(System.out::println);+ sayHi(new -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(System.out)); }@@ }++class -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM implements Java8.Logger {+ private final PrintStream ps;++ -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(PrintStream ps) {+ this.ps = ps;+ }++ @Override public void log(String s) {+ ps.println(s);+ }+}
7. Interface Methods(接口中的方法)
在 Java 8 中新增了接口方法中的 default
和 static
修饰符。接口中的 static
方法允许直接操作调用。接口中的 default
方法允许你为接口添加默认实现方法。
interface Logger { void log(String s); default void log(String tag, String s) { log(tag + ": " + s); } static Logger systemOut() { return System.out::println; }}
D8
中的脱糖都已经支持了这两个接口的新特性。通过上面的方法同样可以分析出脱糖是如何进行优化工作的,具体的分析就留给读者了。
8. Just Use Kotlin?
这个时候肯定有很多读者猜想 Kotlin
是否也具备这种能力。当然,Kotlin 同样提供了 lambda 和接口中的 static
和 default
方法。这些特性都被 kotlinc
以相同的方式实现。
Android 工具和 VM 的开发者肯定会 100% 支持 Kotlin 实现 Java 语言的新特性。因为每次的 Java 新版本都会在字节码构建和 VM 上带来新的优化体验。
在未来和可能 Kotlin
不会支持 Java 6 和 Java 7,Intellij 开发工具已经在在 2016 年 1 月迁移至 Java 8。
9. Desugaring APIs
上面的分析中,我们一直关注的是 Java
语言新特性,其它还有一些主要的方面没有提及,比如新的 APIs
。在 Java 8 转给你带来了很多新的 APIs,比如 stream
、Optional
、CompletableFuture
以及新的 date/time API 等等。
回到上面的例子,我们使用新的 date/time API 来输出日志打印的时间。
import java.time.*;class Java8 { interface Logger { void log(LocalDateTime time, String s); } public static void main(String... args) { sayHi((time, s) -> System.out.println(time + " " + s)); } private static void sayHi(Logger logger) { logger.log(LocalDateTime.now(), "Hello!"); }}
我们同样使用 javac
指令和 d8
指令进行编译:
$ javac *.java$ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class
当编译完成后,我们可以将它运行在一个手机或模拟器中。
$ adb push classes.dex /sdcardclasses.dex: 1 file pushed. 0.5 MB/s (1620 bytes in 0.003s)$ adb shell dalvikvm -cp /sdcard/classes.dex Java82018-11-19T21:38:23.761 Hello
如果我们的设备运行在 API26
或更高的版本上我们会得到一个带有时间戳的日志。但是在一个低于 API26 的机器上,得到确实异常信息。
java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime; at Java8.sayHi(Java8.java:13) at Java8.main(Java8.java:9)
显然,D8
通过脱糖使 lambda
表达式能够运行在所有的 API
版本机器上,但是却没有对新 API
做任何处理,所以我们无法使用 LocalDateTime
类。也说明我们仅仅能够利用部分的 Java 8 新特性,而不是全部。
针对这种情况,开发者可自行编译组件引用或使用相关的第三方实现库来解决,但是退一步讲,既然开发者可以自己编译或实现,为什么 D8
不能在脱糖中为我们做这些呢?
其实 D8
现在仅仅针对 Throwable.addSuppressed
这个 API
进行实现,这个 API
是用于 Java 7 引入的语言特性 try-with-resources
。
我们需要 Java 8 API 在所有设备上工作,我们所需要的只是 D8
团队在他们的脱糖工具中添加支持来进行重写。您可以在 Android 问题跟踪程序上添加 D8
功能请求,以传达您的支持。
10. 总结
虽然一段时间以来,语言特性的脱糖已经以各种形式出现,但是缺乏对新 API
的适配仍然是我们生态系统中的一个巨大缺陷。不然直到绝大多数应用程序能够指定最小 API 26 的那一天,Android 工具链缺少 API 的缺陷才算停止阻碍 Java 库生态系统的发展。
尽管现在 Java 8 语言特性脱糖是 D8
的一部分,但默认情况下它没有启用。开发人员必须明确地选择它们的源代码和目标兼容性到 Java 8。Android 库的作者可以通过使用 Java 8 字节码来构建和发布它们的库(即使你不使用语言特性)。
D8
正在积极工作,因此 Android 对 Java 语言和 API 支持的前景仍然光明。即使你仅仅是一个 KOTLIN 用户,重要的是要保持对 Android 的压力,以支持更好的字节码和新 API 的 Java 新版本。在某些情况下,D8
实际上是超越 Java 8 版本的,我们将在下一篇文章中进行探索。
更多相关文章
- Android(安卓)Interface Definition Language (AIDL)——翻译自d
- Android下如何卸载和格式化sdcard
- 乱七八糟的android心得
- Android从入门到放弃——四大组件之Service(1)
- Android(安卓)Doze模式分析
- java中org.xml.sax不能读取xml回车换行的问题解决(android)
- 解析Android中的Context
- Android(安卓)中的Java跟C/C++的Binder通信
- Android开发教程 如何优化andriod布局