为 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 随便打开一个已有的项目,然后依次选择菜单 File - New - New Module - Android Native Library,保持默认参数,点击 Finish 即可完成创建安卓端的 jni 模块。

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

1.jpg

创建完成后需要注意,Android studio 会自动修改项目 settings.gradle.kts 在其中添加一个插件 org.jetbrains.kotlin.android ,这会导致编译错误 java.lang.IllegalArgumentException: Cannot provide multiple default versions for the same plugin.,所以需要我们删掉新添加的这个插件:

2.jpg

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

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

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

3.jpg

我们看一下 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(): Stringcompanion 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++” 了。

运行代码,可以看到完美符合预期:

4.gif

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:\Users\equationl\.jdks\corretto-19.0.2\include -I C:\Users\equationl\.jdks\corretto-19.0.2\include\win32 编译我们的 C++ 文件为当前平台可用的二进制文件。

上述命令中 nativelib.cpp 即需要编译的文件,nativelib.bin 为输出的二进制文件,C:\Users\equationl\.jdks\corretto-19.0.2\ 为你电脑上安装的任意的 jdk 目录。

输入 “ j d k P a t h / i n c l u d e " 和 " jdkPath/include" 和 " jdkPath/include""jdkPath/include/win32” 是因为这两个目录下有我们的 C++ 文件导入所需的头文件,如 “jni.h” 。

切换到我们的 C++ 文件所在目录后执行上述命令编译:

5.jpg

此时我们可以看到在 “./nativelib/src/main/cpp” 目录下已经生成了 nativelib.bin 文件。

注意:在 macOS 上系统自带了 g++ 命令,但是一般来说 Windows 系统没有自带 g++ 命令,所以需要先自己安装 g++

然后,我们在 sahred 模块下的 desktopMain 包中新建一个文件 NativeLib.kt ,注意该文件的包名需要和 C++ 定义的一致:

6.jpg

然后编写该文件内容为:

package com.equationl.nativelibclass NativeLib {external fun stringFromJNI(): Stringcompanion 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 程序:

7.gif

运行结果完美符合预期。

为 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().javaHomeval systemPrefix: Stringval 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 path\n${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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/264484.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

邮件营销软件:10个创新邮件营销策略,提升投资回报率(一)

电子商务和电子邮件营销密不可分。尽管电子商务在蓬勃发展&#xff0c;而很多人对邮件营销颇有微词。但是在电子商务中&#xff0c;邮件营销的确是一种有效营销方式。在本文中&#xff0c;我们将讨论一下邮件营销在电子商务中的有效运用&#xff0c;帮助您的企业在今年尽可能地…

layui分页laypage结合Flask+Jinja2实现流程

Layui2.0普通用法<!DOCTYPE html> <html> <head><meta charset"utf-8"><meta name"viewport" content"widthdevice-width, initial-scale1"><title>Demo</title><!-- 请勿在项目正式环境中引用该 …

研表究明,文字的序顺并不定一能响影GPT-4读阅

深度学习自然语言处理 原创作者&#xff1a;yy 很多年前&#xff0c;你一定在互联网上看过这张图&#xff0c;展示了人脑能够阅读和理解打乱顺序的单词和句子&#xff01;而最近东京大学的研究发现&#xff0c;大语言模型&#xff08;LLMs&#xff09; 尤其是 GPT-4&#xff0c…

C++枚举类

枚举 C11有作用域枚举和无作用域枚举 无作用域枚举 特点 全局作用域&#xff1a;无作用域枚举的成员&#xff08;枚举值&#xff09;在包含它们的作用域内是直接可见的&#xff0c;不需要使用枚举类型名称作为前缀。 隐式类型转换&#xff1a;无作用域枚举的成员可以隐式地转换…

C++ 模拟实现vector

目录 一、定义 二、模拟实现 1、无参初始化 2、size&capacity 3、reserve 4、push_back 5、迭代器 6、empty 7、pop_back 8、operator[ ] 9、resize 10、insert 迭代器失效问题 11、erase 12、带参初始化 13、迭代器初始化 14、析构函数 完整版代码 一、…

TCP的滑动窗口机制

网络的错误检测和补偿机制非常复杂。 一、等待超时时间&#xff08;返回ACK号的等待时间&#xff09; 当网络繁忙时会发生拥塞&#xff0c;ACK号的返回变慢&#xff0c;较短的等待时间会导致频繁的数据重传&#xff0c;导致本就拥塞的网络雪上加霜。如果等待时间过长&#xf…

IT新闻资讯系统,使用mysql作为后台数据库,此系统具有显示数据库中的所有信息和删除两大功能。

表的准备&#xff1a; -- MySQL Administrator dump 1.4 -- -- ------------------------------------------------------ -- Server version 5.1.40-community /*!40101 SET OLD_CHARACTER_SET_CLIENTCHARACTER_SET_CLIENT */; /*!40101 SET OLD_CHARACTER_SET_RESULTSCHAR…

【PWN】学习笔记(二)【栈溢出基础】

课程教学 课程链接&#xff1a;https://www.bilibili.com/video/BV1854y1y7Ro/?vd_source7b06bd7a9dd90c45c5c9c44d12e7b4e6 课程附件&#xff1a; https://pan.baidu.com/s/1vRCd4bMkqnqqY1nT2uhSYw 提取码: 5rx6 C语言函数调用栈 一个栈帧保存的是一个函数的状态信息&…

架构设计系列之基础:初探软件架构设计

11 月开始突发奇想&#xff0c;想把自己在公司内部做的技术培训、平时的技术总结等等的内容分享出来&#xff0c;于是就开通了一个 Wechat 订阅号&#xff08;灸哥漫谈&#xff09;&#xff0c;开始同步发送内容。 今天&#xff08;12 月 10 日&#xff09;也同步在 CSDN 上开通…

nodejs微信小程序+python+PHP健身服务应用APP-计算机毕业设计推荐 android

人类的进步带动信息化的发展&#xff0c;使人们生活节奏越来越快&#xff0c;所以人们越来越重视信息的时效性。以往的管理方式已经满足不了人们对获得信息的方式、方便快捷的需求。即健身服务应用APP慢慢的被人们关注。首先&#xff0c;网上获取信息十分的实时、便捷&#xff…

mysql中的DQL查询

表格为&#xff1a; DQL 基础查询 语法&#xff1a;select 查询列表 from 表名&#xff1a;&#xff08;查询的结果是一个虚拟表格&#xff09; -- 查询指定的列 SELECT NAME,birthday,phone FROM student -- 查询所有的列 * 所有的列&#xff0c; 查询结果是虚拟的表格&am…

Vue3:表格单元格内容由:图标+具体内容 构成

一、背景 在Vue3项目中&#xff0c;想让单元格的内容是由 &#xff1a;图标具体内容组成的&#xff0c;类似以下效果&#xff1a; 二、图标 Element-Plus 可以在Element-Plus里面找是否有符合需求的图标iconfont 如果Element-Plus里面没有符合需求的&#xff0c;也可以在这…