使用JavaFx开发一些常用的小工具--第2弹

之前使用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) }

        }
    }
}

右侧功能区

  1. 右侧顶部
kotlin 复制代码
@Composable
fun RightTopInfo(
    menu: Menu
) {
    Row(modifier = Modifier.height(40.dp).padding(10.dp)) {
        Text(
            menu.name,
            color = Color(95,210,242),
        )
    }
}
  1. 右侧内容区域
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上也更容易理解。

参考

Compose官网

Jewel官网

Cron网页

番茄工具集

相关推荐
图王大胜17 分钟前
Android SystemUI组件(11)SystemUIVisibility解读
android·framework·systemui·visibility
程序员南飞1 小时前
ps aux | grep smart_webrtc这条指令代表什么意思
java·linux·ubuntu·webrtc
弥琉撒到我1 小时前
微服务swagger解析部署使用全流程
java·微服务·架构·swagger
一颗花生米。2 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
问道飞鱼2 小时前
Java基础-单例模式的实现
java·开发语言·单例模式
服装学院的IT男4 小时前
【Android 13源码分析】Activity生命周期之onCreate,onStart,onResume-2
android
Arms2064 小时前
android 全面屏最底部栏沉浸式
android
服装学院的IT男4 小时前
【Android 源码分析】Activity生命周期之onStop-1
android
ok!ko6 小时前
设计模式之原型模式(通俗易懂--代码辅助理解【Java版】)
java·设计模式·原型模式
2402_857589366 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端