[译] D8 类库脱糖

6,689 阅读12分钟

这是我参与更文挑战的第 28 天,活动详情查看: 更文挑战

原文出自 jakewharton 关于 D8R8 系列文章第 15 篇。

至今在这个系列文章中,关于 D8 的文章有 Java 8 language features 脱糖、平台的vendor- and version-specific bugs 以及关于性能的 method-local optimization。在本文中,我们将介绍 D8 即将推出的一个名为“核心库去糖化”的特性,它使较新的 api 可以在较旧版本的 Android 上使用。

Java8 API(如 streamsoptionalnew time API)的库设计在 2019Google I/O 开发者大会上宣布支持,并在 2019Android DevSummit 大会上发布了 Android Studio 4.0 的首个金丝雀版本发布支持。这将允许开发人员在其应用程序目标的每个版本上使用API 2426 中引入的这些功能。

这也是 Java 库生态系统的一个好处。许多库早已转向 Java8,但无法使用更新的 API 来保持 Android 兼容性。虽然并非每个新 API 都可用,但 D8 desugaring 应该允许这些库使用最需要的 API

1. Not a new feature

尽管最近声势浩大,但对 API 进行去糖处理实际上并不是 D8 的一个新特性。自从 D8 成为 dx 的可用替代品以来,它已经取消了对 API19 Objects.requireNonNull 方法的调用。但是,为什么是这个方法呢?

某些代码的固定格式将导致 Java 编译器合成显式的空检查。

class Counter { 
  final int count = 0; 
} 
class Main { 
  void doSomething(Counter counter) { 
    int count = counter.count;
  } 
}

当使用 JKD 8 编译的时候,doSomething 方法的字节码中包含 getClass() 的调用,并直接进行了返回。

void doSomething(Counter);
  Code:
     0: aload_1
     1: invokevirtual #2   // Method java/lang/Object.getClass:()Ljava/lang/Class;
     4: pop
     5: iconst_0
     6: istore_2
     ⋮

从上面的字节码中可以看到,在第 5 行位置,将常量 0 直接内联到 doSomething 方法中了。因此,如果你传一个 null 作为 Counter 参数,你会得到 null-pointer exception 空指针异常,所以可以看到,通过对 getClass 的调用,保证了程序的正常运行。

如果用 JDK 9 重新编译这个代码段,字节码就会改变。

 void doSomething(Counter);
   Code:
      0: aload_1
-     1: invokevirtual #2   // Method java/lang/Object.getClass:()Ljava/lang/Class;
+     1: invokestatic  #2   // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
      4: pop
      5: iconst_0
      6: istore_2
      ⋮

JDK-8074306在这个场景中更改了Java 编译器的行为,以产生更好的异常。但是 Android 工具链在 JDK9(以及更新版本)上还不能正常工作,因此您可能想知道这些调用是如何产生的。

主要的代码是 Google’s error-prone 编译器和静态分析器,它与 JDK8 一起工作,但构建在 JDK9 编译器之上。虽然 error-prone 通过引入 off-by-default 标志解决了这个问题,但是 RetrolambdaAPI 添加了 desugaring,这基本上要求 D8 也这样做。

Java 字节码上运行 D8(最低 API 级别小于 19 )会将 desugaring getClass() 的调用。

[00016c] Main.doSomething:(LCounter;)V
0000: invoke-virtual {v1}, Ljava/lang/Object;.getClass:()Ljava/lang/Class;
 ⋮

Objects.requireNonNullD8 在很长一段时间内能够设计的唯一 API,它通过简单的重写实现了这一点。但很快,它的 desugaring 能力将不得不扩大,以实现更多功能。

2. Kotlin 中的 Java 8

Java 编译器不同,Kotlin 编译器在为其语言特性生成字节码时会发出对许多 API 的引用。数据类是编译器代表您生成大量字节码的示例。

data class Duration(val amount: Long, val unit: TimeUnit)

Kotlin1.1.60 版本中,当设置为目标编译为 Java 8 时,一个 data classhashCode 方法会被指向为一些 Java 8 APIs

public int hashCode();
  Code:
     0: aload_0
     1: getfield      #10   // Field amount:J
     4: invokestatic  #71   // Method java/lang/Long.hashCode:(J)I

编译器可以自由调用 Long.hashCode,因为我们告诉它我们的目标是 Java8

通常这对 Android 来说不是问题,因为 Kotlin 编译器默认以 Java6为目标。不幸的是,社区将 Java8 作为其语言特性的目标,让 Kotlin 编译器 kotlin 1.3Java 编译器与指定目标的决定的交互很差。结果,Android 开发人员开始发现这些 hashCode 调用初出现的 NoSuchMethodError 异常,因为它们只在 API 24 和更新版本中可用。

虽然 Kotlin 编译器的行为在 Android 项目中被还原,但是 Android 项目使用的库仍然有可能以 Java8 为目标并引用这些方法。D8 团队决定介入并通过对 HashCodeAPI 进行分解来缓解这个问题。

Java 字节码上运行 D8(最低 API 级别小于 24)显示了 desugaring 的过程。

[0003e4] Duration.hashCode:()I
0000: iget-wide v0, v2, LDuration;.amount:J
0002: invoke-static {v0, v1}, L$r8$backportedMethods$utility$Long$1$hashCode;.hashCode:(J)I
 ⋮

我不确定您希望 Long.hashCode 如何被 desugaring,但是我猜测肯定不是生成一个名为 $r8$backportedMethods$utility$Long$1$hashCode 的类。不同于 Objects.requireNonNull 被重写为 getClass() 来减少异常,Long.hashCode 有一个实现,它不能通过简单的重写来复制。

3. Backporting Methods

D8 项目中,每个 API 都有模板实现,它可以对其进行向后兼容。

public final class LongMethods {
  public static int hashCode(long l) {
    return (int) (l ^ (l >>> 32));
  }
}

这些 API 的代码要么是从方法的 Javadoc 规范编写的,要么是从 googleguava 之类的库改编的。构建 D8 时,这些模板将自动转换为方法体的抽象表示形式。

public static CfCode LongMethods_hashCode() {
  return new CfCode(
      /* maxStack = */ 5,
      /* maxLocals = */ 2,
      ImmutableList.of(
          new CfLoad(ValueType.LONG, 0),
          new CfLoad(ValueType.LONG, 0),
          new CfConstNumber(32, ValueType.INT),
          new CfLogicalBinop(CfLogicalBinop.Opcode.Ushr, NumericType.LONG),
          new CfLogicalBinop(CfLogicalBinop.Opcode.Xor, NumericType.LONG),
          new CfNumberConversion(NumericType.LONG, NumericType.INT),
          new CfReturn(ValueType.INT)));
}

D8 编译字节码时,首先遇到对 Long.hashCode 的调用,它使用 hashCode 方法动态生成一个类,hashCode 方法的主体是通过调用工厂方法创建的。然后重写每个 Long.hashCode 调用以指向这个新生成的类。

Class #0            -
  Class descriptor  : 'L$r8$backportedMethods$utility$Long$1$hashCode;'
  Access flags      : 0x1401 (PUBLIC ABSTRACT SYNTHETIC)
  Superclass        : 'Ljava/lang/Object;'
  Direct methods    -
    #0
      name          : 'hashCode'
      type          : '(J)I'
      access        : 0x1009 (PUBLIC STATIC SYNTHETIC)
00044c:                   |[00044c] $r8$backportedMethods$utility$Long$1$hashCode.hashCode:(J)I
00045c: 1300 2000         |0000: const/16 v0, #int 32
000460: a500 0200         |0002: ushr-long v0, v2, v0
000464: c202              |0004: xor-long/2addr v2, v0
000466: 8423              |0005: long-to-int v3, v2
000468: 0f03              |0006: return v3

这个过程的处理允许 Java8 目标数据类在 API 24 之前的 Android 版本上工作。如果仔细观察,您可能会将每个 Dalvik 字节码映射回抽象表示,然后再映射回模板源代码。

为每个方法生成一个类听起来可能有些过分,但这确保了每个 API 只有一个实现需要 backporting。在使用 R8 时,这些合成类还参与优化,如方法内联和类合并,最终减少了它们的影响。

D8 可以对添加到现有类型中的 Java7Java8 中的 98 个单独 API 进行 desugar。但为什么停在那里?

由于添加这些模板非常容易,D8 还可以在现有类型上从 Java9Java10Java11 中另外设计 58 个单独的 API。这使得 Java 库可以针对更新版本的 Java,并且仍然可以在 Android 上使用。

你可以在这里找到 desugar 可用的 API完整列表。其中大部分已经在 AGP3.6.0 中提供。

4. 向后兼容 Types

如同 OptionalFunctionStreamLocalDateTime 这类 Java 8 中的类型,直到 API 24API 26 才被添加到 Android 中。由于一些原因,将这些方法进行向后兼容以确保在较旧的 API 级别上能使用要比将单个方法进行向后兼容复杂得多。

class Main {
  public static void main(String... args) {
    System.out.println(LocalDateTime.now());
  }
}

LocalDateTime 是在 API 26 中引入的,只有 minimum API 26App 才能直接使用。

[000240] Main.main:([Ljava/lang/String;)V
0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: invoke-static {}, Ljava/time/LocalDateTime;.now:()Ljava/time/LocalDateTime;
0005: move-result-object v0
0006: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
0009: return-void

为了在最小 API 低于 26 时启用这些类型,Android Gradle Plugin 4.0或以上版本要求您在其 DSL 中启用 core library desugaring

android {
  compileOptions {
    coreLibraryDesugaringEnabled true
  }
}

重新编译将更改字节码来向后兼容类型。

 [000240] Main.main:([Ljava/lang/String;)V
 0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
-0002: invoke-static {}, Ljava/time/LocalDateTime;.now:()Ljava/time/LocalDateTime;
+0002: invoke-static {}, Lj$/time/LocalDateTime;.now:()Lj$/time/LocalDateTime;
 0005: move-result-object v0
 0006: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
 0009: return-void

可以看到 java.time.LocalDateTime 的调用被重写为 j$.time.LocalDateTime,但是,APK 的其他部分发生了巨大的变化。

使用 diffuse tool,我们可以获得更改的高级视图。

$ diffuse diff app-min-26.apk app-min-25.apk
OLD: app-min-26.apk (signature: V2)
NEW: app-min-25.apk (signature: V2)

          │          compressed          │         uncompressed
          ├───────┬──────────┬───────────┼─────────┬──────────┬─────────
 APK      │ old   │ new      │ diff      │ old     │ new      │ diff
──────────┼───────┼──────────┼───────────┼─────────┼──────────┼─────────
      dex │ 680 B │   44 KiB │ +43.4 KiB │   944 B │ 90.9 KiB │ +90 KiB
     arsc │ 524 B │    520 B │      -4 B │   384 B │    384 B │     0 B
 manifest │ 603 B │    603 B │       0 B │ 1.2 KiB │  1.2 KiB │     0 B
    other │ 229 B │    229 B │       0 B │    95 B │     95 B │     0 B
──────────┼───────┼──────────┼───────────┼─────────┼──────────┼─────────
    total │ 2 KiB │ 45.4 KiB │ +43.4 KiB │ 2.6 KiB │ 92.6 KiB │ +90 KiB


         │        raw        │           unique
         ├─────┬──────┬──────┼─────┬─────┬────────────────
 DEX     │ old │ new  │ diff │ old │ new │ diff
─────────┼─────┼──────┼──────┼─────┼─────┼────────────────
   count │   1 │    2 │   +1 │     │     │
 strings │  16 │ 1005 │ +989 │  16 │ 996 │ +980 (+983 -3)
   types │   7 │  175 │ +168 │   7 │ 170 │ +163 (+164 -1)
 classes │   1 │   88 │  +87 │   1 │  88 │  +87 (+87 -0)
 methods │   5 │  728 │ +723 │   5 │ 727 │ +722 (+724 -2)
  fields │   1 │  255 │ +254 │   1 │ 255 │ +254 (+254 -0)

通过上面的总结,可以得出两个结论:

  1. 我们的 APK 大小增长了 43.4KB,这完全是由于 dex 文件引起的。从 dex 的变化来看,有很多新的类、方法和字段。
  2. 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 压缩版本的 APKR8 还压缩了向后兼容的代码。如果禁用压缩,索引大小的增加将跳到 180KB206 个类、3272 个方法和 713 个字段。

4.2 第二个 Dex 包

发布版本将导致 D8R8 生成所需的最少数量的 dex 文件,实际上这里仍然是这样。D8R8 负责为用户代码和声明的库生成 dex 文件。这意味着只有主类型将出现在第一个 dex 中,我们可以通过转储其成员来确认。

$ unzip app-min-25.apk classes.dex && \
    diffuse members --dex --declared classes.dex
com.example.Main <init>()
com.example.Main main(String[])

D8R8 编译代码并对 j$ 包执行重写时,它们会记录被重写的类型和 API。这将生成一组特定于向后兼容类型的收缩器规则。目前(即,对于 AGP4.0.0-alpha06),这些规则位于build/intermediates/desugar_lib_project_keep_rules/release/out/4,对于本例,仅包含 LocalDateTime.now() 引用。

-keep class j$.time.LocalDateTime {
    j$.time.LocalDateTime now();
}

所有可用的向后类型兼容处理已经被从 OpenJDK 源码预编译为 dex 并作为 Google’s desugar_jdk_libs 中的一部分。dex 文件从 Googlemaven repo 下载,然后与生成的 keep 规则一起输入到一个名为 L8 的工具中。L8 使用提供的规则独立地收缩这个 dex 文件,以生成最后的第二个 dex 文件。

Dumpling L8 缩小的第二个 dex 文件会显示一组类型和 API,这些类型和 API 除了应用程序正在引用的 LocalDateTime.now() API 外,都已完全混淆。

$ unzip app-min-25.apk classes2.dex && \
    diffuse members --dex classes2.dex | grep -C 6 'LocalDateTime.now'
j$.time.LocalDateTime c(s)long
j$.time.LocalDateTime compareTo(Object)int
j$.time.LocalDateTime d() → h
j$.time.LocalDateTime d(s) → x
j$.time.LocalDateTime equals(Object)boolean
j$.time.LocalDateTime hashCode()int
j$.time.LocalDateTime now() → LocalDateTime
j$.time.LocalDateTime toString() → String
j$.time.a <init>(k)
j$.time.a a() → k
j$.time.a a: k
j$.time.a b() → f
j$.time.a c()long

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.

L8R8 的一个版本,它优化了 JDK desugar dex 文件。它不是一个单独的工具或代码库,只是同一个工具在更高级的模式下运行。

可能还不清楚为什么需要显式额外的 dex,而不是像任何其他库一样使用经过 desugaringJDK 类型,并允许 R8 正常处理它们。首先,谷歌可能不想让我谈论它,这本身应该是一个迹象,为什么需要额外的仪式。有关更多信息,您可以参考 OpenJDK 源代码许可证,特别是最新版本。抱歉,如果这是不够的信息,但我怀疑这是所有我可以说的。

由于总是需要至少一个第二个索引,所以您要么需要至少支持 21API,要么使用 legacy multidex。大多数应用程序应该选择前者,或者使用此功能作为另一个理由,可能会将最小值增加到21

4.3 向后兼容方法和类型

除了对自 API1 以来就存在的类型(如 Long)的向后兼容方法外,D8R8 还将对这些向后兼容类型(如 Optional)的较新方法进行兼容。它们使用与前面详述的模板机制相同的模板机制,但仅当您的最低 API 级别足够高,可以访问目标类型,或者您启用了核心库 desugaring 时才可用。

对于 Stream 和四种不同的可选类型,D8R8 将从 java91011 中备份 18 个方法。

5. 开发者故事

作为一个希望使用这些 API 编写代码的开发人员,您如何知道哪 API 做了向后兼容?目前还没有一个很好的方法来了解他们。

首先,启用 coreLibraryDesugaring 后,IDELint 将开始允许您在支持时使用新类型和新 api。在本例中运行 Lint 不会产生任何错误,尽管支持的最低 API 低于LocalDateTime 所需的 26。但是,当库 desugaring 被禁用时,NewApi 检查会像平常一样失败。

Main.java:7: Error: Call requires API level 26 (current min is 25): java.time.LocalDateTime#now [NewApi]
    System.out.println(LocalDateTime.now());
                                     ~~~

这可以确保您不会错误地使用不受支持的类型或 API,但对可发现性没有帮助。

目前,最好的向后兼容类型列表在 Android Studio 4.0 feature list 特性列表中,现有类型的兼容 API 列表是本文中的两个列表(12)。不过,希望在未来这些会更容易被发现。


D8R8 问世以来,各个 API 的向后兼容一直在改进。随着 Android Gradle plugin 4.0 alphas 提供了核心库去糖功能,应用程序可以从 Java8 访问基础类型,即使它们的最低支持 API 级别低于引入这些类型时的级别。这也意味着 Java 库可以开始利用这些类型,同时保持与 Android 的兼容性。

重要的是要记住,即使有了这些闪亮的新 API 可用性,JDKJavaAPI 也在持续改进,这是他们六个月的发布节奏。虽然 D8R8 可以通过将 Java91011 中的一些 APIJava91011 中删除,从而帮助弥补差距,但必须保持压力,将这些 API 实际运到 Android 框架中。