制作一个小型 Kotlin 应用程序

292 阅读3分钟

使用 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'
}
// 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)
}

现在可以构建和运行这个小应用程序

$ ./gradlew echo:run
Is there an echo in here?

如果我们想定制我们的信息

$ ./gradlew echo:run --args="'Nice weather today'"
Nice weather today

将应用程序转变为发行版

假设希望其他人实际运行应用程序,应该将其捆绑为一个发行版。让我们为此使用分发插件。

$ ./gradlew echo:installDist
$ echo/build/install/echo/bin/echo "hello world"
hello world

我们不必应用任何新插件,因为 application 插件已经负责应用 distribution 插件。后者添加了一个任务 installDist,它将分发安装到项目的 build 目录中。完整的分布如下所示: image.png

我们可以看到它已经收集了我们运行时类路径中的所有 jar,包括我们刚刚构建的新 jar,echo-1.0.jar。除了这些 jars,我们还有两个 shell 脚本,一个用于 *nix,一个用于 Windows。这些脚本使用 Gradle 用于 gradlew[.bat] 的相同模板,因此它们应该非常健壮。

虽然只是一个“小”应用程序,但它仍然包含完整的 Kotlin 运行时,尽管它使用的很少。该 lib 目录的大小为 1.7M。但是我们真正想要的是我们的程序(echo-1.0.jar)和一个从命令行轻松调用它的脚而已。

在 Shadow 插件上分层

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()
}

我认为最有趣的部分是 from 和 exclude 语句。 from 告诉 shadow 要捆绑什么:我们的实际编译输出,加上运行时类路径。 exclude 语句对于缩小我们的fat jar很重要。

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

$ ./gradlew echo:runShadow --args="hello world"
hello world

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

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

如果我们检查它的大小,我们会看到它是 1.5M:已经比原来的 1.7M 减少了大约 12%。

在 Proguard 上分层

// 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'
}

Proguard 没有捆绑为 Gradle 插件,而是作为 Gradle 任务,因此要将其添加到构建脚本的类路径中。现在我们可以访问 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")
      }
    }
}
// proguard.pro
-dontobfuscate

-keep class mutual.aid.AppKt { *; }

由于 ProGuardTask 不是由插件注册和配置的,我们必须自己做。第一部分是告诉它我们的规则,非常简单;我们只想缩小,我们想保留我们的主类入口点。接下来,我们告诉它我们的 injars,这只是 shadowJar 任务的输出:这就是正在缩小的内容。 (重要的是,我使用的语法意味着任务依赖关系由 Gradle 确定,而不依赖于依赖项。) outjars 函数非常简单地告诉任务在哪里吐出缩小的 jar。最后,我们有 libraryjars,我认为它是编译我的应用程序所需的类路径。这些不会捆绑到输出中。其中最复杂的部分是 javaRuntime() 函数。

$ ./gradlew echo:minify

如果我们现在检查 echo/build/libs/echo-1.0-minified.jar,我们会发现它只有 12K.验证 fat jar 是否可运行类似,我们可以创建一个 JavaExec 任务并运行我们的缩小 jar:

tasks.register('runMin', JavaExec) {
  classpath = files(minify)
}
$ ./gradlew echo:runMin --args="'hello world'"
hello world

我们仍然需要将这个缩小的应用程序捆绑为一个发行版并发布它。

制作发行版并发布

application 和 shadow 插件都注册了一个“zip”任务(分别是 distZip 和 shadowDistZip)。但是它们没有打包我们的缩小 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")
  }
}

我们要做的第一件事是选择 startShadowScripts 任务(这个任务的作用将很快解释)。我们不想让它使用默认的类路径(由 shadowJar 任务生成),而是希望它使用我们缩小的 jar 作为类路径。语法 classpath = files(minify) 类似于早期的 injars(shadowJar.flatMap { it.archiveFile }),因为它带有任务依赖信息。由于 minify 是一个 TaskProvider,files(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

我们的存档包含我们的fat、minified的 jar,以及两个脚本,一个用于 *nix,一个用于 Windows。特定路径很重要,因为生成的 echo 和 echo.bat 脚本包含 CLASSPATH 属性:

CLASSPATH=$APP_HOME/lib/echo-1.0-minified.jar

发布minified

我们现在有了一个带有压缩 jar 的 zip 文件,而 zip 本身只有 15K,与 distZip 任务生成的原始 1.5M zip 相比有了巨大的改进。我们还有一件事要自动化,那就是发布这个档案。我们将添加 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
    }
  }
}

这增加了一些发布任务,包括 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

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