为 Compose MultiPlatform 添加 C/C++ 支持(2):在 jvm 平台使用 jni 实现桌面端与 C/C++ 互操作

为 Compose MultiPlatform 添加 C/C++ 支持(2):在 jvm 平台使用 jni 实现桌面端与 C/C++ 互操作

前言

在上篇文章中咱们现已介绍了完结 Compose MultiPlatform 对 C/C++ 互操作的基本思路。

而且先介绍了在 kotlin native 渠道运用 cinterop 完结与 C/C++ 的互操作。

今天这篇文章将弥补在 jvm 渠道运用 jni。

在 Compose MultiPlatform 中,运用 jvm 渠道的是 Android 端和 Desktop 端,而安卓端能够直接运用安卓官方的 NDK 完结穿插编译,可是 Desktop 不仅不支撑穿插编译,甚至连运用 Gradle 主动编译都没有。

所以本文重点主要在于完结 Desktop 的 jni 编译以及调用编译出来的二进制库。

Android 运用 jni

在介绍 Desktop 运用 jni 之前,咱们先回忆一下在 Android 中运用 jni,并复用 Android 端的 C++ 代码给 Desktop 运用。

感谢谷歌的作业,在安卓中运用 jni 非常简单,咱们只需求在 Android Studio 随意打开一个已有的项目,然后依次挑选菜单 FileNewNew ModuleAndroid Native Library,坚持默认参数,点击 Finish 即可完结创立安卓端的 jni 模块。

这儿咱们以 jetBrains 的官方 Compose MultiPlatform 模板 项目作为示例:

为 Compose MultiPlatform 增加 C/C++ 支撑(2):在 jvm 渠道运用 jni 完结桌面端与 C/C++ 互操作

创立完结后需求留意,Android studio 会主动修正项目 settings.gradle.kts 在其中增加一个插件 org.jetbrains.kotlin.android ,这会导致编译错误 java.lang.IllegalArgumentException: Cannot provide multiple default versions for the same plugin.,所以需求咱们删掉新增加的这个插件:

为 Compose MultiPlatform 增加 C/C++ 支撑(2):在 jvm 渠道运用 jni 完结桌面端与 C/C++ 互操作

然后在 shared 模块中的 build.gradle.kts 文件的 Android 依靠部分引进 nativelib 模块:

kotlin {
	// ……
    sourceSets {
    	// ……
        val androidMain by getting {
            dependencies {
                // ……
                api(project(":nativelib"))
            }
        }
        // ……
    }
}

接着,需求留意 nativelib 模块的两个文件 native.cppNativeLib.kt

为 Compose MultiPlatform 增加 C/C++ 支撑(2):在 jvm 渠道运用 jni 完结桌面端与 C/C++ 互操作

咱们看一下 nativelib 模块中的 nativelib.cpp 文件的默认内容:

#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_equationl_nativelib_NativeLib_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "C++";
    return env->NewStringUTF(hello.c_str());
}

代码很简单,便是回来一个字符串 “Hello from C++”,咱们改成回来 “C++”。

这儿需求留意这个函数的称号: Java_com_equationl_nativelib_NativeLib_stringFromJNI

开头的 “Java” 是固定字符,后面的 “com_equationl_nativelib_NativeLib” 表明从 java 调用时的类的包名+类名,最终的 “stringFromJNI” 才是这个函数的称号。

经过 jni 从 java(kt)中调用这个函数时有必要确保其包名和类名与其共同才干成功调用。

然后查看 NativeLib.kt 文件:

class NativeLib {
    external fun stringFromJNI(): String
    companion object {
        init {
            System.loadLibrary("nativelib")
        }
    }
}

其中 external fun stringFromJNI(): String 表明需求调用的 c++ 函数名。

System.loadLibrary("nativelib") 表明加载 C++ 编译生成的二进制库,这儿咱们无需关怀详细的编译进程和编译产品,只需求直接加载 nativelib 即可,剩下的作业 NDK 现已替咱们完结了。

最终,咱们来调用一下这个 C++ 函数。

不过在此之前先简单介绍一下咱们用作示例的这个 Compose MultiPlatform 的内容,它的 UI 便是一个按钮,按钮默认显现 “Hello, World!”,当点击按钮后会经过一个 expect 函数获取当时渠道的称号然后显现到按钮上:

@OptIn(ExperimentalResourceApi::class)
@Composable
fun App() {
    MaterialTheme {
        var greetingText by remember { mutableStateOf("Hello, World!") }
        var showImage by remember { mutableStateOf(false) }
        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Button(onClick = {
                greetingText = "Hello, ${getPlatformName()}"
                showImage = !showImage
            }) {
                Text(greetingText)
            }
            AnimatedVisibility(showImage) {
                Image(
                    painterResource("compose-multiplatform.xml"),
                    contentDescription = "Compose Multiplatform icon"
                )
            }
        }
    }
}
expect fun getPlatformName(): String

所以接下来咱们修正安卓渠道的 getPlatformName 函数的 actual 完结,由:

actual fun getPlatformName(): String = "Android"

修正为:

actual fun getPlatformName(): String = NativeLib().stringFromJNI()

这样,它获取的称号便是来自 C++ 代码的 “C++” 了。

运转代码,能够看到完美契合预期:

Desktop 运用 jni

上一节咱们现已完结了在 Android 中运用 jni,本节咱们将在 Desktop 中也完结运用 jni,而且复用上节中的 nativelib.cpp 文件。

由于直接运用 Gradle 编译 C++ 代码不是很便利,而且还不支撑穿插编译,所以这儿咱们首要手动编译,验证可行后再自己编写 gradle 脚本完结主动编译。

有关编写 gradle 脚本的基础知识能够阅览我之前的文章 Compose Desktop 运用中的几个问题(分渠道加载资源、编写Gradle 使命下载渠道资源、桌面特有组件、鼠标&键盘事情) 了解。

首要,咱们能够运用指令 g++ nativelib.cpp -o nativelib.bin -shared -fPIC -I C:Usersequationl.jdkscorretto-19.0.2include -I C:Usersequationl.jdkscorretto-19.0.2includewin32 编译咱们的 C++ 文件为当时渠道可用的二进制文件。

上述指令中 nativelib.cpp 即需求编译的文件,nativelib.bin 为输出的二进制文件,C:Usersequationl.jdkscorretto-19.0.2 为你电脑上装置的任意的 jdk 目录。

输入 “jdkPath/include”和”jdkPath/include” 和 “jdkPath/include/win32″ 是由于这两个目录下有咱们的 C++ 文件导入所需的头文件,如 “jni.h” 。

切换到咱们的 C++ 文件地点目录后履行上述指令编译:

为 Compose MultiPlatform 增加 C/C++ 支撑(2):在 jvm 渠道运用 jni 完结桌面端与 C/C++ 互操作

此刻咱们能够看到在 “./nativelib/src/main/cpp” 目录下现已生成了 nativelib.bin 文件。

留意:在 macOS 上体系自带了 g++ 指令,可是一般来说 Windows 体系没有自带 g++ 指令,所以需求先自己装置 g++

然后,咱们在 sahred 模块下的 desktopMain 包中新建一个文件 NativeLib.kt ,留意该文件的包名需求和 C++ 定义的共同:

为 Compose MultiPlatform 增加 C/C++ 支撑(2):在 jvm 渠道运用 jni 完结桌面端与 C/C++ 互操作

然后编写该文件内容为:

package com.equationl.nativelib
class NativeLib {
    external fun stringFromJNI(): String
    companion object {
        init {
            System.load("D:\project\ideaProject\compose-multiplatform-c-test\nativelib\src\main\cpp\nativelib.bin")
        }
    }
}

能够看到在 Desktop 中加载二进制库和 Android 中略有不同,它运用的是 System.load() 而不是 System.loadLibrary() ,而且加载二进制文件时运用的是绝对途径。

这是由于咱们无法在 Desktop 中像 Android 一样直接把二进制文件打包到指定的途径下而且直接运用库名经过 System.loadLibrary() 加载,所以只能运用绝对途径加载外部二进制文件。

这儿咱们把加载的文件途径写为了从前生成的 nativelib.bin 的途径。

接着,依旧是修正 dektop 的 getPlatformName 函数的完结为:

actual fun getPlatformName(): String = NativeLib().stringFromJNI()

然后运转 Desktop 程序:

为 Compose MultiPlatform 增加 C/C++ 支撑(2):在 jvm 渠道运用 jni 完结桌面端与 C/C++ 互操作

运转结果完美契合预期。

为 Desktop 完结主动编译 C++

在上一节中咱们现已完结了 Desktop 运用 jni 并验证了可行性,可是现在仍是手动编译代码,这显然是不现实的,所以咱们本节将讲解怎么自己编写脚本完结主动编译。

另外,上一节中咱们说过, Dektop 加载二进制文件运用的是绝对途径,所以咱们需求将编译生成的二进制文件放到指定位置并打包进 Desktop 程序装置包中,Desktop 在装置时会主动将这个文件解压到指定途径,关于这个的基础知识仍是能够看我的文章 Compose Desktop 运用中的几个问题(分渠道加载资源、编写Gradle 使命下载渠道资源、桌面特有组件、鼠标&键盘事情) 了解。

首要,需求指定一下资源文件目录,在 desktopApp 模块的 buiuld.gradle.kts 文件中增加以下内容:

compose.desktop {
    application {
    	// ……
        nativeDistributions {
        	// ……
            appResourcesRootDir.set(project.layout.projectDirectory.dir("resources"))
        }
    }
}

指定资源目录为 resources

然后依旧是在这个文件中,增加一个函数 runCommand,用于履行 shell 指令:

fun runCommand(command: String, timeout: Long = 120): Pair<Boolean, String> {
    val process = ProcessBuilder()
        .command(command.split(" "))
        .directory(rootProject.projectDir)
        .redirectOutput(ProcessBuilder.Redirect.INHERIT)
        .redirectError(ProcessBuilder.Redirect.INHERIT)
        .start()
    process.waitFor(timeout, TimeUnit.SECONDS)
    val result = process.inputStream.bufferedReader().readText()
    val error = process.errorStream.bufferedReader().readText()
    return if (error.isBlank()) {
        Pair(true, result)
    }
    else {
        Pair(false, error)
    }
}

代码很简单,接收一个字符串表明的 shell 指令,回来一个 Pair ,第一个 booean 数据表明是否履行成功;第二个 String 是输出内容。

接着注册一个 task:

tasks.register("compileJni") { }

修正原有的 prepareAppResources task,增加上咱们刚注册的 compileJni 为它的依靠:

gradle.projectsEvaluated {
    tasks.named("prepareAppResources") {
        dependsOn("compileJni")
    }
}

这儿的修正依靠需求加在 gradle.projectsEvaluated 语句中,由于 prepareAppResources 这个 task 推迟了注册,假如不在项目装备完结后再修正依靠的话会报 prepareAppResources 不存在。

注:这儿的 prepareAppResources 是 task 模块中用于履行仿制和打包资源文件的 task,所以咱们把自定义的 compileJni 增加成它的依靠,以确保在它之前履行。

另外,这儿有必要明确保证 compileJniprepareAppResources 之前履行,不然由于咱们的 compileJni 使命的输出途径和 prepareAppResources 使命的输出途径冲突,会导致编译失利,详细后面详细解释。

接着,在 compileJni task 中编写咱们的编译逻辑,咱们先看一下完整的代码,然后再逐个解释:

tasks.register("compileJni") {
    description = "compile jni binary file for desktop"
    val resourcePath = File(rootProject.projectDir, "desktopApp/resources/common/lib/")
    val binFilePath = File(resourcePath, "nativelib.bin")
    val cppFileDirectory = File(rootProject.projectDir, "nativelib/src/main/cpp")
    val cppFilePath = File(cppFileDirectory, "nativelib.cpp")
    // 指定输入、输出文件,用于增量编译
    inputs.dir(cppFileDirectory)
    outputs.file(binFilePath)
    doLast {
        project.logger.info("compile jni for desktop running……")
        val jdkFile = org.gradle.internal.jvm.Jvm.current().javaHome
        val systemPrefix: String
        val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
        if (os.isWindows) {
            systemPrefix = "win32"
        }
        else if (os.isMacOsX) {
            systemPrefix = "darwin"
        }
        else if (os.isLinux) {
            systemPrefix = "linux"
        }
        else {
            project.logger.error("UnSupport System for compiler cpp, please compiler manual")
            return@doLast
        }
        val includePath1 = jdkFile.resolve("include")
        val includePath2 = includePath1.resolve(systemPrefix)
        if (!includePath1.exists() || !includePath2.exists()) {
            val msg = "ERROR: $includePath2 not found!nMaybe it's because you are using JetBrain Runtime (Jbr)nTry change Gradle JDK to another jdk which provide jni support"
            throw GradleException(msg)
        }
        project.logger.info("Check Desktop Resources Path……")
        if (!resourcePath.exists()) {
            project.logger.info("${resourcePath.absolutePath} not exists, create……")
            mkdir(resourcePath)
        }
        val runTestResult = runCommand("g++ --version")
        if (!runTestResult.first) {
            throw GradleException("Error: Not find command g++, Please install it and add to your system environment pathn${runTestResult.second}")
        }
        val command = "g++ ${cppFilePath.absolutePath} -o ${binFilePath.absolutePath} -shared -fPIC -I ${includePath1.absolutePath} -I ${includePath2.absolutePath}"
        project.logger.info("running command $command……")
        val compilerResult = runCommand(command)
        if (!compilerResult.first) {
            throw GradleException("Command run fail: ${compilerResult.second}")
        }
        project.logger.info(compilerResult.second)
        project.logger.lifecycle("compile jni for desktop all done")
    }
}

首要,在 task 顶级定义了四个途径: resourcePathbinFilePathcppFileDirectorycppFilePath,分别表明需求寄存二进制文件的资源目录、二进制文件输出途径、C++文件寄存目录和需求编译的详细 C++ 文件途径。

rootProject.projectDir 回来的是当时项目的根目录。

接着,咱们经过 inputs.dir() 办法增加了该 task 的输入途径。

outputs.file 办法增加了该 task 的输出文件。

定义输入途径和输出文件与咱们这儿需求履行的编译没有直接相关,这儿定义这个两个途径是为了让 Gradle 完结增量编译,即只要在前次编译完结后输入途径的中的文件内容发生了改变或输出文件发生了改变才会继续履行这个 task,不然会以为这个 task 没有改变,不会履行,表现在编译输出日志则为:

> Task :desktopApp:compileJni UP-TO-DATE

接下来,咱们的代码写在了 doLast { } 语句中,则表明里边的代码只要在编译阶段才会履行,在装备阶段不会履行。

在其中的 org.gradle.internal.jvm.Jvm.current().javaHome 回来的是当时项目 Gradle 运用的 jdk 根目录。

然后,咱们需求拼接出编译时需求导入的两个 jdk 途径 includePath1includePath2 ,其中的 includePath2 不同的体系称号不一样,所以需求判别一下当时编译运用的体系并更改该值。 能够经过 DefaultNativePlatform.getCurrentOperatingSystem().isXXX 判别当时是否是某个体系。

接着,检查寄存二进制文件的目录是否存在,不存在则创立。

下一步是运用 g++ --version 测验是否装置了 g++ 。

最终,拼接出编译指令后履行编译:

g++ ${cppFilePath.absolutePath} -o ${binFilePath.absolutePath} -shared -fPIC -I ${includePath1.absolutePath} -I ${includePath2.absolutePath}

此刻假如编译成功,那么二进制文件会输出到咱们指定的 dektop 资源目录下。

咱们现在只需求修正 dektop 加载二进制文件的代码为:

val libFile = File(System.getProperty("compose.application.resources.dir")).resolve("lib").resolve("nativelib.bin")
System.load(libFile.absolutePath)

上述代码中 System.getProperty("compose.application.resources.dir") 回来的是咱们最开端在 Gradle 中定义的资源打包装置解压后在体系上的绝对途径。

至此,咱们的主动编译现已完结!

最终来说一下咱们前面说到的为什么咱们的 compileJni task 有必要在 prepareAppResources 之前履行,咱们现在直接把原本的修正 prepareAppResources 依靠于 compileJni 改成 Desktop 模块履行的第一个 task compileKotlinJvm 依靠 compileJni

tasks.named("compileKotlinJvm") {
    dependsOn("compileJni")
}

运转后会看到报错:

A problem was found with the configuration of task ':desktopApp:prepareAppResources' (type 'Sync').
  - Gradle detected a problem with the following location: '/Users/equationl/AndroidStudioProjects/life-game-compose/desktopApp/resources/common'.
    Reason: Task ':desktopApp:prepareAppResources' uses this output of task ':desktopApp:compileJni' without declaring an explicit or implicit dependency. This can lead to incorrect results being produced, depending on what order the tasks are executed.
    Possible solutions:
      1. Declare task ':desktopApp:compileJni' as an input of ':desktopApp:prepareAppResources'.
      2. Declare an explicit dependency on ':desktopApp:compileJni' from ':desktopApp:prepareAppResources' using Task#dependsOn.
      3. Declare an explicit dependency on ':desktopApp:compileJni' from ':desktopApp:prepareAppResources' using Task#mustRunAfter.

简单说便是 prepareAppResourcescompileJni 都声明了同一个输出途径,除非清晰指定它们两个之间的依靠关系,不然编译会出现问题。

其实也很好理解,他们的输出途径都是一个,假如不清晰依靠关系的话增量编译就永久不会触发了,永久都将是全量编译。

而在这儿咱们的需求是首要运用 compileJni 生成二进制文件后,由 prepareAppResources 将其打包,所以天然应该是写成 prepareAppResources 依靠于 compileJni

最终,仍是需求强调一点,Desktop 编译 C++ 是不支撑穿插编译的,也便是说在 Windows 只能编译 Windows 的程序,在 macOS 只能 编译 macOS 的程序。

其实即便 C++ 能够穿插编译也没用,由于 Compose Desktop 并不支撑穿插编译,哈哈哈。

参考资料

  1. Native dependency in Kotlin/Multiplatform — part 2: JNI for JVM & Android
  2. Kotlin JNI for Native Code