作者 / 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 指令。
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
命令所输出的内容。
$ 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^Bn^B^X^Bn^@n^B^P^Nn^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 扩展函数使用:
代码语言: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 来启用包大小缩减功能,我们更新缩减器配置,使其包含如下内容:
#保留 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 代码进行处理。
举个简单的例子,比如我们希望在运行时查找并且调用某个类中的一个扩展函数。我们希望启用方法重命名,因为我们并不关心函数名,只要能在运行时找到它并且调用即可。
代码语言: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 页面中提交问题。