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

image

作者 / 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 扩展函数

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

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^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 注解。这些规则仅仅适用于那些被显式声明保留的类。因此,只有 D8CommandBuilderCommandBuilderKt 的元数据会被保留。但是 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 页面中提交问题。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,907评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,987评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,298评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,586评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,633评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,488评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,275评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,176评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,619评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,819评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,932评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,655评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,265评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,871评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,994评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,095评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,884评论 2 354

推荐阅读更多精彩内容