用 Kotlin 写一个能真正分发出去的命令行工具
从 Clikt 搭骨架到 GraalVM native-image 打包成单文件二进制,记录用 Kotlin 写 CLI 的完整链路,以及 native-image 上的几个反射坑。
写 Android 久了会形成一种惯性:什么东西都想放进 App 里。但有些需求做成 App 特别别扭——比如「批量把设计给的图切成 Android 的多密度目录」,这明显是个命令行工具更合适。
问题是我最熟的是 Kotlin。为这个去学一门新语言值不值?结论是:不值,但得先解决分发问题。
骨架:Clikt
命令行解析用 Clikt,它对子命令的支持是我见过最舒服的:
dependencies {
implementation("com.github.ajalt.clikt:clikt:5.0.1")
}
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
class Adt : CliktCommand(name = "adt") {
override fun help(context: Context) = "Android 开发常用的一堆小工具"
override fun run() = Unit
}
fun main(args: Array<String>) {
Adt().subcommands(
CrashCommand(),
InstallCommand(),
ScreenCommand(),
).main(args)
}
单个子命令写起来也很直观:
class CrashCommand : CliktCommand(name = "crash") {
private val packageName by option("-p", "--package").required()
private val lines by option("-n", "--lines").int().default(60)
override fun run() {
val pid = Adb.pidOf(packageName) ?: fail("应用没在运行")
// 取出最近一次崩溃的堆栈,并把关键帧高亮出来
val log = Adb.logcat(pid = pid, lines = lines)
val frames = log.filter { it.contains(packageName) }
echo(frames.joinToString("\n"))
}
}
fail() 会以非零状态码退出并打印到 stderr,这样它就能被 shell 脚本的 && 正确串联起来。
分发:这才是真正的门槛
JVM 应用分发的标准做法是「打个 fat jar,让用户装 JRE」。对内部工具还行,对外分发基本没人愿意为了一个小工具装 200MB 的运行时。
解决方案是 GraalVM 的 native-image:把 JVM 应用编译成原生二进制,启动时间从 300ms 降到 10ms 以内,体积也能压到 20MB 左右。
plugins {
id("org.graalvm.buildtools.native") version "0.10.6"
id("org.jetbrains.kotlin.jvm") version "2.2.0"
}
graalvmNative {
binaries {
named("main") {
imageName.set("adt")
mainClass.set("dev.example.adt.MainKt")
buildArgs.add("--no-fallback")
buildArgs.add("-H:+UnlockExperimentalVMOptions")
// 把没用到的 JVM 特性裁掉,体积能小一半
buildArgs.add("--gc=serial")
buildArgs.add("-O2")
}
}
}
一条命令出产物:
./gradlew nativeCompile
# → build/native/nativeCompile/adt
native-image 上绕不开的坑
反射是最大的敌人。 native-image 在编译期做静态分析,只保留「能证明会被调用」的代码。反射调用的类在编译期看不出来,就会被裁掉,运行时直接 ClassNotFoundException。
我踩的第一个坑来自 kotlinx.serialization:
Exception in thread "main" kotlinx.serialization.SerializationException:
Serializer for class 'Config' is not found.
解决办法是给序列化类加 @Serializable(这个必须的,编译期生成 serializer)并把序列化器注册进配置目录:
{
"reflection": [
{
"type": "dev.example.adt.Config",
"allDeclaredConstructors": true,
"allDeclaredFields": true
}
],
"resources": [
{ "glob": "META-INF/services/*" }
]
}
第二个坑是 Class.getResourceAsStream 读不到资源,因为资源不会自动打包进去,同样要在 metadata 里声明。
检查办法:加
-H:+ReportExceptionStackTraces和--trace-class-initialization, 失败时能看到具体是哪个类被裁掉了。
发布到 GitHub Releases
打包这一步交给 GitHub Actions,矩阵构建三个平台:
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
artifact: adt-linux-x64
- os: macos-latest
artifact: adt-macos-arm64
- os: windows-latest
artifact: adt-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
- run: ./gradlew nativeCompile
- uses: softprops/action-gh-release@v2
with:
files: build/native/nativeCompile/*
用户下载一个二进制、chmod +x 就能用,不需要装任何东西。
值不值
如果只是自己用,写个 shell 脚本或者 Python 可能更快。但只要满足下面任意一条,Kotlin + native-image 就很划算:
- 逻辑超过 500 行,需要类型和单元测试兜底;
- 需要分发给别人,最好是「下载即用」;
- 你已经很熟 Kotlin,不想为一个小工具再学一门语言。
对我来说第三条最关键。工具的价值在于它真的被用起来,而不是用了什么语言。
下一篇讲跨平台工具选型的取舍——毕竟 native-image 也不是所有场景的最优解。