最近学习之余注意到了Compose MultiPlatform,然后就想试试水,正好最近越来越依赖ChatGPT,这东西是真香啊,但是总觉得每次都要找套壳网站,想用还得打开浏览器,我很懒 ̄へ ̄,然后我大概找了一下,网上好像也没有人做私人桌面版的小工具(虽然这玩意完全不难做吧,但就是好像没看到有),正好又想玩玩Compose Desktop,于是就花了两天写了这个ChatPTQ。
因为这个项目比较简单,没啥特别好提的,所以这篇文章会更多地偏聊天式、口语化。
1 使用
- Windows应用,无需安装,开箱即用
- 使用前先在Setting里配置
- 个人私用,安全、高效、方便
具体的介绍和使用部分就直接看GitHub的文档吧,不想再写一遍了,下面简单说说项目的实现和Compose Desktop的试水体验。
2 实现
因为是个很小的项目,很多地方都很粗糙,能用就行,没有仔细想了。
2.1 UI
就弄了俩页面,Chat和Setting,组件也基本上都是Material的组件。
写起来感受和Compose Android差不多 (除了有些东西不支持,比较简陋)。
但是捏,我好像没找到Toast怎么弄出来,报错的时候我想弹Toast提示用户,那行,只能自己实现一个了。
2.2 Toast实现
定义Toaster接口,随便写几个常用的重载方法。
interface Toaster {
fun toast(message: String) = toast(message, 2500L, true)
fun toast(message: String, duration: Long) = toast(message, duration, true)
fun toast(message: String, duration: Long, success: Boolean)
fun toastFailure(message: String) = toast(message, 2500L, false)
}
创建LocalAppToaster。
val LocalAppToaster = compositionLocalOf { Toaster.Default }
封装Toast作用域。在这个Composable里,定义toast的显示,然后把App这个Composable扔进CompositionLocalProvider里,让它给整个App域都提供Toaster接口,这样,任意的子Composable都能通过LocalAppToaster.current获取到当前的toaster,然后触发toast回调,然后设置个小动画(淡入淡出),Toast就完成了。
package view
@Composable
fun Toast(App: @Composable () -> Unit) {
var showToast by remember { mutableStateOf(false) }
var toastColor by remember { mutableStateOf(toastColors[0]) }
val coroutineScope = rememberCoroutineScope()
var toastText by remember { mutableStateOf("") }
val toaster by remember {
mutableStateOf(object : Toaster {
override fun toast(message: String, duration: Long, success: Boolean) {
if (showToast) {
return
}
coroutineScope.launch {
toastText = message
toastColor = toastColors[if (success) 0 else 1]
showToast = true
delay(duration)
showToast = false
}
}
})
}
CompositionLocalProvider(LocalAppToaster provides toaster) {
Box(modifier = Modifier.fillMaxSize()) {
App()
AnimatedVisibility(showToast, modifier = Modifier.align(Alignment.Center)) {
Box(modifier = Modifier.wrapContentSize().clip(shape = RoundedCornerShape(6.dp)).background(toastColor)) {
Text(toastText, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp))
}
}
}
}
}
这里我偷了懒,没有处理多条Toast的情况,只是简单的拦截了一下,当前正在显示的话,就不显示。
Main.kt中,用Toast把RoutePage(真正的页面内容)包一层,就行了:
@Composable
fun App() {
MaterialTheme {
Toast {
RoutePage()
}
}
}
2.3 Config
讲到Toast就顺便说一下Config全局配置,因为它的实现思路和Toast差不多。
全局配置包括这么几项:
data class AppConfig(
val enableSystemProxy: Boolean = false,
val userProxy: UserProxy = UserProxy("0.0.0.0", 0),
val apiKey: String = "",
val autoStart: Boolean = false,
val gptName: String = "小彭"
)
需要做到:
- 在Setting界面能修改设置,并自动保存到本地且生效
- 任意页面能获取到想要的配置项
实现方式也和Toast差不多。
定义一个回调OnConfigChange,参数代表新的AppConfig,再定义一个AppConfigContext类封装一下AppConifg,给它加个回调。然后把AppConfigContext定义成CompositionLocal的。
typealias OnConfigChange = (AppConfig) -> Unit
class AppConfigContext(val appConfig: AppConfig = AppConfig(), val onConfigChange: OnConfigChange)
val LocalAppConfig = compositionLocalOf { AppConfigContext { } }
同样地,定义一个Config的封装Composable块。设一个LaunchedEffect,一启动就读一次配置,然后应用配置,然后把配置暂记下来。然后,CompositionLocalProvider套住App块,这样就能给全局提供AppConfigContext,其它页面能通过LocalAppConfig.current获取。若接收到页面发来的更改配置事件,统一在AppConfigContext的回调中处理,然后更新本地文件和appConfig变量,appConfig的改变会导致UI刷新。
@Composable
fun AppConfig(App: @Composable () -> Unit) {
var appConfig by remember { mutableStateOf(AppConfig()) }
val coroutineScope = rememberCoroutineScope()
val toast = LocalAppToaster.current
LaunchedEffect(Unit) {
val jsonConfig = readConfig() ?: run {
toast.toastFailure("配置文件未找到")
return@LaunchedEffect
}
applyChanges(null, jsonConfig, onSuccess = {
}, onFailure = {
toast.toastFailure(it)
})
appConfig = jsonConfig
}
CompositionLocalProvider(LocalAppConfig provides AppConfigContext(appConfig) { new ->
coroutineScope.launch {
writeConfig(appConfig, new, onSuccess = {
}, onFailure = {
toast.toastFailure(it)
})
appConfig = new
}
}) {
App()
}
}
Main.Kt中:
@Composable
@Preview
fun App() {
MaterialTheme {
Toast {
AppConfig {
RoutePage()
}
}
}
}
Setting页面更改配置示例:
@Composable
fun ApiKey(appConfig: AppConfig, onChange: OnConfigChange) {
SettingPanel("请求设置") {
OutlinedTextField(appConfig.apiKey,
label = {
Text("APIKey")
},
onValueChange = {
//onChnage就是LocalAppConfig.current.onConfigChange
onChange(appConfig.copy(apiKey = it))
}
)
}
}
2.4 网络请求和数据存储
Compose Desktop的网络请求可以用Retrofit,小数据的存储可以用DataStore,具体地就不多说了,项目里只是很简陋地封装了一下,基本上Android怎么用,Desktop就怎么用。
数据的存储为了偷懒我都存项目根路径了(即File(“.”)),如果是打包的exe就是exe所在的路径。
2.5 快捷Enter键发送
键盘的监听可以用Modifier.onPreviewKeyEvent或者onKeyEvent,只是感觉键盘事件处理起来很棘手,因为搜狗输入法中文已经有字的时候敲Enter、普通的敲Enter换行、快捷Enter发送,这三种场景都要Enter,得想办法处理好快捷键事件,这里我想的是长按就触发快捷发送,但是代码写起来很不顺手,不知道还有没有更好的处理方式。(直接在输入框敲Enter,会触发一个awt无法识别的事件,具体地可以自己println出来试一试。)
2.6 打包
关于打包的参数什么的,可以看官方文档。
(官方文档的教程还有很多别的功能,我没仔细看,有相对应的需求可以自己去找找)
但是呢,官方打出来的包好像只能是一个安装包,就是有安装程序,然后安装到自己电脑上用,但是呢,我不知道是什么原因,一运行exe安装程序,他就自动变成了后台进程,然后什么反应都没有了,也就是无法安装。
那我就换一种思路吧,反正整个应用也不大,能不能直接生成一个可执行的exe呢,直接双击就运行,不搞什么安装。这样也不会往c盘乱塞东西。
最终找到了办法,执行compose desktop下的CreateDistributable Task就可以了,会在build/compose/binaries底下输出直接可执行的exe。
3 结语
这次通过这个小项目试水了Compose Desktop,然后也做出了一个确实可以给自己工作用的ChatGPT桌面端应用。Compose MultiPlatform感觉还是很香的,基本上就是零学习成本写UI,直接就能上手写,而且kotlin是真的好用,爽爽,但是感觉目前有些地方还是不完善,毕竟还没成长起来嘛,社区也没怎么起来,但是确实是感觉未来可期!
最后贴一个我实现过程中参考了的项目:从0到1搞一个 Compose Desktop 版本的天气应用
就写到这里吧~