Kotlin Vocabulary | 枚举和 R8 编译器

1,825 阅读5分钟

学习或使用一门新的编程语言时,了解这门语言所提供的功能,以及了解这些功能是否有相关联的开销,都是十分重要的环节。

这方面的问题在 Kotlin 中显得更加有趣,因为 Kotlin 最终会编译为 Java 字节码,但是它却提供了 Java 所没有的功能。那么 Kotlin 是怎么做到的呢?这些功能有没有额外开销?如果有,我们能做些什么来优化它吗?

接下来的内容与 Kotlin 中枚举 (enums) 和 when 语句 (java 中的 switch 语句) 有关。我会讨论一些和 when 语句相关的潜在开销,以及 Android R8 编译器是如何优化您的应用并减少这些开销的。

编译器

首先,我们讲一讲 D8 和 R8。

事实上,有三个编译器参与了 Android 应用中 Kotlin 代码的编译。

1. Kotlin 编译器

Kotlin 编译器将会首先运行,它会把您写的代码转换为 Java 字节码。虽然听起来很棒,但可惜的是 Android 设备上并不运行 Java 字节码,而是被称为 DEX 的 Dalvik 可执行文件。Dalvik 是 Android 最初所使用的运行时。而 Android 现在的运行时,则是从 Android 5.0 Lollipop 开始使用的 ART (Android Runtime),不过 ART 依然在运行 DEX 代码 (如果替换后的运行时无法运行原有的可执行文件的话,就毫无兼容性可言了)。

2. D8

D8 是整个链条中的第二个编译器,它把 Java 字节码转换为 DEX 代码。到了这一步,您已经有了能够运行在 Android 中的代码。不过,您也可以选择继续使用第三个编译器 —— R8。

3. R8 (可选,但推荐使用)

R8 以前是用来优化和缩减应用体积的,它基本上就是 ProGuard 的一个替代方案。R8 不是默认开启的,如果您希望使用它 (例如您想要这里讨论到的那些优化时),就需要启用它。在模块的 build.gradle 里添加 minifyEnabled = true ,就可以强制打开 R8 。它将在所有其他编译工作后执行,来保证您获得的是一个缩减和优化过的应用。


 android {
    buildTypes {
        release {
            minifyEnabled true
 
            proguardFiles getDefaultProguardFile(
                ‘proguard-android-optimize.txt’),
                ‘proguard-rules.pro’
        }
    }
}

枚举

现在,让我们讨论一下枚举。

无论在 Java 还是 Kotlin 中,枚举的功能和消耗本质上都是一样的。有趣的地方在于引入了 R8 之后,我们能对其中的一些开销做些什么。

枚举本身不包含任何隐藏开销。使用 Kotlin 时,也仅仅是将其转换为 Java 编程语言中的枚举而已,并没有多大开销。(我们曾经提到避免使用枚举,但那是很多年前的事了,而且运行时也与今日不同。所以现在使用枚举没什么问题。)

但当您配合枚举使用 when 语句时,就会引入额外的开销。

首先,我们来看一个枚举的示例:


enum class BlendMode {
    OPAQUE,
    TRANSPARENT,
    FADE,
    ADD
}

这个枚举中包含四个值。这些值是什么无关紧要,这里仅作为示例。

枚举 + when

接下来,我们使用一个 when 语句来转换这个枚举:


fun blend(b: BlendMode) {
    when (b) {
        BlendMode.OPAQUE -> src()
        BlendMode.TRANSPARENT -> srcOver()
        BlendMode.FADE -> srcOver()
        BlendMode.ADD -> add()
    }
}

对应枚举的每一个值,我们都去调用另一个方法。

如果您去看这段代码编译成的 Java 字节码 (您可以通过 Android Studio 的查看字节码功能直接看到 (Tools -> Kotlin -> Show Kotlin Bytecode),然后点击 "Decompile" 按钮),就会看到下面这样的代码:


public static void blend(@NotNull BlendMode b) {
    switch (BlendingKt$WhenMappings.
            $EnumSwitchMapping$0[b.ordinal()]) {
        case 1: {
            src();
            break;
        }
        // ...
    }
}

这段代码中没有对枚举直接使用 switch 语句,而是调用了一个数组。这个数组是从哪来的呢?

而且这个数组存储在一个被生成的类文件中。这个类文件是从哪来的?

这里究竟发生了什么呢?

自动生成的枚举映射

事实上,为了实现二进制兼容,我们不能简单地依靠枚举的序数值进行转换,因为这样的代码十分脆弱。假设您的一个库中包含了一个枚举,而您改变了这个枚举中值的顺序,您就可能破坏了某个人的应用。虽然这些代码除了顺序,看起来完全相同,但就是这种顺序的不同导致了对其它代码的影响。

所以取而代之的是,编译器将序数值与另一个值做映射,这样一来,无论您对这些枚举做什么修改,基于这个库的代码都能正常运行。

当然,这就意味着只要像这样使用枚举,就会额外生成其它内容。在本例中,就会生成很多代码。

生成的代码就像下面这样:


public final class BlendingKt$WhenMappings {
    public static final int[] $EnumSwitchMapping$0 =
            new int[BlendMode.values().length];

    static {
        $EnumSwitchMapping$0[BlendMode.OPAQUE.ordinal()] = 1;
        $EnumSwitchMapping$0[BlendMode.TRANSPARENT.ordinal()] = 2;
        $EnumSwitchMapping$0[BlendMode.FADE.ordinal()] = 3;
        $EnumSwitchMapping$0[BlendMode.ADD.ordinal()] = 4;
    }
}

这段代码中生成了一个BlendingKt$WhenMappings类。这个类里面有一个存储映射信息的数组: $EnumSwitchMapping$0,接下来则是一些执行映射操作的静态代码。

示例中是只有一个 when 语句时的情况。但如果我们写了更多的 when 语句,每个 when 语句就会生成一个对应的数组,即使这些 when 语句都在使用同一个枚举也一样。

虽然所有这些开销没什么大不了的,但是却也意味着,在您不知情的时候,会生成一个类,而且其中还包含了一些数组,这些都会让类加载和实例化消耗更多的时间。

幸运的是,我们可以做一些事情来减少开销: 这就是 R8 发挥作用的时候了。

使用 R8 来解决问题

R8 是一个有趣的优化器,它能 "看" 到与应用相关的所有内容。由于 R8 可以 "看" 到无论是您自己写的还是您依赖的库中的所有代码,它便可以根据这些信息决定做哪些优化。比如,它能避免枚举映射造成的开销: 它不需要那些映射信息,因为它知道这些代码只会以既定的方式使用这些枚举,所以它可以直接调用序数值。

下面是 R8 优化过的代码反编译后的样子:


public static void blend(@NotNull BlendMode b) {
    switch (b.ordinal()) {
        case 0: {
            src();
            break;
        }
        // ...
    }
}

这样就避免了生成类和映射数组,而且只创建了您所需的最佳代码。

探索 R8 与 Kotlin,然后用 Kotlin 写出更好的应用吧。

更多信息

更多 R8 相关信息,请查看以下资源:

  • 官方文档 | D8

developer.android.google.cn/studio/comm…

  • 官方文档 | 缩减、混淆、优化您的应用

developer.android.google.cn/studio/buil…

  • Jake Wharton 的博客,详细介绍了 D8 和 R8 的工作原理,并为各种功能提供了示例,以及如何直接运行编译器、如何获得反编译的结果等

jakewharton.com/blog/