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

【跨平台】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)

commonMainexpect 类或接口中,定义你希望原生代码提供的功能。这与你在 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 函数签名。

步骤:

  1. 编译 jvmMain 运行 Gradle 命令编译你的项目,例如 gradlew :shared:compileJvmMainKotlin。这会在 shared/build/classes/kotlin/jvm/main/com/example/shared/NativeLib.class 生成类文件。

  2. 生成头文件: 打开终端,导航到你的项目根目录,然后执行以下命令。

    • 对于 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.kt
      

      javac -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
    

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 (推荐)

  1. 创建 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 目录下
    )
    
  2. 配置 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,最直接的方法是:

    • 手动运行 cmakecmake --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)之间的协作。

关键点:

  1. expect/actual 这是 KMP 实现平台特定功能的基石。
  2. external 关键字: 告诉 Kotlin 编译器这个方法将由外部原生代码提供。
  3. System.loadLibrary()actual 实现中加载你的原生库。
  4. javah / javac -h 生成正确的 JNI 头文件,确保 C/C++ 函数签名正确。
  5. CMake 或其他原生构建系统: 用于编译你的 C/C++ 代码并生成共享库。
  6. 原生库部署: 确保 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 的官方文档是最好的资源。