Android's Java 8 Support (Android Java 8支持)

本文翻译自:https://jakewharton.com/androids-java-8-support
是技术大神jake wharton的一篇文章,本人能力有限,如果哪里有翻译错误请指出来:
原文:

我已经在家工作了几年,在此期间我听到了程序员们关于Android对不同版本的Java的不断变化的支持的抱怨。每年在Google开发者大会上你都会发现我在篝火聊天询问这些问题或者直接向负责人反馈。在会议上或者其他的开发者活动里,这些讨论和抱怨或多或少也会出现在对话中。这是一个复杂的话题,因为我们对Android的Java支持的讨论是模糊的。对一个版本的Java来说有太多东西:语言特性,字节码,工具,API,JVM等等。
当人们讨论Android的Java 8支持的时候通常指的是语言特性。那么让我们来看看Android的工具链如何处理Java 8的语言特性。

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编译了这个程序之后,用dx工具(dx 是android 把jar转成dex的工具,不确定是这个,如果错误请指出)运行它报了这个错误。

$ 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表达式使用了更新的字节码invokedynamic,它在Java 7中被添加进来。正如错误信息所表明,Android对于这种字节码的支持至少是26版本。在写作时很多东西对于应用是不确定的。作为替代,一个叫做“desugaring”的进程被用来把lambda转换为所有android api能兼容的表达。

Desugaring 历史(解释:https://codeday.me/bug/20170721/45291.html)

参考:https://medium.com/@z125617/什麼是d8-dexer-5dd8ff41a3d1
Android工具链的脱糖过程是丰富多彩的。他们的目的是一致的:使更新的语言特性在所有的设备上能运行。
最初一个叫做Retrolambda的第三方工具被应用。他利用内置的机制,这个机制是JVM通常在运行时而不是编译时把lambda转换为类。依据方法的数量,这些生成的类代价非常高。但是这个工具的所做的超时的工作也降低了一些成本。
Android工具组随后发布了一个新的编译器,它可以以更好的性能支持Java 8新特性脱糖。它被内置在Eclipse Java编译器上,但是它支持Dalvik字节码而不是Java字节码。这种Java脱糖非常有效率,但是采用少的话,性能就会更差,并且与其他工具的整合也几乎不支持。
当这个新的编译器被废除时(谢天谢地),一个支持脱糖的Java字节码到Java字节码的转换器被整合进了Android Gradle插件。完成这个工作的是Google的定制系统,Bazel。这个脱糖过程很有效但性能依旧不是很好。它没有最终定型,但是为了一个更好的解决方案的工作仍然在进行中。
D8被发布去取代dx,在dex过程中脱糖,而不是在单独的Java字节码转换过程。d8相比较dx稳定性和效率都提升了一大截。它现在是Gradle插件3.1版本的默认dexer(dex编译器),而且它会在3.2版本变得更加可靠用于脱糖。

D8

使用D8把上面的例子成功编译成Dalvik字节码。

$ 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脱糖,我们可以使用dexdump工具,它是Android sdk的一部分。这个工具会输出很多,当然我们只看与我们相关的部分。

$ $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
…

如果你之前没有看过任何字节码(Dalvik或其他的),不用担心,大部分是可以看懂的。
在第一段我们的主函数里,字节码0000检索了一个引用,这个引用指向Java8$1类的一个静态实例。因为最初不包含Java8$1类,我们可以推论它是由脱糖生成的。主函数同样不包含任何lambda的痕迹,因此lambda大概率与Java8 1 0002 s a y H i s a y H i J a v a 8 1类有关。索引0002随后用静态实例调用了静态的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$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类中的静态方法,叫做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本体在最初的类里的原因是它可以访问私有变量而生成的类没法访问。
如何理解脱糖工作的关键点就在这里了。尽管看Dalvik字节码是比较困难的。

源变换

为了更好地理解脱糖工作,我们可以在源代码级别去操作变换。它并不是真正这么工作的,但是这是一个能够理解在这之间发生了什么并且理解字节码的很好的练习。
再一次地,我们从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体被移到一个包私有的函数:

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

然后,一个实现了目标接口的类生成了,并且这个类调用了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没有捕获任何状态,一个单例被创建并且存储在一个静态变量里。

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 l a m b d a 1的lambda类。它真实的名字将会如下:- L a m b d a Lambda Java8$QkyWJ8jlAksLjYziID4cZLvHwoY。如此命名的原因和优势将会在另一篇文章指出。。

Native Lambdas

当我们使用dx尝试去编译lambda-包含Java字节码到Dalvik字节码,相应的错误信息表明它只在26及以上的Android 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

因此,如果你重新运行D8并且指定–min-api 26,有一种合理的假设:native lambda将会被使用并且脱糖不会真正发生。

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

但是如果你打印dex文件,你仍会发现-$ L a m b d a Lambda Java8$QkyWJ8jlAksLjYziID4cZLvHwoY类被生成。可能是一个D8 的Bug?
为了学习脱糖的发生,我们需要在Java 8类中去看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
}
…

为了可读性,输出被截取了。但是在主函数中你将会在索引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

在这种情况下,在java.lang.invoke.LambdaMetafactory类中辅助方法叫做metafactory。这个类在JDK中并且负责为运行时的lambda创建匿名类,这与D8在编译时创建它们是类似的。
如果你看了java.lang.invoke的Android文档,或者关于它的AOSP源码,你会发现这个类没有在Android运行时展示。这就是为什么脱糖经常发生在编译期间,无论你的API版本是多少。虚拟机针对invokedynamic都有字节码支持,但是JDK自带的LambdaMetafactory 是无法使用的。

Method References

此外,方法引用同样在Java8中被添加。它很方便去创建一个指向存在方法的lambda 。
logger 例子已经是一个lambda 调用了一个方法System.out.println。我们可以使用方法引用来节省代码:

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

这个用javac 的编译和用D8 编译的有显著不同。当我们打印Dalvik 字节码时,生成的lambda body已经变化了。

[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

这次没有调用生成的包含System.out.println的Java8.lambda$main$0,log 的实现这次直接调用了System.out.println 。
lambda 类也不再是一个静态单例。字节码0000读到了一个PrintStream的实体变量。这个引用是System.out,它在main的调用现场中被解析,并且直接传入构造函数(在字节码中被称为)。

[0002bc] Java8.main:([Ljava/lang/String;)V
0000: 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;)V
0008: 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);
+  }
+}

接口方法

Java8 的另一个语言特性是在接口中包含static 和default方法。其中的静态方法允许在它们操作的接口类型上提供实例工。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脱糖。使用上面的工具可以知道为何脱糖在所有api都支持。这些将留给读者学习。
值得注意的是,这些特性都在 Android 24的 VM被实现。不像lambdas 和method references,给D8加上–min-api 24将使接口函数新特性不用被脱糖。

仅仅使用kotlin?

到此为止,绝大多数读者在这种情况上将会考虑kotlin。是的,kotlin支持lambda和方法引用,可以像传递数据那样传递代码。是的,kotlin提供了 default 和static的接口函数。所有这些特性就像D8脱糖那样实现了这些新特性。
Android的工具链和VM对于新的Java特性的支持仍然很重要,即使你所有的代码都用kotlin去写。新版本的Java带来了更多的字节码和vm的特性,kotlin同样可以利用它们。
在未来的某个时候,kotlin停止支持java6或者java7是可能的。2016年一月,IntelliJ 平台已经迁移到了Java8。Gradle 5.0已经移到了Java8。跑在旧版本的JVM的平台日益减少。没有Java8和VM字节码的支持,Android将会陷入倒退的危险。谢天谢地,D8 和ART及时跟上保证这没有发生。

脱糖API

迄今本文聚焦在语言特性和新的Java版本的字节码上。另一个Java新版本的好处是新的API。Java8带来了很多API:streams,Optional,functional interfaces,CompletableFuture和新的时间API。
回到最初的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 编译,并且用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。不过在低于26的版本的设备上获得的结果是截然不同的。

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

D8已经通过脱糖让lambda在所有的API上使用。但是LocalDateTime却无法在低版本上被支持。这很让人失望,因为你只能看到部分新的Java8特性,而不是所有的。
开发者可以选择去使用ThreeTenBP 这个时间库去实现如上API类似的功能。如果你手动重写这些代码,为何不用D8脱糖?
其实D8已经做了这些,但是仅仅对了某个API:Throwable.addSuppressed。
这个API允许Java 7的try-with-resources语言功能在所有版本的Android上运行,尽管API只能在API 19使用。
我们所有需要做的让Java8可以在所有版本的Android上运行的是:我们可以打包在APK中兼容的实现。之前提到的 Bazel小组已经做了这些工作。他们重写的代码不能直接使用,但是这些不同版本的JDK重新打包可以。我们需要D8 团队去让他们的脱糖工具同样支持这些。你可以在D8特性请求提交你的需求。

当语言特性上的脱糖在某个时间可以支持了,API脱糖的支持不足使我们生态的一个缺陷。直到今天大多数APP指定最低版本是26,Android 的工具链的API脱糖的缺乏拖了Java类库的生态系统。能够同时支持Android和JVM的类库无法使用Java8的问题五年前就被提出来了!
尽管Java8语言特性脱糖成为了D8的一部分,它并没有默认被支持。开发者必须指定source 和target的Android版本去使用Java 8.Android 库的作者可以通过发布Java8字节码的库去引导这样的趋势。
D8正在被不断完善,未来对于Java语言和API支持是光明的。尽管你是一个kotlin使用者,掌握Android对于Java新版本的支持同样很重要。而且在某些场合,D8还走在java发展的前面。
(这篇文章节选自Digging into D8 and R8演讲。)

— Jake Wharton

本人水平着实有限,很多地方可能翻译不合理,希望大家担待,找出不足并指出来,我会做改正的,谢谢~

猜你喜欢

转载自blog.csdn.net/y505772146/article/details/86438781