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 頁面中提交問題。

相關文章
相關標籤/搜索