Compose Multiplatform+Kotlin Multiplatfrom 第四弹跨平台 AI对话
文章目录
- 引言
- 功能效果
- 开发准备
- 依赖使用
- gradle依赖库
- MVI+Flow设计
- 富文本显示
- 总结
引言
Compose Multiplatform+Kotlin Multiplatfrom 今天已经到compose v1.7.3,从界面UI框架上实战开发看,很多api都去掉实验性注解,表示稳定使用了!然后继续这套框架做技术预研,上马目前所有系统,即Android、iOS、macOs、Windows、Linux(没有系统可验证)、Browser。
现在AI大模型是前沿技术广泛引用的排头兵,这次引入deepseek的多轮会话v3大模型,使用调用api的方式完成数据的显示。
*** 基于保密不会将重要的算法、工厂引擎代码透露。
功能效果
目前成功在Android真机,Macbook pro2022,Windows10,iOS 17模拟器运行,本地构建要依赖的库实在是太多了,稍微一个错编译就是很漫长,还有相当多技术在预研,后面后把完整代码公布出来。
运行效果图gif预览,点进去看不到要登录下gitee
gif图太大了,没法压缩到10mb,给出链接iOS/macOs
Windows版电脑有水印不好截图,放下载包.exe
开发准备
- 依托Android的compose框架为界面开发,Android studio最新版Android Studio Koala | 2024.1.1 Patch 1 Build #AI-241.18034.62.2411.12071903, built on July 11, 2024,本地的JDK为17或21,我把第三方库comShot引进来就要21,
XCode是编译iOS的,现在只运行过在模拟器,所有系统配置好环境变量。
开发电脑两台Windows 10专业版,MacBook2022,两边都可自主同步编译,主要问题是gradle.properties文件中指定了项目的JDK路径,
两台电脑是不一致的,必须清楚自己的JDK版本和路径,$java -version 。
- 去deepseek获取免费一个月的api key,其实换openAI的key也可以,但是注意api的路径和域名,确定当前compose plugin最新版本,目前我的所有依赖都是最新版本,已经踏平了很多坑。
- 明确需求,部分实现:
- 1.多轮机器对话 ,支持多个大模型切换
- 2.本地会话记录,sqlDelight数据库
- 3.黑暗模式切换,支持所有系统,支持代码框包裹
- 4.富文本对代码支持,对公式函数支持
- 5.系统图册选择后模型问答
- 6.截图后的模型问答,desktop端自由裁剪很难,移动端还行
- 7.多端编译,一套代码开发,适配不同机型和系统,磨平差异性
- 8.desktop端可随处弹出一个小功能窗快捷询问,移动端则可以是语音和浮窗长驻
- 9.系统粘贴版监听,本地存储粘贴记录
- 10.本地知识库构建,离线小模型处理,多场景搜索算法智能调用
- 10.MVI+Flow流+ViewModel+Koin3开发架构设计,我并没有引入第三方的框架来做,我去研读了下FlowMVI的设计,它引入了太多其他库,而且把逻辑块的设计完全剥离,感觉自己没完全理解不敢用,最终目的就是异步的流式编程,在声明式开发中用户意图单向流动到界面,数据源唯一可信,界面就能按我们的意图渲染,再结合副作用effect,觉得比原生好用好多,就是现在不太会画canvas
- 完全自动运行,类似手机AI应用帮我们点饭,解决隐私权限,模拟点击,持续日志信息队列分析
依赖使用
在multiplatform compose 开发建议还是多依赖第三方的团队sdk,实在没有的再搞接口expect去实现,因为多端差异性开发工作量实在是大,整个UI框架现在迭代速度也挺快的,运行的效果差异巨大,不同系统差异,当同系统下还有机型差异,系统版本插件,就键盘弹起的坑、状态栏样式等都不及预期。
-
通用类commonMain
-
openai-client,这个库用kotlin把整个大模型请求都包装好了,我在java时用okhttp3是成功实现SSE数据流的结果,但在kotlin multiplatform时每次接口数据都是等一会然后所有data包一次性返回来,我对比过openai-client的请求源码分析也没太大逻辑差别,暂时解决不了原生kotlin用ktor3请求SSE效果,有实现可以告知>- module = "com.codingfeline.buildkonfig:buildkonfig-gradle-plugin", version.ref = "buildkonfigGradlePlugin" } core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines-core" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesSwing" } kotlinX-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationJson" } uuid = { module = "com.benasher44:uuid", version.ref = "uuid" } windowSize = { module = "dev.chrisbanes.material3:material3-window-size-class-multiplatform", version.ref = "windowSize" } bottomSheet = { module = "com.github.skydoves:flexible-bottomsheet-material3", version.ref = "sheet" } paging-compose = { module = "app.cash.paging:paging-compose-common", version.ref = "pagingCommonVersion" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } toast4j = { module = "de.mobanisto:toast4j", version.ref = "toast4j" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } accompanist-systemUIController = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist-systemUIController" } napier = { module = "io.github.aakira:napier", version.ref = "napier" } stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } compose-webviews = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "webview" } #没有iOS的富文本处理 richtext-core = { module = "com.halilibo.compose-richtext:richtext-ui", version.ref = "richtext" } richtext-mark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" } richtext-markdown = { module = "com.halilibo.compose-richtext:richtext-markdown", version.ref = "richtext" } richtext-material = { module = "com.halilibo.compose-richtext:richtext-ui-material", version.ref = "richtext" } richtext-material3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" } rich-editor = { module = "com.mohamedrejeb.richeditor:richeditor-compose", version.ref = "richedit" } reveal = { module = "com.svenjacobs.reveal:reveal-core", version.ref = "reveal" } #capture-shot = { module = "ir.mahozad.multiplatform:comshot", version.ref = "capture" } #Android特有 androidx-perference = { module = "androidx.preference:preference-ktx", version.ref = "perference" } androidx-lifecycle = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidAppCompatVersion" } lifecycle-extension = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "androidLifecycleVersion" } android-bugly = { module = "com.tencent.bugly:crashreport", version.ref = "bugly" } permissionX-android = { module = "com.guolindev.permissionx:permissionx", version.ref = "permissionX" } #数据处理 file-picker = { module = "io.github.vinceglb:filekit-compose", version.ref = "file" } okio-core = { module = "com.squareup.okio:okio", version.ref = "okio" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings" } multiplatform-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "settings" } multiplatform-datastore = { module = "com.russhwolf:multiplatform-settings-datastore", version.ref = "settings" } multiplatform-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "settings" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } #依赖注入 koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } stately-common = { module = "co.touchlab:stately-common", version.ref = "stately" } #权限申请,兼容Android mokopermission = { module = "dev.icerock.moko:permissions", version.ref = "mokopermission" } mokopermission-compose = { module = "dev.icerock.moko:permissions-compose", version.ref = "mokopermission" } mokoMvvmCompose = { module = "dev.icerock.moko:mvvm-compose", version.ref = "mokoMvvmVersion" } precompose-navigator = { module = "moe.tlaster:precompose", version.ref = "precompose" } precompose-koin = { module = "moe.tlaster:precompose-koin", version.ref = "precompose" } precompose-viewmodel = { module = "moe.tlaster:precompose-viewmodel", version.ref = "precompose" } mokoMvvmCore = { module = "dev.icerock.moko:mvvm-core", version.ref = "mokoMvvmVersion" } #数据库 android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } sqldelight-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlDelight" } sqlDelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" } primitive-adapters = { module = "app.cash.sqldelight:primitive-adapters", version.ref = "sqlDelight" } sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlDelight" } #ktor 3.0网络连接 ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } #ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } #ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } #ktor engines #ktor-client-apache = { module = "io.ktor:ktor-client-apache", version.ref = "ktor" } #ktor-client-jetty = { module = "io.ktor:ktor-client-jetty", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-ios = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktoken = { group = "com.aallam.ktoken", name = "ktoken", version.ref = "ktoken" } openai-client = { module = "com.aallam.openai:openai-client", version.ref = "openai-client" } #https://coil-kt.github.io/coil/getting_started/ #coil3-core = { module = "io.coil-kt.coil3:coil", version.ref = "coil3" } coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" } coil3-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil3" } coil3-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil3" } #coil3-ktor = { module = "io.coil-kt.coil3:coil-network-ktor2", version.ref = "coil3" } #video/gif只有Android版 #coil3-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil3" } #coil3-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil3" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfigGradlePlugin" } sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } private val _drawerShouldBeOpened = MutableStateFlow(false) val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow() fun openDrawer() { _drawerShouldBeOpened.value = true } fun resetOpenDrawerAction() { _drawerShouldBeOpened.value = false } //全局参数状态 private val _configObs = MutableStateFlow(ModelConfigState()) val configState = _configObs.asStateFlow() private var lastTime = getMills() //本地主题值修改 private val _darkObs = MutableStateFlow(false) val darkState = _darkObs.asStateFlow() fun processConfig(intent: ModelConfigIntent) { when (intent) { is ModelConfigIntent.LoadData - { fetchModelConfig() } is ModelConfigIntent.UpdateData -> { if (getMills() - lastTime > 30000) { fetchModelConfig() } } } } fun processGlobal(intent: GlobalIntent) { when (intent) { is GlobalIntent.CheckDarkTheme -> { fetchDarkStatus() } } } private fun fetchModelConfig() { _configObs.update { it.copy(isLoading = true) } viewModelScope.launch(Dispatchers.IO) { try { val result = globalRepo.fetchModelConfig() _configObs.update { it.copy(isLoading = false, data = result) } } catch (e: Exception) { _configObs.update { it.copy(error = e.toString()) } } } } private fun fetchDarkStatus() { viewModelScope.launch { val isDark = getCacheBoolean(CODE_IS_DARK) _darkObs.value = isDark } } } data class ModelConfigState( val isLoading: Boolean = false, val data: List? = null, val error: String? = null ) //解决不了基类的写法 //data class ModelConfigState(val json: String? = null) : BaseUiState(data = json) sealed class ModelConfigIntent { //获取所有的大模型数据,解析后保存到本地 object LoadData : ModelConfigIntent() //判断时间间隔是否需要更新数据,主动拉取, data class UpdateData(val time: Long) : ModelConfigIntent() } MyState 继承自 UiState //data class MyState( // val items: List = emptyList() // 你可以根据具体需要定制数据类型 //) : BaseUiState(data = items)
富文本显示
//android + windows @Composable private fun TestBotMsgCard1(message: MessageModel) { // val chatViewModel = koinViewModel(ChatViewModel::class) // val isDark = chatViewModel.darkState.collectAsState().value val richTextStyle = RichTextStyle( codeBlockStyle = CodeBlockStyle( textStyle = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 13.sp, color = BackCodeTxtColor, ), wordWrap = true, modifier = Modifier.background( color = BackCodeGroundColor, shape = RoundedCornerShape(6.dp) ) ), stringStyle = RichTextStringStyle() ) //第一种 // com.halilibo.richtext.ui.material.RichText( // modifier = Modifier.padding( // horizontal = 18.dp, // vertical = 12.dp // ).background(MaterialTheme.colorScheme.onPrimary), // style = richTextStyle, // // ) { // //字体颜色对了,但是没能解析富文本的符合 Text(message.answer.trimIndent(), color = MaterialTheme.colorScheme.onTertiary) // // //没能改字体颜色 // Markdown(message.answer.trimIndent()) // } //第二 // val richTextState = rememberRichTextState() // richTextState.setMarkdown(message.answer.trimIndent()) // richTextState.config.codeSpanBackgroundColor= BackCodeGroundColor // richTextState.config.codeSpanColor= BackCodeTxtColor // ThemeChatLite { // RichTextEditor( // modifier = Modifier.padding( // horizontal = 18.dp, // vertical = 12.dp // ).background(MaterialTheme.colorScheme.onPrimary), state = richTextState, // textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onTertiary) // ) //第三 // val parser =CommonmarkAstNodeParser() // RichText( modifier = Modifier.padding( // horizontal = 18.dp, // vertical = 12.dp // ).background(MaterialTheme.colorScheme.onPrimary), // style = richTextStyle, // ){ // BasicMarkdown(astNode = parser.parse(message.answer.trimIndent())) // } //第四 追踪源码查看 RichTextMaterialTheme-》contentColorProvider 修改内部字体颜色,自定义代码颜色 RichTextThemeProvider( contentColorProvider = { MaterialTheme.colorScheme.onTertiary } ) { BasicRichText( modifier = Modifier.padding( horizontal = 18.dp, vertical = 12.dp ).background(MaterialTheme.colorScheme.onPrimary), style = richTextStyle, ) { Markdown(message.answer.trimIndent()) } } }
//ios端 //不会屏闪,也可现实代码,但是没有代码框,iOS端只要遇到代码就有线程报错日志 @Composable fun BotCommonCardApp(message: MessageModel) { val chatViewModel = koinViewModel(ChatViewModel::class) val isDark = chatViewModel.darkState.collectAsState().value val subScope = rememberCoroutineScope() val state = rememberRichTextState() LaunchedEffect(Unit) { state.removeLink() state.config.codeSpanBackgroundColor = BackCodeGroundColor state.config.codeSpanColor = BackCodeTxtColor chatViewModel.processGlobal(GlobalIntent.CheckDarkTheme) if (!state.isCodeSpan) { state.toggleCodeSpan() } // ```java //无法解析这个 只有 `Code span example` ,但是3点是代码块,一点是行内代码, } val answerState = remember { mutableStateOf("") } LaunchedEffect(message.answer.trimIndent()) { subScope.launch(Dispatchers.IO) { //貌似频繁IO val newMsg = message.answer.trimIndent().replace("```java", "`") .replace("```", "`") answerState.value = newMsg } } RichText( state = state.apply { state.setMarkdown(answerState.value) }, modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp) .background(MaterialTheme.colorScheme.onPrimary), color = MaterialTheme.colorScheme.onTertiary, style = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 13.sp, color = MaterialTheme.colorScheme.onTertiary ) ) }
总结
大模型的多轮对话实现总体不难,只是很多小问题,现在我只是实现了主体对话功能,把多端都跑起来了,进而熟悉整个MVI的开发设计,以前对依赖注入Koin不够重视觉得没啥应用,在声明式开发中现在非常好用,绝大数工具实体都不用new对象,而且借助remember和flow做到状态随处感知,界面渲染代码 少很多,后续引入各种库的高级用法优化代码结构。现在是AI大火,跨端开发AI应用探索下这技术路线的可行性,过于先进确实很多问题只能自己解决,多用chatgpt给点思路也行。
kotlin multiplatform 开发框架最大好处是直接用kotlin实现跨端逻辑代码,再结合compose multiplatform框架把UI框架也补全了,不需要像Futter要引入两个渲染引擎,一个引擎使用 C/C++ 开发,直接调用 OpenGL/Skia 的 API 进行绘制,从而摆脱 iOS 的 UIKit 以及 Android 的 View 组件直接渲染成需要的样式,保证样式高度统一,另一个是 Dart 语言的 Runtime,用于解析并运行 Dart 语言编译的 Bundle,导致apk包很大。今年的编译器IDEA也支持运行开发kotlin multiplatform项目,Fleet编译器不整了,目前生态库也不断加入比上一年开始完善很多,前期配置好后面的编译就很省事,前期的拉库是个漫长等待。
-
- 依托Android的compose框架为界面开发,Android studio最新版Android Studio Koala | 2024.1.1 Patch 1 Build #AI-241.18034.62.2411.12071903, built on July 11, 2024,本地的JDK为17或21,我把第三方库comShot引进来就要21,