前言
在上篇文章中我們已經介紹了實現 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 模板 項目作為示例:
創建完成后需要注意,Android studio 會自動修改項目 settings.gradle.kts 在其中添加一個插件 org.jetbrains.kotlin.android
,這會導致編譯錯誤 java.lang.IllegalArgumentException: Cannot provide multiple default versions for the same plugin.
,所以需要我們刪掉新添加的這個插件:
然后在 shared
模塊中的 build.gradle.kts 文件的 Android 依賴部分引入 nativelib
模塊:
kotlin {// ……sourceSets {// ……val androidMain by getting {dependencies {// ……api(project(":nativelib"))}}// ……}
}
接著,需要注意 nativelib
模塊的兩個文件 native.cpp 和 NativeLib.kt:
我們看一下 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++” 了。
運行代碼,可以看到完美符合預期:
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++ 文件所在目錄后執行上述命令編譯:
此時我們可以看到在 “./nativelib/src/main/cpp” 目錄下已經生成了 nativelib.bin 文件。
注意:在 macOS 上系統自帶了 g++ 命令,但是一般來說 Windows 系統沒有自帶 g++ 命令,所以需要先自己安裝 g++
然后,我們在 sahred 模塊下的 desktopMain 包中新建一個文件 NativeLib.kt ,注意該文件的包名需要和 C++ 定義的一致:
然后編寫該文件內容為:
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 程序:
運行結果完美符合預期。
為 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
添加成它的依賴,以保證在它之前執行。
另外,這里必須明確保證 compileJni
在 prepareAppResources
之前執行,否則由于我們的 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 頂級定義了四個路徑: resourcePath
、 binFilePath
、cppFileDirectory
和 cppFilePath
,分別表示需要存放二進制文件的資源目錄、二進制文件輸出路徑、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 路徑 includePath1
和 includePath2
,其中的 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.
簡單說就是 prepareAppResources
和 compileJni
都聲明了同一個輸出路徑,除非明確指定它們兩個之間的依賴關系,否則編譯會出現問題。
其實也很好理解,他們的輸出路徑都是一個,如果不明確依賴關系的話增量編譯就永遠不會觸發了,永遠都將是全量編譯。
而在這里我們的需求是首先使用 compileJni
生成二進制文件后,由 prepareAppResources
將其打包,所以自然應該是寫成 prepareAppResources
依賴于 compileJni
。
最后,還是需要強調一點,Desktop 編譯 C++ 是不支持交叉編譯的,也就是說在 Windows 只能編譯 Windows 的程序,在 macOS 只能 編譯 macOS 的程序。
其實即使 C++ 可以交叉編譯也沒用,因為 Compose Desktop 并不支持交叉編譯,哈哈哈。
參考資料
- Native dependency in Kotlin/Multiplatform — part 2: JNI for JVM & Android
- Kotlin JNI for Native Code