之前使用JavaFx开发了一个简单的桌面工具,编写和打包上只能说差强人意。正好之前用kotlin写了一个springboot项目,了解到kotlin中的kmp技术也是支持桌面端程序编写的。可以了解到compose支持Android,Ios,桌面(windows,mac,linux)和web端。
我们在跨平台上又多一个新的选择,比之flutter,javafx之类。
本文主要从新手的角度如何构建一个compose桌面端程序,通过compose的响应式写法,简单的完成工具的搭建。
环境准备
建议下载最新的idea客户端,可以很容易的创建Compose For Desktop项目。
PS:JDK建议使用17版本,防止出现引用jar包不兼容的情况。
配置主题
Compose示例自带的主题是Material风格,对于样式不怎么熟悉的后端来说,想要调整一个好看的UI,就变的非常困难。Jetbrains家也提供了jewel(钻石)风格,常用的一些组件都有进行封装,相比如原始的毛坯风格会顺眼不少。
Jewel可以编写Compose桌面组件,也可以编写idea工具插件。有兴趣的掘友也可以用此工具编写idea的插件。
基础知识
双向绑定
在编写前我们需要了解Compose中如何进行双向绑定。通过rememer绑定Compose中的变量。
如果改变了remeber的值,相关的UI组件会重新绘制。
万物Compose
所有的组件内容都可以用Compose来进行抽象封装,像搭积木一样拼接成我们想要的内容。
代码编写
Windows窗体
ini
@OptIn(ExperimentalTextApi::class)
fun main() = application {
val textStyle = TextStyle(fontFamily = FontFamily("Inter"))
val themeDefinition = JewelTheme.darkThemeDefinition(defaultTextStyle = textStyle)
IntUiTheme(
themeDefinition,
ComponentStyling.decoratedWindow(
titleBarStyle = TitleBarStyle.dark()
)
) {
Window(onCloseRequest = ::exitApplication,
state = WindowState(width = 1000.dp, height = 720.dp),
resizable = false,
title = "番茄工具集",
icon = painterResource("tomato.png")) {
App()
}
}
}
使用暗黑主题,宽度和高度分别为1000,720dp。
显示效果
比之前写的Javafx还是要方便很多。
功能拆分
功能区域拆分为左边的功能区和右边功能区。三块分别定义如下:
左侧功能区
scss
/**
* 左侧功能区
*/
@Composable
fun LeftFun(
menuList: List<Menu>,
selectedMenu: Menu,
onMenuClick: (Menu) -> Unit
) {
Column(modifier = Modifier.width(250.dp)
.border(
border = BorderStroke(1.dp, color = divider),
shape = RectangleShape
)
.fillMaxHeight()
) {
TopInfo()
Divider(orientation = Orientation.Horizontal, color = Color.Gray)
Spacer(
modifier = Modifier.height(10.dp)
)
Column {
// 分组处理
val menuGroupMap = menuList.groupBy { it.group }
menuGroupMap.forEach { (k, v) -> MenuGroup(k,v,selectedMenu,onMenuClick) }
}
}
}
右侧功能区
- 右侧顶部
kotlin
@Composable
fun RightTopInfo(
menu: Menu
) {
Row(modifier = Modifier.height(40.dp).padding(10.dp)) {
Text(
menu.name,
color = Color(95,210,242),
)
}
}
- 右侧内容区域
scss
selectedMenu.value.content()
菜单切换
菜单实体
less
/**
* @author zooy
* @create 2024-01-04 0:37
* @description
*/
open class Menu (
val group: String,
val name: String,
val image: String,
val content: @Composable (() -> Unit) // 内容Compose
)
菜单仓库
less
package content.repository
import content.*
import content.cron.CronContent
import content.encode.Base64Content
import content.encode.HexContent
import content.encode.Md5Content
import content.encode.Sha1Content
import content.pic.PicToBase64Content
import content.pic.QrcodeContent
/**
* @author zooy
* @create 2024-01-04 0:39
* @description
*/
object MenuRepository {
val menuList = listOf(
Menu(
"",
"设备信息",
Iconc.device,
{ DeviceInfoContent() }
),
Menu(
"Encode",
"Md5",
Iconc.chat,
{ Md5Content() }
),
Menu(
"Encode",
"Base64",
Iconc.directMessage,
{ Base64Content() }
),
Menu(
"Encode",
"SHA1",
Iconc.add,
{ Sha1Content() }
),
Menu(
"Encode",
"Hex",
Iconc.code,
{ HexContent() }
),
Menu(
"图片",
"Base64转换",
Iconc.attachment,
{ PicToBase64Content() }
),
Menu(
"图片",
"二维码",
Iconc.bold,
{ QrcodeContent() }
),
Menu(
"实用工具",
"CRON",
Iconc.code,
{ CronContent() }
)
)
}
Cron示例
只列举其中一个Cron的Compose写法,其他的可以参照我的源码。
Tabs切换
ini
val tabs = remember(tabIds, selectedTabIndex) {
tabIds.mapIndexed { index, id ->
TabData.Default(
selected = index == selectedTabIndex,
closable = false,
content = { tabState ->
SimpleTabContent(
label = "$id",
state = tabState,
modifier = Modifier.width(50.dp)
)
},
onClick = { selectedTabIndex = index },
)
}
}
上面定义了数组
csharp
var tabIds by remember { mutableStateOf(mutableListOf("秒", "分钟", "小时", "日", "月", "周")) }
当用户切换时改变选中的索引,通过不同的索引值跳转不同的页面。
ini
Row(modifier = Modifier.fillMaxWidth().height(300.dp)) {
when (selectedTabIndex) {
0 -> {
TimeContent(second)
}
1 -> {
TimeContent(minute, desc = "分钟")
if (minute.value != "*" && second.value == "*") {
second.value = "0"
}
}
2 -> {
HourContent(hour, desc = "小时")
if (hour.value != "*" && minute.value == "*") {
minute.value = "0"
}
if (hour.value != "*" && second.value == "*") {
second.value = "0"
}
}
3 -> {
DayContent(day, desc = "日")
if (day.value != "*" && hour.value == "*") {
hour.value = "0"
}
if (day.value != "*" && minute.value == "*") {
minute.value = "0"
}
if (day.value != "*" && second.value == "*") {
second.value = "0"
}
}
4 -> {
MonthContent(month, desc = "月")
if (month.value != "*" && day.value == "*") {
day.value = "1"
}
if (month.value != "*" && hour.value == "*") {
hour.value = "0"
}
if (month.value != "*" && minute.value == "*") {
minute.value = "0"
}
if (month.value != "*" && second.value == "*") {
second.value = "0"
}
}
5 -> {
WeekContent(week, desc = "周")
if (week.value != "*" && month.value == "*") {
month.value = "0"
}
if (week.value != "*" && day.value == "*") {
day.value = "0"
}
if (week.value != "*" && hour.value == "*") {
hour.value = "0"
}
if (week.value != "*" && minute.value == "*") {
minute.value = "0"
}
if (week.value != "*" && second.value == "*") {
second.value = "0"
}
}
}
}
这里有一个没处理好的点,就是tab之间是有联动的,这个时候就感觉有一点麻烦。需要追溯之前的设定的值, 如果需要渲染的UI非常多,还会出现一定的卡顿情况。
Cron选择
ini
package content.cron
import androidx.collection.mutableIntListOf
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogState
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowPosition
import org.jetbrains.jewel.ui.Orientation
import org.jetbrains.jewel.ui.component.CheckboxRow
import org.jetbrains.jewel.ui.component.Divider
import org.jetbrains.jewel.ui.component.RadioButtonRow
import org.jetbrains.jewel.ui.component.Text
import theme.slackBlack
import java.time.DayOfWeek
/**
* @author zooy
* @create 2024-03-12 20:26
* @description
*/
@Composable
fun DayContent(second: MutableState<String>, desc: String = "日", modifier: Modifier = Modifier) {
val checked = remember { mutableStateOf(0) }
var showDialog = remember { mutableStateOf(false) }
var positionInRootTopBar = remember { mutableStateOf(Offset.Zero) }
val alertMessage = remember { mutableStateOf("") }
val checkedSet = remember { mutableStateOf(mutableSetOf<Int>()) }
val secondStart = remember { mutableStateOf("1") }
val secondEnd = remember { mutableStateOf("2") }
val circleStart = remember { mutableStateOf("1") }
val circleEnd = remember { mutableStateOf("1") }
val workDay = remember { mutableStateOf("1") }
DialogWindow(
visible = showDialog.value,
onCloseRequest = { showDialog.value = false },
state = DialogState(width = 200.dp, height = 100.dp, position = WindowPosition((positionInRootTopBar.value.x + 300).dp, (positionInRootTopBar.value.y + 300).dp)),
title = "告警提示") {
Text(text = alertMessage.value, color = slackBlack)
}
if(second.value == "*") {
checked.value = 0
}else if(second.value == "?") {
checked.value = 1
} else if(second.value.contains("-")) {
checked.value = 2
secondStart.value = second.value.split("-")[0]
secondEnd.value = second.value.split("-")[1]
} else if(second.value.contains("/")) {
checked.value = 3
circleStart.value = second.value.split("/")[0]
circleEnd.value = second.value.split("/")[1]
} else if(second.value.endsWith("W")) {
checked.value = 4
workDay.value = second.value.replace("W", "")
} else if(second.value == "L") {
checked.value = 5
} else {
checked.value = 6
second.value = second.value.replace(",", ",")
if(!second.value.isNullOrEmpty()
&& !second.value.endsWith(",")) {
checkedSet.value.clear()
checkedSet.value.addAll(second.value.split(",").map { it.toInt() }.toMutableList())
}
}
Column(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.onGloballyPositioned {
// global position (local also available)
positionInRootTopBar.value = it.positionInRoot()
}
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
// 绘制Radio
RadioButtonRow(
text = "每秒 允许的通配符[, - * /]",
selected = checked.value == 0,
onClick = {checked.value = 0; second.value = "*"}
)
}
Divider(orientation = Orientation.Horizontal, color = Color.Gray)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
// 绘制Radio
RadioButtonRow(
text = "不指定",
selected = checked.value == 1,
onClick = {checked.value = 1; second.value = "?"}
)
}
Divider(orientation = Orientation.Horizontal, color = Color.Gray)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
// 绘制Radio
RadioButtonRow(
selected = checked.value == 2,
onClick = {checked.value = 2; second.value = secondStart.value + "-" + secondEnd.value}
) {
Text("周期从")
MiniTextField(secondStart, valueChange = {
second.value = secondStart.value + "-" + secondEnd.value
})
Text("到")
MiniTextField(secondEnd, valueChange = {
second.value = secondStart.value + "-" + secondEnd.value
})
Text(desc)
}
}
Divider(orientation = Orientation.Horizontal, color = Color.Gray)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
// 绘制Radio
RadioButtonRow(
selected = checked.value == 3,
onClick = {checked.value = 3; second.value = circleStart.value + "/" + circleEnd.value}
) {
Text("周期从")
MiniTextField(circleStart, valueChange = {
second.value = circleStart.value + "/" + circleEnd.value
})
Text("${desc}开始")
MiniTextField(circleEnd, valueChange = {
second.value = circleStart.value + "/" + circleEnd.value
})
Text("${desc}执行一次")
}
}
Divider(orientation = Orientation.Horizontal, color = Color.Gray)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
// 绘制Radio
RadioButtonRow(
selected = checked.value == 4,
onClick = {checked.value = 4; second.value = "${workDay.value}W"}
) {
Text("每月")
MiniTextField(workDay, valueChange = {
second.value = "${workDay.value}W"
})
Text("号最近的那个工作日")
}
}
Divider(orientation = Orientation.Horizontal, color = Color.Gray)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
// 绘制Radio
RadioButtonRow(
text = "本月最后一天",
selected = checked.value == 5,
onClick = {checked.value = 5; second.value = "L"}
)
}
Divider(orientation = Orientation.Horizontal, color = Color.Gray)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
// 绘制Radio
RadioButtonRow(
selected = checked.value == 6,
onClick = {
checked.value = 6
checkedSet.value.add(1)
second.value = checkedSet.value.sorted().joinToString(",")
}
) {
Text("指定")
Column {
repeat(3) { index ->
Row(
modifier = Modifier.fillMaxWidth()
) {
for (i in 1 until 12) {
if (index * 10 + i > 31) {
break
}
CheckboxRow(text = (index * 10 + i).toString(), checked = checkedSet.value.contains(index * 10 + i), onCheckedChange = {
checked.value = 6
if(it) {
checkedSet.value.add(index * 10 + i)
second.value = checkedSet.value.sorted().joinToString(",")
} else {
checkedSet.value.remove(index * 10 + i)
if(checkedSet.value.isEmpty()) {
second.value = "*"
} else {
second.value = checkedSet.value.sorted().joinToString(",")
}
}
})
}
}
}
}
}
}
}
}
也做了反向解析,当输入框填入值就可以解析到对应tab页中的值。
程序打包
这个就非常简单了,自带的Compose desktop任务已经把各个桌面端发行版本都集成进来了。我们只需要双击即可。
我们可以非常容易打包出来在windows上使用的msi程序。打包配置可以参考:
ini
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "tomato"
packageVersion = "1.0.0"
description = "tomato tools"
copyright = "Copyright © 2024-2025 db101.cn"
windows {
shortcut = true
packageVersion = "1.0.0"
msiPackageVersion = "1.0.0"
iconFile.set(file("src/jvmMain/resources/tomato.ico"))
}
}
}
}
继续学习
写的过程还是遇到了很多问题,比如文件上传组件没有好的选择,只能用swing中的file组件,没有适配compose desktop好用的组件。资料比较少,需要自己去github上找相应的代码库。除此之外整个写起来还是非常流畅的,比javafx要快上不少,UI上也更容易理解。