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

做者 / Morten Krogh-Jespeersen, Mads Agerhtml

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

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

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

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

Kotlin 元数据

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

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

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 指令。jvm

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 命令所输出的内容。ide

$ 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 的输出中看到这些注解。函数

$ javap -v com/example/mylibrary/CommandBuilderKt.class
...
RuntimeVisibleAnnotations:
  0: kotlin/Metadata(
   mv=[...],
   bv=[...],
   k=...,
   xi=...,
   d1=["^@.\n^B^H^B\n^B^X^B\n^@\n^B^P^N\n^B...^D"],
   d2=["setMinApi", ...])

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

$ 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 扩展函数使用:

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 来启用包大小缩减功能,咱们更新缩减器配置,使其包含以下内容:

#保留 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 类。

-keep,allowobfuscation class com.example.mylibrary.CommandBuilderBase

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

举个简单的例子,好比咱们但愿在运行时查找而且调用某个类中的一个扩展函数。咱们但愿启用方法重命名,由于咱们并不关心函数名,只要能在运行时找到它而且调用便可。

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 元数据,咱们能够经过使用下面的缩减器配置启用重写。

#保留反射的类和它的方法
-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 页面中提交问题。

相关文章
相关标签/搜索