我们将逐步缩小,但首先让我们来激励有问题的二进制文件。 三年前,我写了 “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-runtime依赖项获得了对Jetbrains annotations 工件的依赖,如diff的第一部分所示。
- 如diff第二部分所示,删除了对kotlin-runtime的直接依赖。 很好,因为第一部分已经告诉我们kotlin-runtime是kotlin-stdlib的依赖项。
这两个事实显示在所显示的差异中,但是仅隐含着一个微妙的第三事实。 因为第一节是缩进的,所以我们知道我们的直接依赖项之一是对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, "args");
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("+--- ") }
.takeWhile { it.isNotEmpty() }
反编译的Java看起来像这样:
public static final Set findDependencyPaths(String var0) {
String[] var10000 = new String[]{"\r\n", "\n", "\r"};
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("+--- ") }
.takeWhile { it.isNotEmpty() }
此更改将二进制文件降至13643字节(?13KiB),减少了99.2%。
剩下的肿胀
Kotlin是一种多平台语言,意味着它具有自己的空列表,集合和映射实现。 但是,以JVM为目标时,没有理由在java.util.Collections提供的JVM上使用它们。 我提交了KT-41333来跟踪此增强功能。
转储最终二进制文件的内容表明其空集合(和相关类型)贡献了剩余大小的约50%:
$ unzip -l build/libs/dependency-tree-diff-r8.jar
Archive: 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, "$this$lines");
Intrinsics.checkNotNullParameter(var0, "$this$lineSequence");
String[] var10000 = new String[]{"\r\n", "\n", "\r"};
Intrinsics.checkNotNullParameter(var0, "$this$splitToSequence");
Intrinsics.checkNotNullParameter(var10000, "delimiters");
Intrinsics.checkNotNullParameter(var10000, "$this$asList");
这些Intrinsics调用在函数参数上强制类型系统的可为空的不变量,但是在内联之后,除第一个外,其余都是内在的。 这样的重复调用会出现在整个代码中。 这是由Kotlin重命名这些内在方法而引起的R8错误,R8没有更新以正确跟踪该更改。
修正了这两个问题后,二进制文件很可能会落入个位数的KiB中,从而使原始的胖.jar文件减少了99%。
如果要构建遮蔽依赖性的JVM二进制文件或JVM库,请确保使用R8或ProGuard之类的工具删除未使用的代码路径,或者使用Graal本机映像生成最小的本机二进制文件。 该工具保留为Java字节码,因此单个.jar可以在多个平台上使用。
GitHub上提供了dependency-tree-diff的完整源代码和构建设置。