将Kotlin二进制缩小99.2%

2020-08-26 14:39:12

我们会讲到收缩,但首先让我们激励一下有问题的双星。三年前,我写了一篇名为“隐藏的改变以拉动请求”的帖子,内容包括将重要的统计数据和差异作为评论推向公关。这避免了影响二进制大小、清单和依赖关系树的更改带来的意外。

显示依赖关系树使用Gradle的依赖关系任务和diff-U0来显示上次提交后的更改。该帖子中的示例将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-run:1.1-m03+|\-org.jetbrains.kotlin:kotlin-stdlib:1.0.4->;Kotlin:kotlin-运行时:1.1-M04+|\--org.jetbrains.kotlin:注释:13.0@-145,2+146@@-+-org.jetbrains.kotlin:kotlin-stdlib:1.1-M03-+---org.jetbrains.kotlin:kotlin-运行时:1.1-M03++-org.jetbrains.kotlin:kotlin-stdlib:1.1-M04。

除了看到反映的版本跳跃之外,我们还可以推断出关于更改的两个额外事实:

Kotlin运行时依赖项依赖于JetBrains的注释工件,如diff的第一部分所示。

删除了对kotlin-run的直接依赖,如diff的第二部分所示。这很好,因为第一节已经告诉我们,kotlin-run是kotlin-stdlib的依赖项。

这两个事实显示在显示的diff中,但是还有一个微妙的第三个事实,它只是隐含的。因为第一部分是缩进的,所以我们知道我们的一个直接依赖项对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-运行时:1.1-m04+|\-org.jetbrains.kotlin:注释:13.0-+-org.jetbrains.kotlin:kotlin-stdlib:1.1-M03(*)-\-org.jetbrains.kotlin:kotlin-run:1.1-m03+\--org.jet.jet。

我们隐含的第三个事实,即哪个其他直接依赖项受到影响,现在在输出中是显式的。变更创建者现在可以反映与受影响的依赖项是否存在任何兼容性问题。

此工具需要签入到我们的回购中并在CI上运行。在使用Kotlin脚本成功构建ADB事件镜像之后,该工具的第一个版本也使用了Kotlin脚本。虽然Kotlinc工作正常并且很小,但它没有安装在CI机器上。我们依赖Kotlin Gradle插件来编译Kotlin,而不是独立的二进制文件。

您可以在本地重定向Kotlin脚本缓存目录以捕获编译后的JAR,但它仍然依赖于Kotlin脚本工件,它很大,有很多依赖项,并且仍然非常动态。很明显,这不是正确的路径,但是我提交了KT-41304,希望将来能够更容易地生成一个.jar的脚本。

我切换到一个经典的Kotlin Gradle项目,并生成了一个包含kotlin-stdlib依赖项的胖.jar。在前置脚本以使JAR自动执行之后,二进制文件计入1699978字节(或~1.62MiB)。不错,但我们可以做得更好!

使用unzip-l列出.jar中的文件显示,除了.class之外,大多数是.kotlin_module或.kotlin_METADATA。它们由Kotlin编译器和Kotlin的反射使用,我们的二进制文件不需要它们。

我们可以将这些与用于Java9模块系统的module-info.class和META-INF/maven/中的文件一起从二进制文件中过滤出来,META-INF/maven/中的文件传播有关使用Maven工具构建的项目的信息。

删除所有这些文件后,新的二进制大小为1513414字节(约1.44MiB),大小减少了11%。

R8是Android构建的代码优化器和混淆器。虽然它通常用于在转换为Dalvik可执行格式期间对Java类文件进行优化和模糊处理,但它也支持输出Java类文件。为了使用它,我们需要使用ProGuard的配置语法指定该工具的入口点。

除了入口点之外,还禁用了模糊处理,并且我们保留了源文件和行号属性,以便仍然可以理解发生的任何异常。

通过r8传递胖的.jar会生成一个新的缩小的.jar,然后可以使其成为可执行文件。生成的二进制文件现在只有41680字节(~41KiB),大小减少了98%。好的!。

由于我们生成的是二进制文件而不是库,因此-allowaccess修改选项将允许公开隐藏成员,从而使类合并和内联等优化更加有效。将其相加将产生37630字节(~37KiB)的二进制。

在这里停车是绝对安全的,但我不擅长阻止…。

既然二进制文件足够小,我们就可以开始查看哪些代码对大小有影响。通常我会求助于javap来查看字节码,但是因为我们只关心看到API调用,所以我们可以解压二进制文件并在IntelliJ Idea中打开类文件,IntelliJ Idea将使用FernFlower反编译器来显示大致等价的Java。

Fun Main(vararg args:string){if(args.。Size==2){val old=args[0]。让(::文件)。ReadText()Val new=args[1]。让(::文件)。ReadText()。

公共静态最终空Main(字符串...。Var0){本征。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.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)。

Private Fun findDependencyPath(text:string):设置<;list<;string>;>;{VAL DependencyLines=text。LineSequence()。DropWhile{!它。以(";+--";)}开头。边吃边{它。IsNotEmpty()}。

Public static Final Set findDependencyPath(String Var0){string[]var10000=new string[]{";\r\n";,";\n";,";\r";};list var1;DlimitedRangesSequence var2;

这表明我们正在使用拆分的Kotlin实现并使用其序列类型。Java11添加了一个String.linees(),它返回一个Stream,该流还包含已经在使用的dropWhile和takeWhile操作符。不幸的是,Kotlin还有一个String.linees()扩展,所以我们需要一个强制转换才能使用Java11方法。

Private Fun findDependencyPath(Text:String):set<;list<;string>;>;{-Val DependencyLines=text.lineSequence()+Val DependencyLines=(Text as java.lang.String).ines().dropWhile{!it.startsWith(";+--";)}.TakeWhile{it.isNotEmpty()。

Kotlin是一种多平台语言,这意味着它有自己的空列表、集合和映射实现。然而,当以JVM为目标时,没有理由使用它们而不是java.util.Colltions提供的那些。我提交了KT-41333来追踪这项改进。

转储最终二进制文件的内容会显示其空集合(和相关类型)约占剩余大小的50%:

$UNZIP-l build/libs/dependency-tree-diff-r8.jarArchive:Build/libs/Dependency-Tree-Diff-r8.jar长度日期时间名称-84 12-31-1969 19:00 META-INF/MANIFEST.MF926 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/Collection/EmptyIterator.class 3227 12-31-1969 19:00kotlin/Collection/EmptyList.class 2023 12-31-1969 19:00kotlin/Collection/EmptySet.class 1958 12-31-1969 19:00 kotlin/。Jvm/INTERNAL/CollectionToArray.class 1638 12-31-1969年19:00kotlin/jvm/Internal/Intrinsics.class-21192 11个文件。

除了这些额外的类型之外,字节码还包含一组额外的空检查。例如,上一节中findDependencyPath的反编译字节码实际上如下所示:

公共静态最终集findDependencyPath(String Var0){Intrinsics.。CheckNotNullParameter(var0,";$this$LINES";);本征。CheckNotNullParameter(var0,";$this$lineSequence";);string[]var10000=new string[]{";\r\n";,";\n";,";\r";};内部。CheckNotNullParameter(var0,";$this$plitToSequence";);本征。CheckNotNullParameter(var10000,";delimiters";);本征。CheckNotNullParameter(var10000,";$this$asList";);

这些Intrinics调用在函数参数上强制类型系统的空性不变量,但是在内联之后,除了第一个调用之外,所有其他调用都是多余的。类似这样的重复调用出现在整个代码中。这是一个R8错误,原因是Kotlin重命名了这些内部方法,而R8没有更新以正确跟踪该更改。

修复了这两个问题后,二进制文件很可能会变成个位数的KIBS,从而比原来的FAT.jar减少99%。

如果您正在构建JVM二进制文件或JVM库来着色依赖关系,请确保使用像r8或ProGuard这样的工具来删除未使用的代码路径,或者使用Graal本机映像来生成最小的本机二进制文件。此工具保留为Java字节码,以便单个.jar可以在多个平台上使用。