PAYATHON 2026

如何在不使用 fat JAR 的情况下分发 KMP 库的 JVM 版本?

数据阿明

结论

建议将 JVM 字节码和 JNI 原生二进制分开发布:保留一个通用 JVM JAR,为每个受支持的操作系统和 CPU 架构发布单独的原生库 JAR。应用构建或打包时,只选择部署平台需要的那一份。

如果需要兼容 Maven 和 Gradle,可以先采用 classifier。如果主要面向 Gradle,并希望自动选择依赖,可以进一步设计带操作系统和架构属性的 variants。

为什么需要单独处理 JNI 分发?

问题中的 JNI 实现已经能正常构建和运行,接下来要解决依赖选择和运行时加载。

JVM 字节码可以跨平台,但它调用的 .so、.dll 或 .dylib 仍受操作系统、CPU 架构和 ABI 约束。commonMain 中的公共 API 也不能消除这些限制。

需要处理两个环节:

  1. 构建时下载并打包正确的原生二进制。
  2. 运行时让 JNI 加载这份二进制。

可以将所有平台文件放进同一个 JAR,但使用 JNI 并不要求这样打包。

用 classifier 拆分发布

下面是一种发布布局。com.example、mylib 和 <version> 均为示意值,classifier 名称由库自行约定:

com.example:mylib-jvm:<version>
com.example:mylib-jvm:<version>:natives-linux-x86_64
com.example:mylib-jvm:<version>:natives-linux-aarch64
com.example:mylib-jvm:<version>:natives-macos-aarch64
com.example:mylib-jvm:<version>:natives-windows-x86_64

无 classifier 的主 JAR 包含 JVM 类、JNI 方法声明和加载逻辑;带 classifier 的 JAR 只包含对应平台的原生文件。例如:

mylib-jvm-<version>.jar
└── JVM classes

mylib-jvm-<version>-natives-linux-x86_64.jar
└── natives/linux-x86_64/libmylib_jni.so

mylib-jvm-<version>-natives-windows-x86_64.jar
└── natives/windows-x86_64/mylib_jni.dll

发布端需要为各平台生成原生库 JAR,并将它们作为带 classifier 的附加 artifact 发布到 JVM 模块坐标下。仅给文件改名不会建立依赖关系,也不会让构建工具自动识别平台。

如果希望每个平台拥有独立的依赖元数据,也可以使用独立模块,例如 mylib-natives-linux-x86_64。classifier 适合共享依赖描述的附加文件;独立模块更便于表达各平台不同的依赖关系。

JVM 应用如何引入?

假设使用方是普通 Gradle JVM 应用,并且已经配置好发布仓库,可以这样写:

// build.gradle.kts
val mylibVersion = "YOUR_VERSION"
val nativeClassifier = providers.gradleProperty("mylibNatives").get()

dependencies {
    implementation("com.example:mylib-jvm:$mylibVersion")
    runtimeOnly("com.example:mylib-jvm:$mylibVersion:$nativeClassifier")
}

然后显式指定部署平台:

./gradlew build -PmylibNatives=natives-linux-x86_64

将 YOUR_VERSION 替换为实际发布版本。mylibNatives 是这个示例自行定义的 Gradle property,不是 KMP 内置参数。

对于 KMP 使用方,公共代码仍可依赖根 KMP 坐标;原生 artifact 应加入 JVM target 对应的运行时依赖配置,具体配置名取决于项目中的 target 命名。

发布通用库时,不要根据发布机器的操作系统写死原生运行时依赖。否则,在 macOS 上发布的库可能让 Linux 使用方也解析到 macOS 二进制。

原生库 JAR 到了 classpath,为什么还需要加载代码?

标准 JNI 加载不会自动扫描 classpath 中的 JAR,也不会解压其中的共享库。常见做法是把资源提取到实际文件,再用绝对路径调用 System.load()。如果原生库由部署系统安装,也可以使用 System.loadLibrary() 按库名加载。Java System API 文档

下面的代码放在 JVM 源码中,演示如何从 Linux x86_64 artifact 提取并加载一个自包含的 JNI 共享库:

import java.nio.file.Files
import java.nio.file.StandardCopyOption

object NativeLoader {
    private val initialized: Unit by lazy {
        val resource = "/natives/linux-x86_64/libmylib_jni.so"
        val directory = Files.createTempDirectory("mylib-jni-")
        val library = directory.resolve("libmylib_jni.so")

        val input = NativeLoader::class.java.getResourceAsStream(resource)
            ?: error("Missing native library resource: $resource")

        input.use {
            Files.copy(it, library, StandardCopyOption.REPLACE_EXISTING)
        }

        System.load(library.toAbsolutePath().toString())
    }

    fun load() {
        initialized
    }
}

应在首次调用 JNI 方法之前执行 NativeLoader.load()。完整实现还需要选择当前运行平台的资源路径,并管理缓存或临时文件的清理。

如果 JNI wrapper 动态依赖另一份 C shared library,还要一并提供该依赖,并配置系统动态链接器,使其能够找到这份依赖。只把依赖文件放在同一个目录中,不能保证所有操作系统都会自动找到它;需要分别处理 Linux 的 RPATH/RUNPATH、macOS 的加载路径或 Windows 的 DLL 搜索规则。若构建条件和许可证允许,也可以把 C library 静态链接进 JNI wrapper,减少外部文件数量。

能否让 Gradle 自动选择平台?

可以,但需要显式建立依赖选择规则。

classifier 只是 artifact 的标识,Gradle 不会因为看到 natives-linux-x86_64 就自动选择它。可以在库的 Gradle Module Metadata 中发布平台 variants,并在消费端提供匹配的操作系统和架构属性;也可以提供 Gradle 插件封装这些配置。

Gradle 官方文档使用 LWJGL 演示了如何通过 component metadata rule,将原生 classifier JAR 建模成带操作系统和架构属性的运行时 variants。Gradle 原生依赖示例

需要区分构建机器的平台和应用最终运行的平台。在 macOS 上为 Linux 容器打包时,应选择 Linux 原生库。主机自动检测可以作为本地开发的默认值,但应允许部署配置覆盖。

有哪些实际项目可以参考?

  • LWJGL:使用通用 Java artifact 配合 natives-* classifier 分发原生代码。Gradle 官方示例展示了这些文件如何参与平台依赖选择,可以参考其拆包方式。LWJGL 依赖建模示例
  • JavaCPP Presets:将 Java 接口与各平台原生 artifact 分开发布,并提供 -platform 聚合依赖。其文档说明了如何仅包含指定平台,以及如何在 Gradle 中显式选择 classifier。文档中的 Maven javacpp.platform 属性筛选方式不能直接套用于 Gradle。JavaCPP 按平台缩减依赖

这两个项目都采用了分开发布的方式。借鉴类似的 artifact 布局时,可以保留现有 JNI 实现。

发布时还要注意什么?

  • 明确支持矩阵:操作系统和架构之外,Linux 还可能需要区分 glibc 与 musl,以及最低兼容版本。
  • 匹配进程架构:应匹配实际运行 JVM 的架构,例如 ARM 机器上的 x86_64 JVM 仍需要对应的 x86_64 原生库。
  • 保持接口兼容:JVM JAR 与原生 artifact 应采用明确的版本对应关系,避免 JNI 方法或 C ABI 不匹配。
  • 检查最终分发包:声明 runtimeOnly 负责加入运行时依赖;最终 ZIP、安装包或容器是否携带该 JAR,还取决于应用的打包配置。
  • 保留多平台部署选择:如果一个应用安装包必须在多个系统运行,可以携带多个独立原生 JAR,并在运行时选择;如果按平台生成安装包,每个包只放对应平台的文件即可。

对于当前问题,可以先采用“通用 JVM JAR + 显式选择的原生 classifier JAR”。需要简化接入时,再增加 Gradle variants 或配套插件。

备注:内容仅供参考。