发布于 2026-01-06 0 阅读
0

构建工具:制作一个小型 Kotlin 应用

构建工具:制作一个小型 Kotlin 应用

有时候你需要解决问题,但你真的不想用 Bash 脚本。最近,我决定用一个简单但便宜的 JVM 应用替换掉一个功能强大但成本高昂的 Gradle 任务。¹这个Gradle 任务平均每次构建需要 2 分钟,我的团队上个月累计花了 43 个小时等待它完成。而替换它的 Kotlin 应用只需要大约 300 毫秒就能完成,我预计下个月我们累计等待它的时间不会超过 8 分钟。时间就是金钱,我估计这在未来 12 个月内可以挽回 10 万美元的开发人员生产力损失。

这不是那个故事。2

本文并非讲述如何构建这样的应用,而是介绍如何使用 Gradle 构建应用。我们将学习如何使用 `gradle-build`application和 `gradle- distributionbuild` 插件来构建应用并打包发行版;如何使用 `gradle-build`shadow插件将其转换为胖 JAR 包;以及如何使用Proguard对整个应用进行压缩。最终,我们将一个 150 万的 JAR 包压缩成一个 1.2 千的“胖”JAR 包,最终二进制文件的大小减少了 99.2%。

看到99.2这个数字,我立刻想起杰克·沃顿(Jake Wharton)差不多一年前也写过一篇非常类似的文章。他的文章值得一读。我的这篇文章与他不同的是,我会一步一步地解释如何使用Gradle实现类似的结果。

所有代码都在Github上。(Github已移除ICE。)

本项目使用 Gradle 7.1.1 构建。这一点很重要,因为如果您使用的是 Gradle 6.x,则需要对代码进行一些更改。

该应用程序

这篇文章并非关于应用本身,但我们需要一些东西来构建,所以…… 3

// echo/src/main/kotlin/mutual/aid/App.kt
package mutual.aid

fun main(args: Array<String>) {
  val echo = args.firstOrNull() ?: "Is there an echo in here?"
  println(echo)
}
Enter fullscreen mode Exit fullscreen mode

真正代码

我一直强调的一点是,构建工程本身就是一个独立的领域。我之所以这么说,是因为构建工程是我的收入来源。让我们来看看构建代码!

使用 Gradle 构建应用程序

这款application插件让这一切变得非常简单。

// echo/build.gradle
plugins {
  id 'org.jetbrains.kotlin.jvm' version '1.5.20'
  id 'application'
}

group = 'mutual.aid'
version = '1.0'

application {
  mainClass = 'mutual.aid.AppKt'
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以构建并运行这个小应用程序了。

$ ./gradlew echo:run
Is there an echo in here?
Enter fullscreen mode Exit fullscreen mode

或者如果我们想定制我们的信息……

$ ./gradlew echo:run --args="'Nice weather today'"
Nice weather today
Enter fullscreen mode Exit fullscreen mode

我们可以运行程序并提供任何我们想要的参数。这是任何 JVM 应用程序最基本的构建模块。

将应用程序转化为分发

假设你希望其他人实际运行你的应用程序,你应该将其打包成一个发行版。我们来使用distribution插件来实现这一点。

$ ./gradlew echo:installDist
$ echo/build/install/echo/bin/echo "Unless you're out West, I hear it's pretty hot out there"
Unless you're out West, I hear it's pretty hot out there
Enter fullscreen mode Exit fullscreen mode

(令人欣慰的是,在这个版本中,我们可以去掉非常不美观的--args=...语法。)

我耍了个小花招。我们不需要安装任何新插件,因为application插件本身已经负责安装distribution它了。后者会添加一个任务,installDist将发行版安装到你的项目build目录中。以下是完整的发行版内容:

$ tree echo/build/install/echo
echo/build/install/echo
├── bin
│   ├── echo
│   └── echo.bat
└── lib
    ├── annotations-13.0.jar
    ├── echo-1.0.jar
    ├── kotlin-stdlib-1.5.20.jar
    ├── kotlin-stdlib-common-1.5.20.jar
    ├── kotlin-stdlib-jdk7-1.5.20.jar
    └── kotlin-stdlib-jdk8-1.5.20.jar
Enter fullscreen mode Exit fullscreen mode

我们可以看到,它已经收集了运行时类路径上的所有 jar 包,包括我们刚刚构建的新 jar 包echo-1.0.jar。除了这些 jar 包之外,我们还有两个 shell 脚本,一个用于 *nix 系统,一个用于 Windows 系统。这些脚本使用了与 Gradle 相同的模板gradlew[.bat],因此它们应该相当稳定可靠。

我们已经开始看到问题了。我们的应用虽然很小,但却占用了完整的 Kotlin 运行时环境,尽管它只用到了很少的资源。那个lib目录的大小达到了 1.7MB。这一切仅仅是为了输出一个字符串!不仅如此,我们真正需要的只是程序(jar 文件)和一个可以从命令行轻松调用它的脚本,却要处理这么多单独的文件,这实在令人恼火。

在 Shadow 插件上进行分层

他们说,当你遇到一个问题并决定用正则表达式解决时,你现在又多了两个问题。Shadow插件更是把这个问题推向了极致:根据Alec Strong 的这篇文章来看,当你尝试用阴影来解决问题时,你现在至少会遇到五个问题。

说清楚点,我是在开玩笑。Shadow Gradle插件的维护者John Engelman为社区做出了贡献,他免费开源地提供了这个工具。如果Shadow插件用起来不方便,那是因为它解决的是一个难题。

// echo/build.gradle
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar

plugins {
  id 'org.jetbrains.kotlin.jvm' version '1.5.20'
  id 'application'
  id 'com.github.johnrengelman.shadow' version '7.0.0'
}

def shadowJar = tasks.named('shadowJar', ShadowJar) {
  // the jar remains up to date even when changing excludes
  // https://github.com/johnrengelman/shadow/issues/62
  outputs.upToDateWhen { false }

  group = 'Build'
  description = 'Creates a fat jar'
  archiveFileName = "$archivesBaseName-${version}-all.jar"
  reproducibleFileOrder = true

  from sourceSets.main.output
  from project.configurations.runtimeClasspath

  // Excluding these helps shrink our binary dramatically
  exclude '**/*.kotlin_metadata'
  exclude '**/*.kotlin_module'
  exclude 'META-INF/maven/**'

  // Doesn't work for Kotlin? 
  // https://github.com/johnrengelman/shadow/issues/688
  //minimize()
}
Enter fullscreen mode Exit fullscreen mode

说到难题,可以看到我在迭代过程中遇到了一些问题。如果你想让我的工作更轻松,请随意点赞!

我认为最有趣的部分是 ` fromand`exclude语句。`and`from语句指定shadow要打包的内容:实际的编译输出,加上运行时类路径。这些exclude语句对于缩小我们庞大的 jar 文件体积至关重要。

我们现在可以运行这个胖 jar 包并验证它是否仍然有效(该runShadow任务是由shadow插件添加的,因为它与插件集成application):

$ ./gradlew echo:runShadow --args="'I don't mind billionaires going into space, but maybe they could just stay?'"
I don't mind billionaires going into space, but maybe they could just stay?
Enter fullscreen mode Exit fullscreen mode

最后,我们可以检查 fat jar 本身(当我们运行该任务时,此任务也会隐式运行runShadow):

$ ./gradlew echo:shadowJar
# Produces output at echo/build/libs/echo-1.0-all.jar
Enter fullscreen mode Exit fullscreen mode

如果我们查看它的大小,会发现它只有 150 万:比最初的 170 万减少了约 12%。不过,我们还可以做得更好。

在 Proguard 上分层

我知道我知道。Proguard 已经过时了,感觉像是 2019 年或者R8发布那会儿的东西了。但它是免费的,开源的,而且它有一个 Gradle 任务,我可以很轻松地配置它。(而且shadow它的minify()函数不起作用😭)

设置如下:

// echo/build.gradle
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.gradle.internal.jvm.Jvm
import proguard.gradle.ProGuardTask

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    // There is apparently no plugin
    classpath 'com.guardsquare:proguard-gradle:7.1.0'
  }
}

plugins {
  id 'org.jetbrains.kotlin.jvm' version '1.5.20'
  id 'application'
  id 'com.github.johnrengelman.shadow' version '7.0.0'
}
Enter fullscreen mode Exit fullscreen mode

由于我完全不明白的原因,Proguard 没有作为 Gradle 插件打包,而是作为 Gradle 任务打包,因此要将其添加到构建脚本的类路径中,我必须求助于这种古老的黑魔法。

Android 平台有相应的插件。我怀疑肯定有人也开发过 JVM 项目的插件。但是一想到要找到一个绝对能兼容 Gradle 7 的插件就让我感到绝望,所以我决定写这篇博文。

现在我们已经可以访问该ProGuardTask任务了。我们该如何使用它呢?

// echo/build.gradle
def minify = tasks.register('minify', ProGuardTask) {
  configuration rootProject.file('proguard.pro')

  injars(shadowJar.flatMap { it.archiveFile })
  outjars(layout.buildDirectory.file("libs/${project.name}-${version}-minified.jar"))

  libraryjars(javaRuntime())
  libraryjars(filter: '!**META-INF/versions/**.class', configurations.compileClasspath)
}

/**
 * @return The JDK runtime, for use by Proguard.
 */
List<File> javaRuntime() {
  Jvm jvm = Jvm.current()
  FilenameFilter filter = { _, fileName -> fileName.endsWith(".jar") || fileName.endsWith(".jmod") }

  return ['jmods' /* JDK 9+ */, 'bundle/Classes' /* mac */, 'jre/lib' /* linux */]
    .collect { new File(jvm.javaHome, it) }
    .findAll { it.exists() }
    .collectMany { it.listFiles(filter) as List }
    .toSorted()
    .tap {
      if (isEmpty()) {
        throw new IllegalStateException("Could not find JDK ${jvm.javaVersion.majorVersion} runtime")
      }
    }
}
Enter fullscreen mode Exit fullscreen mode
// proguard.pro
-dontobfuscate

-keep class mutual.aid.AppKt { *; }
Enter fullscreen mode Exit fullscreen mode

这有点拗口。由于ProGuardTask它没有通过插件注册和配置,我们需要自己完成这些操作。首先,我们需要告诉它我们的规则,这些规则非常简单:我们只想压缩文件,并且要保留主类入口点。接下来,我们需要告诉它我们的输出injars,也就是任务的输出shadowJar:这就是要压缩的内容。(重要的是,我使用的语法意味着任务依赖项由 Gradle 确定,而无需使用 ` dependsOn.`。)该outjars函数非常简单地告诉任务将压缩后的 jar 文件输出到哪里。最后,我们还有 `.` libraryjars,我将其视为编译我的应用程序所需的类路径。这些内容不会被打包到输出中。其中最复杂的部分是 ` .`javaRuntime()函数。Proguard 的 GitHub 项目提供了一个更简单的示例,如果您愿意,可以使用它。

开始运行吧。

$ ./gradlew echo:minify
Enter fullscreen mode Exit fullscreen mode

如果我们现在检查一下echo/build/libs/echo-1.0-minified.jar,就会发现它只有 12K 🎉

就像我们验证完整 jar 文件是否可运行一样,我们可以创建一个JavaExec任务并运行我们压缩后的 jar 文件:

tasks.register('runMin', JavaExec) {
  classpath = files(minify)
}
Enter fullscreen mode Exit fullscreen mode

然后运行它:

$ ./gradlew echo:runMin --args="'How would space guillotines even work?'"
How would space guillotines even work?
Enter fullscreen mode Exit fullscreen mode

不过,我们还没完成。我们还需要将这个精简后的应用程序打包成发行版并发布。

瘦胖体型的形成

这两个application插件都shadow注册了一个“zip”任务(分别是 ` distZipzip` 和shadowDistZip`zip`)。但它们都不是我们需要的,因为它们没有打包我们压缩后的 jar 包。幸运的是,它们都是 Gradle 核心类型的任务Zip,配置起来相对容易。

// echo/build.gradle
def startShadowScripts = tasks.named('startShadowScripts', CreateStartScripts) {
  classpath = files(minify)
}

def minifiedDistZip = tasks.register('minifiedDistZip', Zip) { 
  archiveClassifier = 'minified'

  def zipRoot = "/${project.name}-${version}"
  from(minify) {
    into("$zipRoot/lib")
  }
  from(startShadowScripts) {
    into("$zipRoot/bin")
  }
}
Enter fullscreen mode Exit fullscreen mode

我们首先要做的就是接管该startShadowScripts任务(稍后会解释该任务的作用)。shadowJar我们希望它使用我们压缩后的 jar 包作为类路径,而不是使用任务生成的默认类路径。语法classpath = files(minify)与之前的类似,injars(shadowJar.flatMap { it.archiveFile })因为它也包含了任务依赖信息。由于minify`<classpath>` 是一个`<classpath>` TaskProviderfiles(minify)它不仅设置了类路径,还将该minify任务设置为了startShadowScripts任务的依赖项。

接下来,我们创建自己的Zip任务,minifiedDistZip并以类似于基础任务的方式构建它distZip。如果我们查看最终产品,就更容易理解它:

$ ./gradlew echo:minifiedDistZip
$ unzip -l echo/build/distributions/echo-1.0-minified.zip 
Archive:  echo/build/distributions/echo-1.0-minified.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  07-11-2021 17:02   echo-1.0/
        0  07-11-2021 17:02   echo-1.0/lib/
    12513  07-11-2021 16:59   echo-1.0/lib/echo-1.0-minified.jar
        0  07-11-2021 17:02   echo-1.0/bin/
     5640  07-11-2021 17:02   echo-1.0/bin/echo
     2152  07-11-2021 17:02   echo-1.0/bin/echo.bat
---------                     -------
    20305                     6 files
Enter fullscreen mode Exit fullscreen mode

我们的压缩包包含一个经过压缩的 jar 文件,以及两个脚本,一个用于 *nix 系统,一个用于 Windows 系统。具体的路径很重要,因为生成的脚本包含一个echo属性echo.batCLASSPATH

CLASSPATH=$APP_HOME/lib/echo-1.0-minified.jar
Enter fullscreen mode Exit fullscreen mode

这里有个小陷阱,浪费了我大约20分钟的时间。你可能已经注意到这一点了。

def zipRoot = "/${project.name}-${version}"
Enter fullscreen mode Exit fullscreen mode

开头/非常重要!没有开头,压缩包里的内容路径会非常混乱。幸运的是,我现在对 Gradle 的了解已经足够,知道什么时候该止步不前,继续前进。

发布我们的精简版

现在我们有了一个包含压缩后 jar 文件的 zip 文件,这个 zip 文件本身只有 15K,相比任务生成的原始 1.5M zip 文件,这是一个巨大的改进distZip。我们只需要自动化最后一件事,那就是发布这个压缩包。我们将添加maven-publish插件,然后进行配置:

// echo/build.gradle
plugins {
  id 'org.jetbrains.kotlin.jvm' version '1.5.20'
  id 'application'
  id 'com.github.johnrengelman.shadow' version '7.0.0'
  id 'maven-publish'
}

publishing {
  publications {
    minifiedDistribution(MavenPublication) {
      artifact minifiedDistZip
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

这会添加一些发布任务,包括publishMinifiedDistributionPublicationToMavenLocal……我们可以运行它并检查输出:

$ ./gradlew echo:publishMinifiedDistributionPublicationToMavenLocal
$ tree ~/.m2/repository/mutual/aid/echo/
~/.m2/repository/mutual/aid/echo/
├── 1.0
│   ├── echo-1.0-minified.zip
│   └── echo-1.0.pom
└── maven-metadata-local.xml
Enter fullscreen mode Exit fullscreen mode

我们甚至得到了一个 pom 文件,因此我们可以通过引用其 Maven 坐标从存储库中解析此构件mutual.aid:echo:1.0

总结

本文旨在提供一个使用 Gradle 构建小型 Kotlin 应用的极简示例,该应用需满足以下要求:零依赖、尽可能小巧,并可发布以便潜在用户轻松访问。如果您愿意阅读文档并进行实验,还可以实现更多功能。

提醒一下,完整的源代码可以在这里找到。

尾注

1我的意思是,Kotlin 应用速度非常快,因为它利用了系统知识,并使用正则表达式解析构建脚本来获取其元数据。相比之下,Gradle 任务的智能之处在于它使用了 Gradle 自身的自省功能。但遗憾的是,它的速度慢了 400 倍。我们的折衷方案是在 CI 中运行该任务,以验证 Kotlin 应用的正确性,而我们的开发人员每天都在使用该应用。2
可能会在获得公司沟通部门批准后的几个月内讲述这个故事。敬请期待!3欢迎提交PR

参考

  1. 应用程序插件:https://docs.gradle.org/current/userguide/application_plugin.html
  2. 分发插件:https://docs.gradle.org/current/userguide/distribution_plugin.html
  3. Shadow插件:https://github.com/johnrengelman/shadow
  4. Proguard 任务:https://www.guardsquare.com/manual/setup/gradle
  5. Proguard JVM 应用示例:https://github.com/Guardsquare/proguard/blob/master/examples/gradle/applications.gradle
文章来源:https://dev.to/autonomousapps/tools-of-the-build-trade-the-making-of-a-tiny-kotlin-app-3eba