前言:cmp使用了大半年了,说一下感受
架构:kmp,kotlin是2.1.21
ui:compose,版本是1.8.2
网络:ktor
数据存储:datastore/room,(目前除了js,都支持了)
平台是android/ios/desktop(macos、win10)/鸿蒙
关于桌面端desktop
桌面开发,如果想使用原生库相关功能,只能使用jni去调用h文件,还是采用kotlin/native引入jni和原生库
先说一下开发桌面端的注意事项
1.区分生成环境和资源目录,由于kts没有像android那样有buildTypes的BuildConfig这样的东西,所以目前只有在kts声明1个定义,声明是否是生产环境,
compose.desktop {
application {
mainClass = "com.example.composeApp.MainKt"
jvmArgs+= listOf("-Dfile.encoding=UTF-8")
// 为不同构建类型设置不同的环境变量
buildTypes {
release {
if(args.contains("env=")){
val env= args.first { it.startsWith("env=") }
args.remove(env)
}
args += listOf("env=production")
// proguard.isEnabled=false proguard { // configurationFiles.from(project.file("proguard-desktop-rules.pro")) isEnabled.set(false) obfuscate.set(false) optimize.set(false) }
}
}
nativeDistributions {
targetFormats(TargetFormat.Exe, TargetFormat.Dmg)
packageName = "desktop-test"
packageVersion = "1.0.1"
copyright = "ls all right"
description = "desktop to test"
vendor = "ls dev"
appResourcesRootDir.set(layout.projectDirectory.dir("jvmResources"))
然后再main方法判断是否有env=production,字段,判断是生产环境,
资源目录是指生成应用程序时自带的resource目录,主要是存放外部的jar和一些原生库文件(.dll/so/dylib) 具体看官方文档[www.jetbrains.com/help/kotlin…] appResourcesRootDir会自动打包到应用程序,对应平台的目录也会自动合并
在main 方法中写法如下
@OptIn(ExperimentalComposeUiApi::class)
fun main(args: Array<String>) {
val scheme = "myapp"
val appName = "desktop-test"
println(">>>参数====${args.toList()}")
val osName = System.getProperty("os.name").lowercase()
val isProdution = args.isNotEmpty() && args.contains("env=production")
val resourcesDir = if (isProdution) {
File(System.getProperty("compose.application.resources.dir"))
} else {
val devpath = System.getProperty("user.dir").plus(File.separator).plus("jvmResources")
println(">>>dev path---${devpath}")
File(devpath)
}
2.单实例运行
这种情况主要是为了预防2点,点击过快,生产2个实例,还有注册深层链接(比如web打开桌面应用)【www.jetbrains.com/help/kotlin… ,出现的多实例运行 原理是开1个socket,监听是否已存在,存在就杀死新开的实例,拿到传递的参数,给到已存在的实例
package com.example.composeApp
import kotlinx.io.IOException
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.BindException
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
object AppInstanceManager {
private const val PORT = 49200 // 确保这个端口未被占用
fun isAnotherInstanceRunning(): Boolean {
return try {
Socket().use { socket ->
socket.connect(InetSocketAddress("localhost", PORT), 500) // 添加超时
println("isAnotherInstanceRunning success")
true
}
} catch (e: Exception) {
println("isAnotherInstanceRunning error ${e.message}")
false
}
}
fun startInstanceServer(callback: (String) -> Unit) {
Thread({
try {
ServerSocket(PORT, 0, InetAddress.getByName("localhost")).use { serverSocket ->
println("Server socket started on port $PORT")
while (!Thread.currentThread().isInterrupted) {
try {
serverSocket.accept().use { socket ->
socket.soTimeout = 5000 // 设置socket超时
BufferedReader(InputStreamReader(socket.inputStream)).use { reader ->
val message = reader.readText()
println("Received message: $message")
callback(message)
}
}
} catch (e: SocketTimeoutException) {
// 正常超时,继续循环
} catch (e: IOException) {
println("Socket error: ${e.message}")
break
}
}
}
} catch (e: BindException) {
println("Port $PORT already in use: ${e.message}")
} catch (e: IOException) {
println("Failed to start server: ${e.message}")
}
}, "InstanceServerThread").apply {
isDaemon = true // 设置为守护线程
start()
}
}
fun sendToRunningInstance(message: String): Boolean {
return try {
Socket().use { socket ->
socket.connect(InetSocketAddress("localhost", PORT), 1000)
BufferedWriter(OutputStreamWriter(socket.outputStream)).use { writer ->
println(">>>.发送的socket消息----$message")
writer.write(message)
writer.flush()
}
}
true
} catch (e: Exception) {
false
}
}
}
//window平台要手写注册深层链接协议,写到注册表
package com.example.composeApp
import java.io.File
import java.nio.file.Paths
object RegistryUtil {
// 注册自定义协议
// fun registerUriScheme(scheme: String, appName: String, appPath: String) {
// val registryScript = """
// Windows Registry Editor Version 5.00
//
// [HKEY_CLASSES_ROOT$scheme]
// @="URL:$appName Protocol"
// "URL Protocol"=""
//
// [HKEY_CLASSES_ROOT$scheme\shell]
//
// [HKEY_CLASSES_ROOT$scheme\shell\open]
//
// [HKEY_CLASSES_ROOT$scheme\shell\open\command]
// @=""$appPath" "%1""
// """.trimIndent()
//
// val tempFile = File.createTempFile("register_${scheme}_protocol", ".reg")
// tempFile.writeText(registryScript)
//
// try {
// val process = ProcessBuilder("regedit", "/s", tempFile.absolutePath)
// .redirectOutput(ProcessBuilder.Redirect.INHERIT)
// .redirectError(ProcessBuilder.Redirect.INHERIT)
// .start()
// process.waitFor()
// } finally {
// tempFile.delete()
// }
// }
//
// // 检查协议是否已注册
// fun isUriSchemeRegistered(scheme: String): Boolean {
// return try {
// val process = ProcessBuilder("reg", "query", "HKEY_CLASSES_ROOT\$scheme", "/ve")
// .redirectOutput(ProcessBuilder.Redirect.PIPE)
// .redirectError(ProcessBuilder.Redirect.PIPE)
// .start()
// process.waitFor()
// process.inputStream.bufferedReader().readText().contains(scheme)
// } catch (e: Exception) {
// false
// }
// }
private const val REG_ADD_CMD = "reg add"
private const val REG_DELETE_CMD = "reg delete"
// 注册协议处理
fun registerProtocol(protocol: String, appPath: String, appName: String): Boolean {
val commands = listOf(
"$REG_ADD_CMD HKCU\Software\Classes\$protocol /ve /d "URL:$appName Protocol" /f",
"$REG_ADD_CMD HKCU\Software\Classes\$protocol /v "URL Protocol" /d "" /f",
"$REG_ADD_CMD HKCU\Software\Classes\$protocol\shell\open\command /ve /d "\"$appPath\" \"%1\"" /f"
)
return commands.all { executeCommand(it) }
}
// 取消注册协议
fun unregisterProtocol(protocol: String): Boolean {
val command = "$REG_DELETE_CMD HKCU\Software\Classes\$protocol /f"
return executeCommand(command)
}
// 检查协议是否已注册
fun isProtocolRegistered(protocol: String): Boolean {
val command = "reg query HKCU\Software\Classes\$protocol /ve"
return executeCommand(command, checkOnly = true)
}
private fun executeCommand(command: String, checkOnly: Boolean = false): Boolean {
return try {
val process = Runtime.getRuntime().exec(arrayOf("cmd", "/c", command))
if (!checkOnly) {
process.waitFor()
}
println(">>>注册协议是否成功${process.exitValue() == 0}")
process.exitValue() == 0
} catch (e: Exception) {
println(">>>注册协议失败===${e.message}")
false
}
}
}
mac配置plist的深层链接
macOS {
// dockName = "pdf转word"
iconFile.set(File(projectDir.absolutePath + File.separator + "icons" + File.separator + "logo.icns"))
bundleID = "com.ls.desktop"
val macExtraPlistKeys = """
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>my app deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
"""
infoPlist {
extraKeysRawXml = macExtraPlistKeys
}
}
main方法写法如下,要放在最前面,防止代码多次运行
// 检查是否已有实例运行
if (AppInstanceManager.isAnotherInstanceRunning()) {
val deepLink = args.filter {
it.startsWith("myapp")
**}**
if (deepLink.isNotEmpty()) {
// 将深层链接发送给已运行的实例
println("将深层链接发送给已运行的实例")
AppInstanceManager.sendToRunningInstance(deepLink[0])
}
// 退出当前实例
exitProcess(0)
return
}
val appPath = System.getProperty("java.class.path").split(appName).first().plus(appName)
.plus(File.separator).plus("${appName}.exe")
println(">>>>>当前路径===${appPath}")
// 注册URI协议(如果尚未注册)
3.关于应用配置,这些比较常规,贴一下kts配置
nativeDistributions {
targetFormats(TargetFormat.Exe, TargetFormat.Dmg)
packageName = "desktop-test"
packageVersion = "1.0.1"
copyright = "ls all right"
description = "desktop to test"
vendor = "ls dev"
// 为不同构建类型设置不同的环境变量
appResourcesRootDir.set(layout.projectDirectory.dir("jvmResources"))
windows {
shortcut = true
dirChooser = true
upgradeUuid = "e7d5e736-a824-cdec-3c63-9df9860656ed"
iconFile.set(File(projectDir.absolutePath + File.separator + "icons" + File.separator + "logo.ico"))
console = true
perUserInstall=true
// installationPath="D:\desktop-test"
menu=true
}
macOS {
// dockName = "pdf转word"
iconFile.set(File(projectDir.absolutePath + File.separator + "icons" + File.separator + "logo.icns"))
bundleID = "com.ls.desktop"
val macExtraPlistKeys = """
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>my app deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
"""
infoPlist {
extraKeysRawXml = macExtraPlistKeys
}
}
linux {
shortcut = true
}
}
下面开发说原生库的加载和kotlin/native的使用
首先建立1个单独的模块,desktop_native,配置kts,只声明jvm平台,编写native的配置
kts如下
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
import org.gradle.internal.impldep.org.junit.experimental.categories.Categories.CategoryFilter.include
import org.gradle.kotlin.dsl.sourceSets
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
// alias(libs.plugins.android.kotlin.multiplatform.library)
}
kotlin {
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_1_8)
}
}
val javahome = System.getProperty("java.home")
val jni_home = "${javahome}/include"
val window_home = "${javahome}/include/win32"
val linux_home = "${javahome}/include/linux"
val macos_home = "${javahome}/include/darwin"
listOf(
mingwX64(),
// linuxX64(),
// macosX64(),
).forEach {
val path = if (it.name.contains("mingwX64")) {
"windows"
} else if (it.name.contains("linuxX64")) {
"linux"
} else if (it.name.contains("macosX64")) {
"macos"
} else {
""
}
val nativePath="${rootProject.projectDir}\composeApp\jvmResources\${path}".replace("\\","/")
it.compilations.getByName("main") {
cinterops {
val jni by creating {
packageName("jni")
includeDirs(
jni_home,
window_home,
linux_home,
macos_home
)
headers(
project.fileTree(jni_home)
)
}
val hello by creating {
packageName("hello")
includeDirs(
"src/nativeInterop/cinterop/headers"
)
compilerOpts+=listOf(
"-I${nativePath}",
"-I${project.file("src/nativeInterop/cinterop/headers")}",
)
headers(
project.fileTree("src/nativeInterop/cinterop/headers")
)
}
}
}
it.binaries {
executable {
entryPoint = "com.example.native.main"
}
sharedLib { // 编译为 .so
baseName = "native_lib" // 输出 libnative-lib.so
outputDirectory = file("${rootProject.projectDir}/composeApp/jvmResources/${path}")
println("nativePath=$nativePath")
linkerOpts+=listOf(
"-L${nativePath}",
"-ltest_c_lib",
)
}
}
}
// Source set declarations.
// Declaring a target automatically creates a source set with the same name. By default, the
// Kotlin Gradle Plugin creates additional source sets that depend on each other, since it is
// common to share sources between related targets.
// See: https://kotlinlang.org/docs/multiplatform-hierarchy.html
sourceSets {
nativeMain {
dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
jvmMain {
dependencies {
// dependsOn(nativeMain.get())
// Add Android-specific dependencies here. Note that this source set depends on
// commonMain by default and will correctly pull the Android artifacts of any KMP
// dependencies declared in commonMain.
}
}
}
}
还是和上篇的android一样,
写好native配置和生成目录,
拷贝生成结果到对应的平台目录下
注意,jvm平台只有引入jni来生成native方法来进行注册,因为native在jvm没有内置jni,android是有内置的
@file:OptIn(ExperimentalNativeApi::class, ExperimentalForeignApi::class)
package com.example.native
import kotlinx.cinterop.*
import kotlin.experimental.ExperimentalNativeApi
import jni.*
import platform.posix.printf
import hello.*
import kotlinx.cinterop.internal.CStruct
import platform.posix.strlen
fun getNativeMessageDy(
env: CPointer<JNIEnvVar>,
thiz: jobject,
input: jstring
) : jstring? {
val envVar=env.pointed.pointed!!
// hello()
val str= memScoped {
val cstr = envVar.GetStringUTFChars!!.invoke(env, input, null) ?: return@memScoped null
val kstr = cstr.toKStringFromUtf8()
envVar.ReleaseStringUTFChars!!(env, input, cstr)
printf("Kotlin", "Hello string------%s", kstr)
val outputStr = "Native processed: $kstr"
return envVar.NewStringUTF!!(env, outputStr.cstr.ptr)
}
return str
}
fun cpp_add(
env: CPointer<JNIEnvVar>,
thiz: jobject,
a: jint,
b: jboolean
) {
// 将 JNI 类型转换为 Kotlin 类型
val kotlinA: Int = a.toInt()
val kotlinB: Boolean = b.toByte() != 0.toByte() // jboolean 是 0 或 1
// 调用业务逻辑
println("Received values: a=$kotlinA, b=$kotlinB")
}
@CName(externName="JNI_OnLoad")
fun JNI_OnLoad(vm: CPointer<JavaVMVar>, preserved: COpaquePointer): jint {
return memScoped {
val envStorage = alloc<CPointerVar<JNIEnvVar>>()
val vmValue = vm.pointed.pointed!!
val result = vmValue.GetEnv!!(vm, envStorage.ptr.reinterpret(), JNI_VERSION_1_6)
if (result == JNI_OK) {
val env = envStorage.pointed!!.pointed!!
val jclass = env.FindClass!!(envStorage.value, "com/example/native/NativeJvmLib".cstr.ptr)
val jniMethod = allocArray<JNINativeMethod>(2)
jniMethod[0].fnPtr = staticCFunction(::getNativeMessageDy)
jniMethod[0].name = "getNativeMessageDy".cstr.ptr
jniMethod[0].signature = "(Ljava/lang/String;)Ljava/lang/String;".cstr.ptr
jniMethod[1].fnPtr = staticCFunction(::cpp_add)
jniMethod[1].name = "cppAdd".cstr.ptr
jniMethod[1].signature = "(IZ)V".cstr.ptr
env.RegisterNatives!!(envStorage.value, jclass, jniMethod, 2)
}else{
return@memScoped JNI_ERR
}
JNI_VERSION_1_6
}
}
@CName(externName="Java_com_example_native_NativeJvmLib_getNativeMessage")
fun getNativeMessage(env: CPointer<JNIEnvVar>, thiz: jobject): jstring {
memScoped {
return env.pointed.pointed!!.NewStringUTF!!(env, "Hello from Kotlin/Native!".cstr.ptr)!!
}
}
fun testC_1(){
memScoped {
val str="哈哈哈哈"
val size=strlen(str)
printf("长度---%d",size.toInt())
}
}
写法和android一样,比较常规
对应的kotlin方法声明
package com.example.native
import java.io.File
import java.lang.System.currentTimeMillis
object NativeJvmLib {
external fun getNativeMessage():String
external fun getNativeMessageDy(aa:String):String?
external fun cppAdd(a:Int,b: Boolean)
}
接下来是实现加载原生库,
因为每个平台的名称和后缀不一样,所以写个了方法,
上面之所以写资源目录,就是为了获取原生库的路径
比如win平台,native生成dll后被拷贝到这里,
fun getNativeAfterPath(prefix: String, name: String): String {
var sysType = if (osName.contains("window", true)) "windows"
else if (osName.contains("mac",true)) "macos"
else "linux"
if(isProdution) sysType=""
val syssuffix = if (osName.contains("window", true)) "dll"
else if (osName.contains("mac",true)) "dylib"
else "so"
var fullPath =
prefix.plus(File.separator).plus(sysType).plus(File.separator).plus(name).plus(".").plus(syssuffix)
fullPath = fullPath.replace("[/\\]".toRegex(), "/")
return fullPath
}
val libpath =getNativeAfterPath( resourcesDir.absolutePath,"native_lib")
System.load(libpath)
println(">>>>jni from TestNativeJni====${NativeJvmLib.getNativeMessage()}")
println(">>>>jni from TestNativeJni====${NativeJvmLib.getNativeMessageDy("很符合防护服")}")
println(">>>>jni from TestNativeJni====${NativeJvmLib.cppAdd(1,true)}")
其他原生库也是放到这种目录,加载方法一样
val libopencv = getNativeAfterPath(resourcesDir.absolutePath, "opencv_java4110")
System.load(libopencv)
我在linux和mac平台也是能正常跑起来了
只是在linux平台,因为有ghome桌面的原因,配置jni路径,只能引入
```
headers("${jni_home}/jni.h","${linux_home}/jni_md.h",)
```
总结:
其实jvm平台,主要是要熟悉配置,
因为平台差异,无法交叉编译,需要每个平台进行单独编译,
官方文档有实时更新,这个要注意去看,mac和linux平台比较规范,win是真的乱,