R8 编译器: 为 Kotlin 库和应用 "瘦身"

2022-09-23 11:01:16 浏览数 (3)

作者 / Morten Krogh-Jespeersen, Mads Ager

R8 是 Android 默认的程序缩减器,它可以通过移除未使用的代码和优化其余代码的方式降低 Android 应用大小,R8 同时也支持缩减 Android 库大小。除了生成更小的库文件,库压缩操作还可以隐藏开发库里的新特性,等到这些特性相对稳定或者可以面向公众的时候再对外开放。

Kotlin 对于编写 Android 应用和开发库来说是非常棒的开发语言。不过,使用 Kotlin 反射来缩减 Kotlin 开发库或者应用就没那么简单了。Kotlin 使用 Java 类文件中的元数据 来识别 Kotlin 语言中的结构。如果程序缩减器没有维护和更新 Kotlin 的元数据,相应的开发库或者应用就无法正常工作。

R8 现在支持维持和重写 Kotlin 的元数据,从而全面支持使用 Kotlin 反射来压缩 Kotlin 开发库和应用。该特性适用于 Android Gradle 插件版本 4.1.0-beta03。欢迎大家踊跃尝试,并在 Issue Tracker 页面 向我们反馈整体使用感受和遇到的问题。

本文接下来的内容为大家介绍了 Kotlin 元数据的相关信息以及 R8 中对于重写 Kotlin 元数据的支持。

Kotlin 元数据

Kotlin 元数据 是存储在 Java 类文件的注解中的一些额外信息,它由 Kotlin JVM 编译器生成。元数据确定了类文件中的类和方法是由哪些 Kotlin 代码构成的。比如,Kotlin 元数据可以告诉 Kotlin 编译器类文件中的一个方法实际上是 Kotlin 扩展函数。

我们来看一个简单的例子,以下库代码定义了一个假想的用于指令构建的基类,用于构建编译器指令。

代码语言:javascript复制
package com.example.mylibrary

/** CommandBuilderBase 包含 D8 和 R8 中通用的选项 */

abstract class CommandBuilderBase {
    internal var minApi: Int = 0
    internal var inputs: MutableList<String> = mutableListOf()

    abstract fun getCommandName(): String
    abstract fun getExtraArgs(): String

    fun build(): String {
        val inputArgs = inputs.joinToString(separator = " ")
        return "${getCommandName()} --min-api=$minApi $inputArgs ${getExtraArgs()}"
    }
}

fun <T : CommandBuilderBase> T.setMinApi(api: Int): T {
    minApi = api
    return this
}

fun <T : CommandBuilderBase> T.addInput(input: String): T {
    inputs.add(input)
    return this
}

然后,我们可以定义一个假想 D8CommandBuilder 的具体实现,它继承自 CommandBuilderBase,用于构建简化的 D8 指令。

代码语言:javascript复制
package com.example.mylibrary

/** D8CommandBuilder to build a D8 command. */
class D8CommandBuilder: CommandBuilderBase() {
    internal var intermediateOutput: Boolean = false
    override fun getCommandName() = "d8"
    override fun getExtraArgs() = "--intermediate=$intermediateOutput"
}

fun D8CommandBuilder.setIntermediateOutput(intermediate: Boolean) : D8CommandBuilder {
    intermediateOutput = intermediate
    return this
}

上面的示例使用的扩展函数来保证当您在 D8CommandBuilder 上调用 setMinApi 方法的时候,所返回的对象类型是 D8CommandBuilder 而不是 CommandBuilderBase。在我们的示例中,这些扩展函数属于顶层的函数,并且仅存在于 CommandBuilderKt 类文件中。接下来我们来看一下通过精简后的 javap 命令所输出的内容。

代码语言:javascript复制
$ javap com/example/mylibrary/CommandBuilderKt.class
Compiled from "CommandBuilder.kt"
public final class CommandBuilderKt {
public static final <T extends CommandBuilderBase> T addInput(T,      String);
public static final <T extends CommandBuilderBase> T setMinApi(T, int);
...
}

从 javap 的输出内容里可以看到扩展函数被编译为静态方法,该静态方法的第一个参数是扩展接收器。不过这些信息还不足以告诉 Kotlin 编译器这些方法需要作为扩展函数在 Kotlin 代码中调用。所以,Kotlin 编译器还在类文件中增加了 kotlin.Metadata 注解。注解中的元数据里包含本类中针对 Kotlin 特有的信息。如果我们使用 verbose 选项就可以在 javap 的输出中看到这些注解。

代码语言:javascript复制
$ javap -v com/example/mylibrary/CommandBuilderKt.class
...
RuntimeVisibleAnnotations:
  0: kotlin/Metadata(
   mv=[...],
   bv=[...],
   k=...,
   xi=...,
   d1=["^@.n^B^H^Bn^B^X^Bn^@n^B^P^Nn^B...^D"],
   d2=["setMinApi", ...])

元数据注解的 d1 字段包含了大部分实际的内容,它们以 protocol buffer 消息的形式存在。元数据内容的具体意义并不重要。重要的是 Kotlin 编译器会读取其中的内容,并且通过这些内容确定了这些方法是扩展函数,如下 Kotlinp dump 输出内容所示。

代码语言:javascript复制
$ kotlinp com/example/mylibrary/CommandBuilderKt.class
package {

// signature:   addInput(CommandBuilderBase,String)CommandBuilderBase
public final fun <T : CommandBuilderBase> T.addInput(input: kotlin/String): T

// signature: setMinApi(CommandBuilderBase,I)CommandBuilderBase
public final fun <T : CommandBuilderBase> T.setMinApi(api: kotlin/Int): T

...
}

该元数据表明这些函数将在 Kotlin 用户代码中作为 Kotlin 扩展函数使用:

代码语言:javascript复制
D8CommandBuilder().setMinApi(12).setIntermediate(true).build()

R8 过去是如何破坏 Kotlin 开发库的

正如前文所提到的,为了能够在库中使用 Kotlin API,Kotlin 的元数据非常重要,然而,元数据存在于注解中,并且会以 protocol buffer 消息的形式存在,而 R8 是无法识别这些的。因此,R8 会从下面两个选项中择其一:

  • 去除元数据
  • 保留原始的元数据

但是这两个选项都不可取。

如果去除元数据,Kotlin 编译器就再也无法正确识别扩展函数。比如在我们的例子中,当编译类似 D8CommandBuilder().setMinApi(12) 这样的代码时,编译器就会报错,提示不存在该方法。这完全说得通,因为没有了元数据,Kotlin 编译器唯一能看到的就是一个包含两个参数的 Java 静态方法。

保留原始的元数据也同样会出问题。首先 Kotlin 元数据中所保留的类是父类的类型。所以,假设在缩减开发库大小的时候,我们仅希望 D8CommandBuilder 类能够保留它的名称。这时候也就意味着 CommandBuilderBase 会被重命名,一般会被命名为 a。如果我们保留原始的 Kotlin 元数据,Kotlin 编译器会在元数据中寻找 D8CommandBuilder 的超类。如果使用原始元数据,其中所记录的超类是 CommandBuilderBase 而不是 a。此时编译就会报错,并且提示 CommandBuilderBase 类型不存在。

R8 重写 Kotlin 元数据

为了解决上述问题,扩展后的 R8 增加了维护和重写 Kotlin 元数据的功能。它内嵌了 JetBrains 在 R8 中开发的 Kotlin 元数据开发库。元数据开发库可以在原始输入中读取 Kotlin 元数据。元数据信息被存储在 R8 的内部数据结构中。当 R8 完成对开发库或者应用的优化和缩小工作后,它会为所有声明被保留的 Kotlin 类合成新的正确元数据。

来一起看一下我们的示例有哪些变化。我们将示例代码添加到一个 Android Studio 库工程中。在 gradle.build 文件中,通过将 minifyEnbled 置 true 来启用包大小缩减功能,我们更新缩减器配置,使其包含如下内容:

代码语言:javascript复制
#保留 D8CommandBuilder 和它的全部方法
-keep class com.example.mylibrary.D8CommandBuilder {
  <methods>;
}
#保留扩展函数
-keep class com.example.mylibrary.CommandBuilderKt {
  <methods>;
}
#保留 kotlin.Metadata 注解从而在保留项目上维持元数据
-keepattributes RuntimeVisibleAnnotations
-keep class kotlin.Metadata { *; }

上述内容告诉 R8 保留 D8CommandBuilder 以及 CommandBuilderKt 中的全部扩展函数。它还告诉 R8 保留注解,尤其是 kotlin.Metadata 注解。这些规则仅仅适用于那些被显式声明保留的类。因此,只有 D8CommandBuilder 和 CommandBuilderKt 的元数据会被保留。但是 CommandBuilderBase 中的元数据不会被保留。我们这么处理可以减少应用和开发库中不必要的元数据。

现在,启用缩减后所生成的库,里面的 CommandBuilderBase 被重命名为 a。此外,所保留的类的 Kotlin 元数据也被重写,这样所有对于 CommandBuilderBase 的引用都被替换为对 a 的引用。这样开发库就可以正常使用了。

最后再说明一下,在 CommandBuilderBase 中不保留 Kotlin 元数据意味着 Kotlin 编译器会将生成的类作为 Java 类进行对待。这会导致库中 Kotlin 类的 Java 实现细节产生奇怪的结果。要避免这样的问题,就需要保留类。如果保留了类,元数据就会被保留。我们可以在保留规则中使用 allowobfuscation 修饰符来允许 R8 重命名类,生成 Kotlin 元数据,这样 Kotlin 编译器和 Android Studio 都会将该类视为 Kotlin 类。

代码语言:javascript复制
-keep,allowobfuscation class com.example.mylibrary.CommandBuilderBase

到这里,我们介绍了库缩减和 Kotlin 元数据对于 Kotlin 开发库的作用。通过 kotlin-reflect 库使用 Kotlin 反射的应用同样需要 Kotlin 元数据。应用和开发库所面临的问题是一样的。如果 Kotlin 元数据被删除或者没有被正确更新,kotlin-reflect 库就无法将代码作为 Kotlin 代码进行处理。

举个简单的例子,比如我们希望在运行时查找并且调用某个类中的一个扩展函数。我们希望启用方法重命名,因为我们并不关心函数名,只要能在运行时找到它并且调用即可。

代码语言:javascript复制
class ReflectOnMe() {
    fun String.extension(): String {
        return capitalize()
    }
}

fun reflect(receiver: ReflectOnMe): String {
    return ReflectOnMe::class
        .declaredMemberExtensionFunctions
        .first()
        .call(receiver, "reflection") as String
}

在代码中,我们添加了一个调用: reflect(ReflectOnMe())。它会找到定义在 ReflectOnMe 中的扩展函数,并且使用传入的 ReflectOnMe 实例作为接收器,"reflection" 作为扩展接收器来调用它。

现在 R8 可以在所有保留类中正确重写 Kotlin 元数据,我们可以通过使用下面的缩减器配置启用重写。

代码语言:javascript复制
#保留反射的类和它的方法
-keep,allowobfuscation class ReflectOnMe {
  <methods>;
}
#保留 kotlin.Metadata 注解从而在保留项目上维持元数据
-keepattributes RuntimeVisibleAnnotations
-keep class kotlin.Metadata { *; }

这样的配置使得缩减器在重命名 ReflectOnMe 和扩展函数的同时,仍然维持并且重写 Kotlin 元数据。

尝试一下吧!

欢迎尝试 R8 对于 Kotlin 库项目中 Kotlin 元数据重写的特性,以及在 Kotlin 项目中使用 Kotlin 反射。该特性可以在 Android Gradle Plugin 4.1.0-beta03 及以后的版本中使用。如果在使用过程中遇到任何问题,请在我们的 Issue Tracker 页面中提交问题。

0 人点赞