眼看着微软的winui
,MAUI
用起来还不如以前的winform
,WPF
,很多桌面开发人员都想尝试新兴平台和语言,传统的网页套壳方面除了很多年前就爆火的Electron
,紧跟在后面的还有React Native
,tauri
,UI毕竟是网页前端一贯的优势,天生就是干这个的,做起来确实方便。但也并非只能考虑套壳写html,其他新兴语言也纷纷加入windows桌面开发的市场,dart
的Flutter
,go-lang
的wails
和fyne
,都很有很多应用采用,他们胜在一个语法好看,维护也得力,跟那堆浏览器套壳相比性能要强些。当然最早搞跨平台的JVM平台也不能一直只用祖传Swing
和JavaFX
了,Jetpack Compose
依靠Android
官方的背书做大做强,也进入到桌面开发的市场。
尽管这些框架都是以多平台自称,但并非所有应用都面向所有用户,单一平台的开发需求还是有很多的,尤其是windows
这样一个体量的平台,做好这一个就难。不过上面说的这些框架的配置都是一上来就搞多平台,多多少少有些臃肿,如果只做单一平台多少是会简单一些的。但几乎没怎么见过配置很简单的例子,都是套那些模板,我觉得没必要搞那么复杂,那么本文就做一个非常简单的只有几行代码的小例子:时间显示器。
缘起
可能有人会觉得,什么时间显示器,做这么个东西也太没用了,windows
任务栏右下角不是就一直在显示时间吗?其实我就是为了这个东西才写的这例子,有的系统会显示秒,有的则不会,那么怎么修改呢?windows经典技能修改注册表HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced
,新建DWORD(32bit)
项,命名ShowSecondsInSystemClock
,赋值1
就显示秒。
终于显示秒了,看着挺简单,但细想是真的太搞笑了,这么简单的需求都要去改注册表,就没办法在控制面板设置,真是绝了,而且现在这系统耗费这么多资源的情况下,却连个秒都不舍得显示了,所以我特意做个时间显示器,连毫秒一起显示,帮Windows把时间显示全。
配置
首先不需要在IDEA
或跑到Android studio
去选什么模板,因为无论选什么模板,都给你搞一大堆配置,目录结构也不舒服。我们空白文件夹起步,先写gradle
的配置,settings.gradle.kts
就配置下rootProject.name
就好,当然这个也并非必要,不写这个文件都没关系。然后写build.gradle.kts
的部分:
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
kotlin("jvm") version "1.8.20"
id("org.jetbrains.compose") version "1.4.0"
}
dependencies {
implementation(compose.desktop.currentOs)
}
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Exe)
packageName = "时间显示器"
packageVersion = "1.0.0"
}
}
}
就这么简单就妥了,目录照样用传统的src\main\kotlin
,一个平台有什么好建那么多main
文件夹的,然后建个kotlin
文件名为Main.kt
,然后准备写main()
方法,kotlin
会自动为这个main()
方法生成一个名为MainKt
的对象名。
窗口绘制
下面就直接在新建的Main.kt
中绘窗口,和flutter
有些像:
fun main() = application {
Window(
state = rememberWindowState(
size = DpSize(350.dp, 100.dp),
position = WindowPosition.Aligned(Alignment.Center)
),
resizable = false,
title = "时间显示器",
onCloseRequest = ::exitApplication
) {
App()
}
}
这个部分可以说一眼就能看清在干什么,main()
不用说,这个application
就是Application.desktop.kt
中的application()
,就两个参数。上面默认composable{}
的是他的第2个参数content
,就是在这里面绘制应用内容。如果你想关闭程序后仍在后台运行一些东西可以更改他的第1个参数exitProcessOnExit
为false
。
然后就是调用Window
,是在Window.desktop.kt
中的Window()
,这个参数多但都可以字面理解,上面的代码分别赋值了状态(窗口大小,定位),禁止手动调大小,标题,关闭事件。请注意这个关闭事件onCloseRequest
和上面提到的那个不一样,这个决定你是否关闭,就像Windows记事本notepad
那个经典的“你想将更改保存到 无标题 吗?”
就是这图:
所以你写onCloseRequest = {}
的话,他就真的不关了,只能去任务管理器关。然后就是这个Window
里面的内容App()
,下面就实现这个方法,首先这种方法是在绘制嘛,自然要特殊对待,也就要给注解的,两个注解:@Composable
和@Preview
,然后是内容:
@Composable
@Preview
fun App() {
MaterialTheme {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text("时间")
}
}
}
这段就是就是先定一个皮肤MaterialTheme
,但不要一觉得是皮肤就能换,这东西要写的话也就一个material
没别的,然后就是定个铺满窗口的Box
,第一个参数contentAlignment
跟名字意思一样就是居中,第二个modifier
是大小和padding
的设置,这个值除了用前面窗口传下来的就是用Modifier
这个类,他除了元素的尺寸大小还可以定一个debug用的诊断信息参数inspectorInfo
,但用处不大。前端网页开发人员可能一看到这里面只有padding
就不能设置margin
吗,用过flutter
的可能就会一笑,其实Box
这种单纯的容器就是用来做margin
的,具体到一些组件的padding
可能有的还有自己独特的padding
参数,不用这个modifier
设置。
下面就是效果:
可以看到确实定在中间了,那么下一步就是显示日期
显示日期
如果你第一时间就想到java.util.Date
和java.text.SimpleDateFormat
这俩祸害,请缓一下,千万别用这两个历史遗留问题满满的类了!日期我们用java1.8
就引入的LocalDateTime.now()
,他对应的格式化是DateTimeFormatter
,他们的注释都写了大量的例子可以看,使用其实很简单就是:
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.S")
var time = dateFormatter.format(LocalDateTime.now())
这个格式化是挺厉害的可以自己灵活的用代码去拼接,如果有多个日期格式需求就不用和以前一样定义多个SimpleDateFormat
,像下面这样就能控制:
val timeFormatter = DateTimeFormatterBuilder()
.append(DateTimeFormatter.ISO_LOCAL_DATE)
.appendLiteral(' ')
.appendValue(ChronoField.HOUR_OF_DAY, 2)
.appendLiteral(':')
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
.optionalStart()
.appendLiteral(':')
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
.optionalStart()
.appendFraction(ChronoField.MICRO_OF_SECOND, 0, 1, true)
.toFormatter()
可以用java.time.format.DateTimeFormatterBuilder
来动态的构建日期格式化,用java.time.temporal.ChronoField
来选中日期类型,这一段格式化是和前面完全一样的,当然了这里不用这种写法,只是了解一下。
然后把下面的Text("时间")
改成Text(time)
就行了,然后再运行:
可以看到时间了,但这个当然没办法动态变化了,这时候你可能想到多线程来持续修改时间,但这可是kotlin
,用协程就好了!他就像java
拖了很久才在JDK19
出的虚拟线程,用Thread.startVirtualThread()
来启动,由于这不是个长期支持版本,我想大部分人最多还是只会用到JDK17
,没办法用到这个好玩意。但kotlin
就不一样了,先天自带这个好东西,但是要想在compose
直接跑这个,我们还需要在build.gradle.kts
那边引入一个小小的包kotlinx-coroutines-swing
:
dependencies {
implementation(compose.desktop.currentOs)
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.1")
}
然后可以在text
定义的下面启动最简单的MainScope
协程:
MainScope().launch {
while (true) {
time = dateFormatter.format(LocalDateTime.now())
delay(100)
}
}
这段不用说自然是放到time
定义的与MaterialTheme
之间的地方,写起来也是很简单呐,但只是这样是没效果的,因为这个变量修改想想就知道不可能直接传递给UI的,需要给一个state
,所以修改一下time
的定义:
var time by mutableStateOf(dateFormatter.format(LocalDateTime.now()))
这个写by
来代替=
赋值是有好处的,改完time
仍然是String
类型,后面可以直接用=
去修改和获取,否则因为是MutableState
类型,要调用get()
、set()
,这个语法糖确实黑科技,其他平台几乎做不到。下面效果就出来了:
下面我又想到Windows时间显示的一大痛点,居然不能复制!想记录时间只能看着手敲,这也太蠢了,所以我特地做个复制按钮吧!
复制日期
复制自然要访问剪切板,剪切板不必用awt
那个破Toolkit.getDefaultToolkit().systemClipboard
了,可以用LocalClipboardManager.current
轻松搞定,写起来简单的多,在time
定义的下面再定义这个剪切板对象:
val clipboard = LocalClipboardManager.current
然后改下Text(text)
的位置,给他套个Row
对象让他和按钮排一行,然后放入这个按钮:
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(time)
Button(modifier = Modifier.padding(horizontal = 10.dp), onClick = {
clipboard.setText(AnnotatedString(time))
}) {
Text("复制")
}
}
Row
需要设定下垂直对齐,因为按钮比文本长一些,然后就是Button
,如前文所说用Modifier
定义下padding
控制与左边文本的距离,从这里就可以看到其实这个padding
就是前端网页的margin
,想调padding
是用他的contentPadding
属性来调的。然后在onClick
中赋值剪切板即可,按下按钮就复制:
综合源代码
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable
@Preview
fun App() {
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.S")
var time by mutableStateOf(dateFormatter.format(LocalDateTime.now()))
val clipboard = LocalClipboardManager.current
MainScope().launch {
while (true) {
time = dateFormatter.format(LocalDateTime.now())
delay(100)
}
}
MaterialTheme {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(time)
Button(modifier = Modifier.padding(horizontal = 10.dp), onClick = {
clipboard.setText(AnnotatedString(time))
}) {
Text("复制")
}
}
}
}
}
fun main() = application {
Window(
state = rememberWindowState(
size = DpSize(350.dp, 100.dp),
position = WindowPosition.Aligned(Alignment.Center)
),
resizable = false,
title = "时间显示器",
onCloseRequest = ::exitApplication
) {
App()
}
}
可以看到就一个配置文件,一个代码文件,几十行代码(一大半还是import
、括号符号、空行),相当的简洁。本篇属于compose
和kotlin
的入门向教学,后续会根据此文展开说一些其他内容,已经在编辑中。
本文写作于2023年6月27日并发布于lyrieek的掘金,于2023年7月19日进行修订发布于lyrieek的阿里云开发者社区。