Android如何支持Java 8

哈Ha! 我提请您注意, 臭名昭著的杰克·沃顿(Jake Worton)撰写的一系列精彩文章的翻译,内容涉及Java如何支持Android 8。



原始文章在这里

我在家工作了几年,经常听到同事抱怨Android支持不同版本的Java。

这是一个相当复杂的话题。 首先,您需要确定“ Android中的Java支持”的含义,因为在该语言的一个版本中,可能有很多东西:功能(例如,lambda),字节码,工具,API,JVM等。

当他们谈论Android中对Java 8的支持时,通常是指语言功能的支持。 因此,让我们从它们开始。

Lambdas


lambda是Java 8的主要创新之一。
代码变得更加简洁明了,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和legacy dx tool对此进行编译后,会出现以下错误:

 $ javac *.java $ ls Java8.java Java8.class Java8$Logger.class $ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.class Uncaught 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使用Java 7中添加的字节码invokedynamic的新指令这一事实。从错误文本中,您可以看到Android仅从26 API(Android 8)开始支持它。

听起来不太好,因为几乎没有人会发布具有26 minApi的应用程序。 为了解决这个问题,使用了所谓的desugaring流程,该流程使lambda支持在所有版本的API上都可以实现。

糖化历史


她在Android世界中非常丰富多彩。 脱糖的目标始终是相同的-允许新的语言功能在所有设备上都能使用。

最初,例如,为了在Android中支持lambda,开发人员连接了Retrolambda插件。 他使用了与JVM相同的内置机制,将lambda转换为类,但是他是在运行时而不是在编译时执行的。 就方法的数量而言,生成的类非常昂贵,但是随着时间的流逝,经过不断的改进和改进,该指标或多或少地变得合理。

然后,Android团队宣布了一个支持所有Java 8功能且生产率更高的新编译器 。 它建立在Eclipse Java编译器的基础上,但是没有生成Java字节码,而是生成了Dalvik字节码。 但是,其性能仍然有很多不足之处。

幸运的是,当新的编译器被放弃时,负责处理的Java字节码中的Java字节码转换器已从Google的构建系统Bazel 集成到Android Gradle插件中 。 而且它的性能仍然很低,因此并行寻求更好的解决方案。

现在我们看到 dexer - D8 ,它应该可以代替dx tool 。 现在,在将已编译的JAR文件转换为.dex (dexing)的过程中执行了脱糖操作。 与dx相比,D8的性能要好得多,并且由于Android Gradle Plugin 3.1,它已成为默认的dexer。

D8


现在,使用D8,我们可以编译上面的代码。

 $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class $ ls Java8.java Java8.class Java8$Logger.class classes.dex 

要查看D8如何转换lambda,您可以使用Android SDK中随附的dexdump tool 。 它会显示很多东西,但是我们将只关注于此:

 $ $ANDROID_HOME/build-tools/28.0.2/dexdump -d classes.dex [0002d8] Java8.main:([Ljava/lang/String;)V 0000: sget-object v0, LJava8$1;.INSTANCE:LJava8$1; 0002: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V 0005: return-void [0002a8] Java8.sayHi:(LJava8$Logger;)V 0000: const-string v0, "Hello" 0002: invoke-interface {v1, v0}, LJava8$Logger;.log:(Ljava/lang/String;)V 0005: return-void … 

如果您还没有阅读字节码,请不要担心:这里写的大部分内容都可以直观地理解。

在第一个块中,索引为0000 main方法从INSTANCE字段获取对INSTANCEJava8$1的引用。 该类是在过程中生成的。 main方法字节码也没有包含我们的lambda的任何内容,因此,很可能与Java8$1类相关联。 索引0002然后使用到INSTANCE的链接调用sayHi静态方法。 sayHi需要Java8$Logger ,因此似乎Java8$1实现了此接口。 我们可以在这里验证:

 Class #2 - Class descriptor : 'LJava8$1;' Access flags : 0x1011 (PUBLIC FINAL SYNTHETIC) Superclass : 'Ljava/lang/Object;' Interfaces - #0 : 'LJava8$Logger;' 

SYNTHETIC标志意味着已经生成Java8$1类,并且它包含的接口列表包含Java8$Logger
此类表示我们的lambda。 如果查看log方法的实现,您将看不到lambda的主体。

 … [00026c] Java8$1.log:(Ljava/lang/String;)V 0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V 0003: return-void … 

相反, Java8类的static方法Java8 lambda$main$0 。 我重复一遍,此方法仅以字节码表示。

 … #1 : (in LJava8;) name : 'lambda$main$0' type : '(Ljava/lang/String;)V' access : 0x1008 (STATIC SYNTHETIC) [0002a0] Java8.lambda$main$0:(Ljava/lang/String;)V 0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; 0002: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V 0005: return-void 

SYNTHETIC标志再次告诉我们该方法已生成,其字节码仅包含lambda主体:对System.out.println的调用。 lambda主体位于Java8.class内的原因很简单-它可能需要访问该类的private成员,生成的类将无法访问该private成员。

上面介绍了了解脱糖工作原理所需的所有内容。 但是,在Dalvik字节码中查看它,您会发现一切都变得更加复杂和令人恐惧。

源转换


为了更好地了解如何进行糖化 ,让我们逐步尝试将类转换为可在所有版本的API上使用的类。

让我们以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!"); } } 

首先,将lambda主体移至package private方法。

  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); + } +} 

接下来, Java8$1的单例实例,该实例存储在static变量INSTANCE

  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) { 

这是可以在所有版本的API上使用的最终配音类:

 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); } } 

如果您在Dalvik字节码中查看生成的类,将不会找到类似Java8 $ 1的名称-会有类似-$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY 。 为该类生成此类命名的原因及其优点是吸引到单独的文章中。

本机Lambda支持


当我们使用dx tool编译包含lambda的类时,出现一条错误消息,指出仅适用于26个API。

 $ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.class Uncaught 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 

因此,如果我们尝试使用—min-api 26标志进行编译,这似乎是合乎逻辑的,则不会发生脱糖作用。

 $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --min-api 26 \ --output . \ *.class 

但是,如果转储.dex文件,则仍可以在其中找到它-$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY 。 为什么这样 这是D8错误吗?

为了回答这个问题,以及为什么总是发生脱糖作用 ,我们需要查看Java8类的Java字节码。

 $ javap -v Java8.class class 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方法内部,我们再次在索引0处看到invokedynamic 。 调用中的第二个参数为0与之关联的引导方法的索引。

这是引导方法的列表:

 … 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方法在java.lang.invoke.LambdaMetafactory类中称为metafactory 。 他住在JDK中,并在运行时为lambda创建匿名的即时类,就像D8在计算时生成它们一样。

如果您查看 Android java.lang.invoke Android java.lang.invoke
AOSP java.lang.invoke AOSP java.lang.invoke ,我们看到此类不在运行时中。 这就是为什么无论您拥有什么minApi,都总是在编译时进行去杂耍。 VM支持类似于invokedynamic字节码指令,但是JDK内置的invokedynamic无法使用。

方法参考


Java 8与lambda一起添加了方法引用-这是创建其lambda主体引用现有方法的有效方法。

我们的Logger界面就是这样一个例子。 lambda主体引用到System.out.println 。 让我们将lambda转换为引用方法:

  public static void main(String... args) { - sayHi(s -> System.out.println(s)); + sayHi(System.out::println); } 

当我们编译它并查看字节码时,我们将看到与先前版本的不同之处:

 [000268] -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM.log:(Ljava/lang/String;)V 0000: 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;)V 0005: return-void 

而不是调用生成的Java8.lambda$main$0 (其中包含对System.out.println的调用),现在直接调用System.out.println

具有lambda的类不再是static单例,而是通过字节码中的索引0000 ,我们看到我们获得了指向PrintStream - System.out的链接,该链接随后用于对其调用println

结果,我们的班级变成了这样:

  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); + } +} 

接口中的Default方法和static方法


Java 8带来的另一个重要而重要的变化是能够在接口中声明default方法和static方法。

 interface Logger { void log(String s); default void log(String tag, String s) { log(tag + ": " + s); } static Logger systemOut() { return System.out::println; } } 

D8也支持所有这些。 使用与以前相同的工具,很容易看到具有default方法和static方法的Logger的登录版本。 lambda和method references的区别之一是,默认方法和静态方法是在Android VM中实现的,并且从24个API开始,D8不会它们解耦

也许只用Kotlin?


阅读本文时,大多数人可能会想到Kotlin。 是的,它支持所有Java 8功能,但是除了一些细节外,它们由kotlinc以与D8相同的方式实现。

因此,即使您的项目是100%用Kotlin编写的,Android对Java新版本的支持仍然非常重要。

将来Kotlin可能会停止支持Java 6和Java 7字节码, IntelliJ IDEA将从Gradle 5.0切换到Java8。运行在较早JVM上的平台数量正在减少。

脱糖API


一直以来,我都在谈论Java 8功能,但没有对新的API进行任何说明-流, CompletableFuture ,日期/时间等。

回到Logger示例,我们可以使用新的日期/时间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对其进行javac并使用D8将其转换为Dalvik字节码,从而解耦以支持所有版本的API。

 $ javac *.java $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class 

您甚至可以在设备上运行此程序以确保其正常运行。

 $ adb push classes.dex /sdcard classes.dex: 1 file pushed. 0.5 MB/s (1620 bytes in 0.003s) $ adb shell dalvikvm -cp /sdcard/classes.dex Java8 2018-11-19T21:38:23.761 Hello 

如果此设备上使用API​​ 26及更高版本,则会显示Hello消息。 如果没有,我们将看到以下内容:

 java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime; at Java8.sayHi(Java8.java:13) at Java8.main(Java8.java:9) 

D8处理了lambdas(一种参考方法),但与LocalDateTime无关,这很可悲。

开发人员必须在日期/时间api上使用自己的实现或包装器,或者使用ThreeTenBP类的ThreeTenBP来处理时间,但是为什么不能用自己的双手做D8?

结语


缺少对所有新Java 8 API的支持仍然是Android生态系统中的一个大问题。 毕竟,我们每个人不太可能允许我们在项目中指定26分钟的API。 同时支持Android和JVM的库无法使用5年前引入的API!

即使现在Java 8支持已成为D8的一部分,每个开发人员仍应在Java 8中明确指定源和目标兼容性。如果编写自己的库,则可以通过布局使用Java 8字节码的库来加强这种趋势。 (即使您没有使用新的语言功能)。

在D8上正在进行许多工作,因此在支持语言功能的情况下,似乎以后一切都会好起来的。 即使您仅在Kotlin上编写代码,强制Android开发团队支持所有新版本的Java,改进字节码和新的API也非常重要。

这篇文章是我的演讲“ 深入研究D8和R8”的书面版本。

Source: https://habr.com/ru/post/zh-CN478692/


All Articles