kmp的实际使用2,开发desktop桌面端的体验

1,451 阅读4分钟

前言:cmp使用了大半年了,说一下感受

源码地址:gitee.com/lsgold/comp…

架构: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会自动打包到应用程序,对应平台的目录也会自动合并

1750818659039.png

1750818348662.png

在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的配置

1750819336889.png

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后被拷贝到这里, 1750819696834.png

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是真的乱,