【跨平台】Compose Desktop进行JNI开发

本文介绍了使用Compose Multiplatform开发时,如何使用JNI接口
在 Kotlin Multiplatform (KMP) 项目中,要在桌面端(通常指 JVM 桌面应用,如 macOS, Windows, Linux)进行 JNI (Java Native Interface) 开发,核心思路是利用 KMP 的 expect/actual 机制,为桌面 JVM 平台提供 JNI 的实际实现。
JNI 简介
JNI 允许 Java 代码(或运行在 JVM 上的 Kotlin 代码)调用原生应用程序(用 C/C++ 等语言编写)或库,反之亦然。在 KMP 桌面端,这通常用于:
- 集成现有 C/C++ 库: 如果你有成熟的原生库,JNI 是将其集成到 Kotlin 桌面应用的桥梁。
- 访问平台特定功能: 某些操作系统级别的功能可能没有 JVM 或 Kotlin 友好的 API,此时可以通过 JNI 调用原生 API。
- 性能敏感部分: 对于某些计算密集型任务,原生代码可能提供更好的性能。
KMP 桌面端 JNI 开发步骤
以下是使用 Kotlin Multiplatform 在桌面端进行 JNI 开发的详细步骤:
1. 设置 KMP 项目结构
确保你的 KMP 项目有一个 JVM 桌面模块。通常,你的 build.gradle.kts 文件会包含类似这样的配置:
// shared/build.gradle.kts
kotlin {
jvm() // 这是针对桌面 JVM 平台的 target
// ... 其他平台,如 android()
sourceSets {
val commonMain by getting {
// ... common code
}
val jvmMain by getting {
dependencies {
// JNI 相关的依赖,通常在原生库编译时用到
// 但在 Kotlin 代码中直接与 JNI 交互不需要额外的 Kotlin/JVM 依赖
}
}
}
}
2. 定义 JNI 接口(Common Main)
在 commonMain 的 expect 类或接口中,定义你希望原生代码提供的功能。这与你在 Android 中使用 expect 声明平台特定功能的方式相同。
// shared/src/commonMain/kotlin/com/example/shared/NativeLib.kt
package com.example.shared
// 期望提供一个获取字符串的本地方法
expect class NativeLib() {
fun getStringFromNative(): String
}
3. 实现 JNI 接口(JVM Main)
在 jvmMain 中,提供 expect 接口的 actual 实现。这个 actual 类将负责加载原生库,并声明 external 函数来映射到原生 C/C++ 方法。
// shared/src/jvmMain/kotlin/com/example/shared/NativeLib.jvm.kt
package com.example.shared
actual class NativeLib {
// 静态代码块用于加载 JNI 库
companion object {
init {
// "native_lib" 是你的 C/C++ 库的名称,不带前缀和后缀(例如 libnative_lib.so, native_lib.dll, libnative_lib.dylib)
System.loadLibrary("native_lib")
}
}
// 声明一个 external 方法,它会映射到 C/C++ 中的 JNI 函数
external fun getStringFromNative(): String
}
4. 生成 JNI 头文件(.h)
编译 jvmMain 模块,它会生成 .class 文件。然后,你可以使用 javah 工具(JDK 自带)或 javac -h 命令来生成 JNI 头文件。这个头文件定义了你需要用 C/C++ 实现的 JNI 函数签名。
步骤:
编译
jvmMain: 运行 Gradle 命令编译你的项目,例如gradlew :shared:compileJvmMainKotlin。这会在shared/build/classes/kotlin/jvm/main/com/example/shared/NativeLib.class生成类文件。生成头文件: 打开终端,导航到你的项目根目录,然后执行以下命令。
- 对于 Java 8 及更早版本,使用
javah:javah -jni -d src/main/c++ shared/build/classes/kotlin/jvm/main/com/example/shared/NativeLib这里
src/main/c++是你希望存放头文件的目录。 - 对于 Java 9 及更高版本,使用
javac -h:javac -h src/main/c++ shared/src/jvmMain/kotlin/com/example/shared/NativeLib.jvm.ktjavac -h是直接从.kt源文件(实际上是 Kotlin 编译后生成 JVM 字节码的能力)生成 JNI 头文件,更方便。
生成的头文件 (
com_example_shared_NativeLib.h) 会包含类似以下内容:/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class com_example_shared_NativeLib */ #ifndef _Included_com_example_shared_NativeLib #define _Included_com_example_shared_NativeLib #ifdef __cplusplus extern "C" { #endif /* * Class: com_example_shared_NativeLib * Method: getStringFromNative * Signature: ()Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_com_example_shared_NativeLib_getStringFromNative (JNIEnv *, jobject); #ifdef __cplusplus } #endif #endif- 对于 Java 8 及更早版本,使用
5. 编写原生 C/C++ 代码
现在,你可以根据生成的 .h 头文件,编写 C/C++ 源文件(例如 native_lib.cpp),实现 Java_com_example_shared_NativeLib_getStringFromNative 函数。
// shared/src/main/c++/native_lib.cpp
#include "com_example_shared_NativeLib.h" // 包含生成的 JNI 头文件
#include <iostream> // 仅作示例
JNIEXPORT jstring JNICALL Java_com_example_shared_NativeLib_getStringFromNative
(JNIEnv *env, jobject obj) {
// 可以在这里调用其他 C/C++ 库或执行复杂逻辑
std::string nativeString = "Hello from Native C++ in KMP Desktop!";
std::cout << "Native C++ code executed!" << std::endl; // 打印到控制台,调试用
return env->NewStringUTF(nativeString.c_str());
}
6. 编译原生库
你需要一个构建系统来编译你的 C/C++ 代码,并生成共享库文件(.so for Linux, .dylib for macOS, .dll for Windows)。常用的工具是 CMake 或 Gradle 的 Native Build 插件。
使用 CMake (推荐)
创建
CMakeLists.txt: 在你的shared模块下创建一个src/main/c++/CMakeLists.txt文件。# shared/src/main/c++/CMakeLists.txt cmake_minimum_required(VERSION 3.10) project(native_lib CXX) # 查找 JNI 头文件和库 find_package(JNI REQUIRED) # 添加你的 C++ 源文件 add_library(native_lib SHARED native_lib.cpp) # 链接 JNI 库 target_link_libraries(native_lib PRIVATE ${JNI_LIBRARIES}) # 设置输出目录,方便 Gradle 查找 set_target_properties(native_lib PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" # 输出到 build/lib 目录下 )配置 Gradle 调用 CMake: 在
shared/build.gradle.kts中配置cmake。// shared/build.gradle.kts android { // 如果有 Android 平台,通常会在这里配置 externalNativeBuild // ... externalNativeBuild { cmake { path("src/main/c++/CMakeLists.txt") // CMakeLists.txt 的路径 } } } // 或者为 JVM 目标单独配置 Native 构建(如果只有 JVM 桌面) // 通常在 desktop 或 jvm target 的 task 中调用 CMake tasks.register("buildNativeLib", Exec::class) { dependsOn("compileJvmMainKotlin") // 确保 NativeLib.class 已生成,方便 javac -h workingDir = file("src/main/c++") // CMakeLists.txt 所在目录 commandLine("cmake", "-B", "build", "-DCMAKE_BUILD_TYPE=Release") // 配置 build 目录 commandLine("cmake", "--build", "build") // 运行构建 } // 确保在构建 JVM 应用时先构建原生库 tasks.getByName("jvmMainClasses") { dependsOn("buildNativeLib") } // 或者更简洁地通过 Gradle 的 native 插件来管理,但更复杂 // 或者直接在你的 desktop 模块的 run task 中手动复制库到 classpath注意: 对于桌面 KMP,最直接的方法是:
- 手动运行
cmake和cmake --build命令来生成库。 - 或者使用 Gradle 的
Exec任务来自动化这个过程。 - 然后将生成的
.so/.dylib/.dll文件放置到 JVM 运行时能够找到的路径,例如:- 打包到 JAR 中(不太常见,因为需要特殊的 ClassLoader)
- 放在 JVM 应用启动时
java.library.path指定的目录。 - 最简单的是,放在运行应用的目录的
libs文件夹下,或者直接放在项目的shared/src/jvmMain/resources目录下,这样它会被打包进 JAR,但加载时可能仍需要java.library.path。更推荐将原生库复制到最终可执行文件的同级目录。
- 手动运行
7. 运行 KMP 桌面应用
在你的桌面应用模块(例如 desktop/src/main/kotlin/Main.kt)中,你可以像调用普通 Kotlin 函数一样调用你的 NativeLib。
// desktop/src/jvmMain/kotlin/Main.kt (或其他桌面端入口文件)
import com.example.shared.NativeLib
fun main() {
val nativeLib = NativeLib()
val message = nativeLib.getStringFromNative()
println("Message from native: $message")
}
运行注意事项:
- 原生库路径: 当你运行桌面 JVM 应用程序时,JVM 需要能够找到你编译好的原生库文件。
- 最常见且推荐的做法: 将生成的
.so,.dylib,.dll文件放置在你的应用程序的可执行 JAR 文件所在的目录,或者一个名为libs的子目录中。 - 通过
java.library.path: 你可以在启动 JVM 时通过-Djava.library.path=/path/to/your/native/libs参数指定原生库的查找路径。 - 在 Gradle 中打包: 某些 Gradle 插件可以帮助你将原生库打包到最终的可执行文件中(例如
jpackage或一些shadowJar配置),但这会增加复杂性。
- 最常见且推荐的做法: 将生成的
总结与建议
在 KMP 桌面端进行 JNI 开发是一个相对复杂的过程,因为它涉及到 Kotlin/JVM、C/C++ 和构建系统(Gradle, CMake)之间的协作。
关键点:
expect/actual: 这是 KMP 实现平台特定功能的基石。external关键字: 告诉 Kotlin 编译器这个方法将由外部原生代码提供。System.loadLibrary(): 在actual实现中加载你的原生库。javah/javac -h: 生成正确的 JNI 头文件,确保 C/C++ 函数签名正确。- CMake 或其他原生构建系统: 用于编译你的 C/C++ 代码并生成共享库。
- 原生库部署: 确保 JVM 运行时能够找到你的
.so,.dylib,.dll文件。
建议:
- 只在必要时使用 JNI: JNI 会增加项目的复杂性(需要维护 C/C++ 代码,处理内存管理,平台兼容性等)。如果 Kotlin/JVM 本身可以完成任务,尽量避免使用 JNI。
- 考虑 Kotlin/Native: 如果你的目标平台是完全原生的(例如,直接构建 macOS/Windows/Linux 可执行文件而不是 JVM 应用),Kotlin/Native 可能是更好的选择,它允许你直接调用 C 语言家族的库,而无需 JNI 的开销。但如果你需要利用 JVM 生态系统,JNI 是你的选择。
- 逐步进行: 从一个简单的 JNI 调用开始,逐步添加更复杂的功能。
- 查阅官方文档: JNI 和 Kotlin Multiplatform 的官方文档是最好的资源。