使用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网页

番茄工具集

相关推荐
胚芽鞘68133 分钟前
关于java项目中maven的理解
java·数据库·maven
岁忧1 小时前
(LeetCode 面试经典 150 题 ) 11. 盛最多水的容器 (贪心+双指针)
java·c++·算法·leetcode·面试·go
CJi0NG2 小时前
【自用】JavaSE--算法、正则表达式、异常
java
Hellyc2 小时前
用户查询优惠券之缓存击穿
java·redis·缓存
今天又在摸鱼2 小时前
Maven
java·maven
老马啸西风2 小时前
maven 发布到中央仓库常用脚本-02
java·maven
代码的余温2 小时前
MyBatis集成Logback日志全攻略
java·tomcat·mybatis·logback
coderlin_4 小时前
BI布局拖拽 (1) 深入react-gird-layout源码
android·javascript·react.js
一只叫煤球的猫4 小时前
【🤣离谱整活】我写了一篇程序员掉进 Java 异世界的短篇小说
java·后端·程序员
2501_915918414 小时前
Fiddler中文版全面评测:功能亮点、使用场景与中文网资源整合指南
android·ios·小程序·https·uni-app·iphone·webview