我們將逐步縮小,但首先讓我們來激勵有問題的二進位文件。 三年前,我寫了 「Surfacing Hidden Change to Pull Requests」一文,其中涵蓋了將重要的統計數據和對PR的差異作為評論。 這樣可以避免因影響二進位大小,清單和依賴關係樹的更改而引起的意外情況。
顯示依賴關係樹使用了Gradle的依賴關係任務和diff -U 0來顯示上一次提交的更改。 該示例中的示例將Kotlin版本從1.1-M03提升到1.1-M04,產生了以下差異:
@@ -125,2 +125,3 @@-| \--- org.jetbrains.kotlin:kotlin-stdlib:1.0.4 -> 1.1-M03-| \--- org.jetbrains.kotlin:kotlin-runtime:1.1-M03+| \--- org.jetbrains.kotlin:kotlin-stdlib:1.0.4 -> 1.1-M04+| \--- org.jetbrains.kotlin:kotlin-runtime:1.1-M04+| \--- org.jetbrains:annotations:13.0@@ -145,2 +146 @@-+--- org.jetbrains.kotlin:kotlin-stdlib:1.1-M03-+--- org.jetbrains.kotlin:kotlin-runtime:1.1-M03++--- org.jetbrains.kotlin:kotlin-stdlib:1.1-M04
除了可以看到版本變化之外,我們還可以推斷出兩個額外的事實:
這兩個事實顯示在所顯示的差異中,但是僅隱含著一個微妙的第三事實。 因為第一節是縮進的,所以我們知道我們的直接依賴項之一是對kotlin-stdlib的傳遞性依賴項。 不幸的是,我們不知道哪個依賴項會受到影響。
為了解決這個問題,我編寫了一個名為dependency-tree-diff的工具,該工具顯示了樹中所有更改的根依賴關係路徑。
+--- com.jakewharton.rxbinding:rxbinding-kotlin:1.0.0-| \--- org.jetbrains.kotlin:kotlin-stdlib:1.0.4 -> 1.1-M03-| \--- org.jetbrains.kotlin:kotlin-runtime:1.1-M03+| \--- org.jetbrains.kotlin:kotlin-stdlib:1.0.4 -> 1.1-M04+| \--- org.jetbrains.kotlin:kotlin-runtime:1.1-M04+| \--- org.jetbrains:annotations:13.0-+--- org.jetbrains.kotlin:kotlin-stdlib:1.1-M03 (*)-\--- org.jetbrains.kotlin:kotlin-runtime:1.1-M03+\--- org.jetbrains.kotlin:kotlin-stdlib:1.1-M04 (*)
我們的隱含第三事實(其他直接依賴關係受到影響)現在在輸出中是顯式的。變更作者現在可以反映受影響的依賴項是否可能存在任何兼容性問題。
您可以了解有關該工具的更多信息,並在其自述文件中查看另一個示例。
該工具需要籤入我們的倉庫並在CI上運行。使用Kotlin腳本成功構建了adb-event-mirror之後,該工具的第一個版本也使用了Kotlin腳本。儘管運行良好且很小,但CI機器上未安裝kotlinc。我們依靠Kotlin Gradle插件來編譯Kotlin,而不是獨立的二進位文件。
您可以在本地重定向Kotlin腳本緩存目錄以捕獲已編譯的jar,但是它仍然依賴於Kotlin腳本工件,該工件很大,具有很多依賴性,並且仍然非常動態。很顯然,這不是正確的方法,但是我提交了KT-41304,希望將來使製作腳本的胖.jar文件變得更容易。
我切換到經典的Kotlin Gradle項目,並生成了一個帶有.kotlin-stdlib依賴項的胖.jar。在添加腳本以使 make the jar self-executing之後,二進位文件的鎖定為1699978位元組(或〜1.62MiB)。不錯,但是我們可以做得更好!
刪除Kotlin元數據
使用unzip -l列出.jar中的文件表明,除了.class之外,大多數是.kotlin_module或.kotlin_metadata。 Kotlin編譯器和Kotlin的反思都使用了它們,而我們的二進位文件都不需要它們。
我們可以將這些信息與用於Java 9模塊系統的module-info.class以及META-INF / maven /中的文件一起從二進位文件中過濾掉,這些文件將傳播有關使用Maven工具構建的項目的信息。
刪除所有這些文件將產生1513414位元組(〜1.44MiB)的新二進位大小,大小減少11%。
使用R8
R8是用於Android構建的代碼優化器和混淆器。雖然通常在轉換為Dalvik可執行格式時用於優化和模糊化Java類文件,但它也支持輸出Java類文件。為了使用它,我們需要使用ProGuard的配置語法指定該工具的入口點。
-dontobfuscate-keepattributes SourceFile, LineNumberTable-keep class com.jakewharton.gradle.dependencies.DependencyTreeDiff { public static void main(java.lang.String[]);}
除了入口點之外,混淆功能也被禁用,並且我們保留了源文件和行號屬性,因此發生的任何異常仍然可以理解。
通過R8傳遞fat .jar會生成一個新的縮小的.jar,然後可以將其製成可執行文件。現在生成的二進位文件僅為41680位元組(〜41KiB),大小減少了98%。真好!
由於我們生成的是二進位文件而不是庫文件,因此-allowaccessmodification選項將允許隱藏成員公開,從而使諸如類合併和內聯之類的優化更加有效。加上這個會產生一個37630位元組(〜37KiB)的二進位文件。
調整標準庫使用
絕對安全的在這裡停下來,但我不好停…
現在二進位文件已經足夠小了,我們可以開始研究哪些代碼對大小有所影響。通常我會使用javap來查看字節碼,但是由於我們只關心看到API調用,因此可以解壓縮二進位文件並在IntelliJ IDEA中打開類文件,該文件將使用Fernflower反編譯器顯示大致等效的Java。
main方法首先以文件形式讀取參數:
fun main(vararg args: String) { if (args.size == 2) { val old = args[0].let(::File).readText() val new = args[1].let(::File).readText()
反編譯的代碼如下所示:
public static final void main(String... var0) { Intrinsics.checkNotNullParameter(var0, &34;); if (var0.length == 2) { String[] var10000 = var0; String var3 = var0[0]; var3 = FilesKt__FileReadWriteKt.readText$default(new File(var3), (Charset)null, 1); String var1 = var10000[1]; String var8 = FilesKt__FileReadWriteKt.readText$default(new File(var1), (Charset)null, 1);
窺視FilesKt__FileReadWriteKt會顯示我們在過去某個時刻編寫的不幸的文件讀取代碼,其中包含kotlin.ExceptionsKt,kotlin.jvm.internal.Intrinsics和kotlin.text.Charsets。
從java.io.File切換到java.nio.path.Path意味著我們可以使用內置方法來讀取內容。
fun main(vararg args: String) { if (args.size == 2) {- val old = args[0].let(::File).readText()- val new = args[1].let(::File).readText()+ val old = args[0].let(Paths::get).let(Paths::readString)+ val new = args[0].let(Paths::get).let(Paths::readString)
經過這些更改,二進位文件下降到30914位元組(〜30KiB)。
另一個引起我注意的標準庫用法是按行劃分輸入:
private fun findDependencyPaths(text: String): Set<List<String>> { val dependencyLines = text.lineSequence() .dropWhile { !it.startsWith(&34;) } .takeWhile { it.isNotEmpty() }
反編譯的Java看起來像這樣:
public static final Set findDependencyPaths(String var0) { String[] var10000 = new String[]{&34;, &34;, &34;}; List var1; DelimitedRangesSequence var2;
這表明我們正在使用Kotlin實施拆分和使用其Sequence類型。 Java 11添加了String.lines(),它返回一個Stream,該流還具有已經在使用的dropWhile和takeWhile運算符。 不幸的是,Kotlin還具有String.lines()擴展名,因此我們需要進行強制轉換才能使用Java 11方法。
private fun findDependencyPaths(text: String): Set<List<String>> {- val dependencyLines = text.lineSequence()+ val dependencyLines = (text as java.lang.String).lines() .dropWhile { !it.startsWith(&34;) } .takeWhile { it.isNotEmpty() }
此更改將二進位文件降至13643位元組(〜13KiB),減少了99.2%。
剩下的腫脹
Kotlin是一種多平臺語言,意味著它具有自己的空列表,集合和映射實現。 但是,以JVM為目標時,沒有理由在java.util.Collections提供的JVM上使用它們。 我提交了KT-41333來跟蹤此增強功能。
轉儲最終二進位文件的內容表明其空集合(和相關類型)貢獻了剩餘大小的約50%:
$ unzip -l build/libs/dependency-tree-diff-r8.jarArchive: build/libs/dependency-tree-diff-r8.jar Length Date Time Name--------- ---------- ----- ---- 84 12-31-1969 19:00 META-INF/MANIFEST.MF 926 12-31-1969 19:00 com/jakewharton/gradle/dependencies/DependencyTrees$findDependencyPaths$dependencyLines$1.class 854 12-31-1969 19:00 com/jakewharton/gradle/dependencies/DependencyTrees$findDependencyPaths$dependencyLines$2.class 6224 12-31-1969 19:00 com/jakewharton/gradle/dependencies/DependencyTreeDiff.class 604 12-31-1969 19:00 com/jakewharton/gradle/dependencies/Node.class 2534 12-31-1969 19:00 kotlin/collections/CollectionsKt__CollectionsKt.class 1120 12-31-1969 19:00 kotlin/collections/EmptyIterator.class 3227 12-31-1969 19:00 kotlin/collections/EmptyList.class 2023 12-31-1969 19:00 kotlin/collections/EmptySet.class 1958 12-31-1969 19:00 kotlin/jvm/internal/CollectionToArray.class 1638 12-31-1969 19:00 kotlin/jvm/internal/Intrinsics.class--------- ------- 21192 11 files
除了這些額外的類型外,字節碼還包含許多額外的空檢查。 例如,最後一部分中的findDependencyPaths的反編譯字節碼實際上看起來像這樣:
public static final Set findDependencyPaths(String var0) { Intrinsics.checkNotNullParameter(var0, &34;); Intrinsics.checkNotNullParameter(var0, &34;); String[] var10000 = new String[]{&34;, &34;, &34;}; Intrinsics.checkNotNullParameter(var0, &34;); Intrinsics.checkNotNullParameter(var10000, &34;); Intrinsics.checkNotNullParameter(var10000, &34;);
這些Intrinsics調用在函數參數上強制類型系統的可為空的不變量,但是在內聯之後,除第一個外,其餘都是內在的。 這樣的重複調用會出現在整個代碼中。 這是由Kotlin重命名這些內在方法而引起的R8錯誤,R8沒有更新以正確跟蹤該更改。
修正了這兩個問題後,二進位文件很可能會落入個位數的KiB中,從而使原始的胖.jar文件減少了99%。
如果要構建遮蔽依賴性的JVM二進位文件或JVM庫,請確保使用R8或ProGuard之類的工具刪除未使用的代碼路徑,或者使用Graal本機映像生成最小的本機二進位文件。 該工具保留為Java字節碼,因此單個.jar可以在多個平臺上使用。
GitHub上提供了dependency-tree-diff的完整原始碼和構建設置。