从 0 到 1 搞一个 Compose Desktop 版本的天气应用(附源码)

13,868 阅读39分钟

大家好,好久不见,今天带大家一起来玩下 Compose Desktop ,带大家从头到尾写一个桌面版的天气应用,并且打好包让别人也可以进行使用,废话不多说,先来看下最终的实现效果吧!

QQ20221212-143238-HD.gif

效果是不是挺好?哈哈哈!

其实作为一个安卓开发来说,当运行起第一个桌面版程序的时候内心突然感觉回到了最开始学习编程的时候,那种感觉就好像一个多年未见的老友对你说:久违了!特别是使用的技术还都是安卓开发的技术,只是有一些平台原因需要稍做修改的地方就能开发出一个完整的桌面版软件,内心还是非常激动,非常地有成就感,这种感觉太舒服了!

踩坑

缘起 Compose

为什么会搞 Compose Desktop,这还得从 Jetpack Compose 说起:Google 从 2017 年开始立项开搞 Compose 到第一个正式版本用了四年的时间,那么久的时间,投入了那么多的人力,以及后面投入了大量经费宣传,无一不在告诉安卓开发者 Compose 很重要,这也是之后安卓开发的新方向!所以当第一个 alpha 版本的 Compose 出现的时候我就坐不住了,立马加上依赖尝试了下!刚开始写的时候感觉有点奇怪,毕竟从之前的开发模式变为了全新的声明式开发,但写了不到一周就感受到了 Compose 的优势,编写起来太快了,动画实现起来也太简单了,声明式编程也太方便了。。。。

其实 Compose Desktop 出现的也很早,Jetpack Compose 出来没多久它也就出来了,有很多同行在 Compose Desktop 出来第一个 alpha 版本的时候就开始研究,不过由于我是做安卓开发的,日常工作也不会涉及到桌面开发,况且 Compose DesktopJetbrains 开发的,并不 Google 开发的,毕竟是模仿 Jetpack Compose 的,未来两边的 API 都有可能对应不上,所以也就一直没有关注。

但是到后来转折点来了,公众号:《Android 开发者》在 2022 年 11 月 30 日早上发了一篇文章,名为:“Jetpack 更新一览 | 2022 Android 开发者峰会”,这篇文章的前半部分没有什么新鲜感,因为这些库我也一直在用,也经常关注着新版本和新功能,但看到文章最后的时候,里面有一段话是这么写的:

我们一直在尝试使 Jetbrains 的 Kotlin 多平台移动版支持跨平台共享代码。我们针对 Android 和 iOS 应用推出了实验性的 Collection 预览版和 DataStore 库。期待您的反馈!您可以查看相关博文,了解更多内容。

这话说的,你们一直在尝试,Jetpack Compose 正式版都出来快一年半了!Compose Desktop 正式版也都一年多了!这么久怎么连点信都没有。。。不过好在现在有信了!Google 这也算放出了几个信号,也不知道我的理解是否正确😂:

  1. 他们也一直在关注 JetbrainsCompose Desktop
  2. 目前已经有两个 Jetpack 的库支持了 Compose Desktop
  3. 后续也会将更多的 Jetpack 中的库支持 Compose Desktop

对标 Flutter ?

Flutter 现在已经比较成熟了,它最大的优势就是跨平台,Flutter 虽然宣称的是原生的性能,一套代码多端实现,但其实对于跨平台来说一套代码并不能完全实现需求,肯定需要各种适配,只不过看框架适配的好与坏,Compose Desktop 也是如此,但 Flutter 的性能也只是媲美原生,而 Compose 就是原生啊!Compose Desktop 其实并不是和 Flutter 抢饭碗,它只是告诉广大安卓开发:你们并不需要学习安卓之外的东西就能开发各种设备上的应用!这也是 Kotlin 的辉煌,我个人认为这也是 Jetbrains 公司开发 Compose Desktop 的初衷。

基于上面的分析,打开了 JetbrainsCompose Desktop 的官网:www.jetbrains.com/zh-cn/lp/co… ,也开始试着玩一玩桌面版的应用!

初探 Compose Desktop

我本来还想着使用 Android Studio 来使用 Compose Desktop 来着,结果打开 Android Studio 新建项目一看并没有找到创建 Compose Desktop 的入口,后来想想也对,Android Studio 嘛!本来就是为了构建 Android 项目的,并不是为了构建别的东西,对吧!(内心独白:可能是我自己没找到)

那就使用 IntelliJ Idea 来看看吧,点击 New -> Project 就会出现下面的页面:

image.png

选择 Kotlin 之后就可以看到右边有 Compose Multiplatform 的选项,里面有三种,第一种就是这段时间要搞的 Compose Desktop ,第二种就是多平台了,里面有 Android ,也有 Compose Desktop ,第三种是 Compose Web 。不得不说太强了!桌面、Web、移动端,Compose 一套搞定!目前 IOS 也支持了,不过这不是咱们要看的重点,还是来看 Compose Desktop 吧!接下来点击 Next ,之后选择配置项之后点击 Finish 后第一个 Compose Desktop 项目就创建好了!

项目结构

接下来看下初始项目的结构吧:

image.png

OK,有一个 Main.kt 文件,还有 build 和 settings 文件。下面咱们一个一个来看,先来看下 settings 文件吧:

pluginManagement {
    repositories {
        google()
        gradlePluginPortal()
        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
    }
    
}
rootProject.name = "Demo"

嗯,这个很简单,放了依赖的仓库地址,还有项目的名称。

接下来再来看下 build 文件:

plugins {
    kotlin("jvm") version "1.5.31"
    id("org.jetbrains.compose") version "1.0.0"
}
​
dependencies {
    implementation(compose.desktop.currentOs)
}
​
tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "11"
}
​
compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "Demo"
            packageVersion = "1.0.0"
        }
    }
}

build 文件中稍微多点,分别是 plugins 、dependencies 、jvmTarget 和 application ,前几个就不过多介绍,因为安卓项目中都有,最后的 application 是这里独有的,其实这块就是对桌面项目的一些属性的配置,可以看到有包名和版本号等信息,这块在这里先不进行过多介绍,因为这块的内容很多,WindowsMacLinux 各个系统的配置都不太相同,在之后的文章中会着重来介绍,这里先跳过。

初始代码

最后来看下 Main.kt 文件:

@Composable
@Preview
fun App() {
    var text by remember { mutableStateOf("Hello, World!") }
    MaterialTheme {
        Button(onClick = {
            text = "Hello, Desktop!"
        }) {
            Text(text)
        }
    }
}
​
fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}

代码并不多,而且很熟悉,但也有不认识的地方。可以看到这里出现了 Java 中熟悉的 Main 方法,然后里面调用了一个 application 方法,在其中有一个可组合项 Window ,在里面调用了 App 可组合项。

Application

可组合项咱们都是非常了解的,这块不太清楚的其实就是 applicationWindow ,因为这两个在之前 Jetpack Compose 中都是没有的,下面咱们就先来看看 application

fun application(
    exitProcessOnExit: Boolean = true,
    content: @Composable ApplicationScope.() -> Unit
) {
    val configureSwingGlobals = System.getProperty("compose.application.configure.swing.globals") == "true"
    if (configureSwingGlobals) {
        configureSwingGlobalsForCompose()
    }
    runBlocking {
        awaitApplication {
            content()
        }
    }
    if (exitProcessOnExit) {
        exitProcess(0)
    }
}

applicationCompose Desktop 应用程序的入口点,这块需要注意的是:不要在这个函数中使用任何动画(例如,withframamanosanimatefloatasstate 等),因为底层的 MonotonicFrameClock 没有与任何显示同步,所以无法尽快地生成帧。

方法一共接收两个参数,来分别看下:

  • exitProcessOnExit:结束进程,默认为 true,在应用程序关闭后调用 exitProcess(0)exitProcess 加速进程退出(立即退出,而不是1-4秒)。如果为 false ,函数的执行将在应用程序退出后被解除阻塞(当最后一个窗口关闭,以及所有 LaunchedEffect 完成时)。
  • content:放可组合项的,不做多介绍。

Window

下面再来看下可组合项 Window

@Composable
fun Window(
    onCloseRequest: () -> Unit,
    state: WindowState = rememberWindowState(),
    visible: Boolean = true,
    title: String = "Untitled",
    icon: Painter? = null,
    undecorated: Boolean = false,
    transparent: Boolean = false,
    resizable: Boolean = true,
    enabled: Boolean = true,
    focusable: Boolean = true,
    alwaysOnTop: Boolean = false,
    onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
    onKeyEvent: (KeyEvent) -> Boolean = { false },
    content: @Composable FrameWindowScope.() -> Unit
)

Window 的代码有点多,这块咱们先不关心里面的具体实现,先来看看都有哪些功能。在当前 Compose 中组合成平台窗口。当 Window 进入组合成时,将创建一个新的平台窗口并接收焦点。当 Window 离开合成时,Window 将被释放并关闭。Window 的参数有点多,咱们分别来看下:

  • onCloseRequest:当用户关闭窗口时将被调用的回调函数
  • state:用于控制或观察窗口状态的状态对象
  • visible:是否对用户可见
  • title:窗口的名称
  • icon:窗口的图标(和应用图标不同,完全两码事
  • undecorated:禁用或启用此窗口的装饰
  • transparent:禁用或启用窗口透明度,需要注意:只有在窗口未装饰时才应该设置透明度,否则将引发异常
  • resizable:用户是否可以调整窗口的大小
  • enabled:窗口是否能对输入事件作出反应
  • focusable:窗口是否可以接收焦点
  • alwaysOnTop:窗口是否在另一个窗口的顶部
  • onPreviewKeyEvent:当用户与硬件键盘交互时调用此回调,它为聚焦组件的祖先提供了拦截KeyEvent 的机会
  • onKeyEvent:当用户与硬件键盘交互时调用此回调。在实现此回调时,返回 true 以停止此事件的传播。如果返回 false,KeyEvent 将被发送给这个 onKeyEvent 的父事件。

第一次运行

OK,到现在位置初始项目中的内容大概都过了一遍,接下来运行看下效果吧!

那么问题又来了,怎么运行呢。。。之前咱们运行安卓项目的时候都是点击 Android Studio 上方运行,但现在看下:

image.png

没有了,灰着的!不用担心,不还有 main 函数呢嘛!直接运行 main 函数!

image.png

点击运行按钮看下:

image.png

直接弹出了一个Java 程序,里面放着一个按钮,刚才咱们看可组合项 Window 的时候不是可以修改名字嘛,下面修改下看看!

fun main() = application {
    Window(onCloseRequest = ::exitApplication, title = "天青色等烟雨") {
        App()
    }
}

改了下名字,第二次运行的时候可以点击 main 函数,也可以点击 Idea 的上方运行按钮了,因为刚才运行的记录已经被保存下来了!

image.png

OK,点击运行查看结果:

image.png

没错,和想的一样!

到现在为止已经可以使用咱们之前学习的 Jetpack Compose 知识来愉快的编程了!

显示图片

刚还想的可以好好使用 Compose 来编写桌面程序来着,可我刚想显示一张图片就发现了问题!怎么显示???

普通图片

Jetpack Compose 中显示图片不叫事,直接使用 painterResource 将图片资源传进去就可以了,但在 Compose Desktop 中该怎么办呢?

先来看下 Compose Desktop 中的 Image

@Composable
fun Image(
    painter: Painter,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    alignment: Alignment = Alignment.Center,
    contentScale: ContentScale = ContentScale.Fit,
    alpha: Float = DefaultAlpha,
    colorFilter: ColorFilter? = null
) 

可以看到和 Jetpack Compose 中是一致的,不同的就是如何在 Compose Desktop 中构建 Painter

先来看一种构建的方式吧:

BitmapPainter(useResource(resourcePath, ::loadImageBitmap))

这块的 resourcePath 指的是图片的路径,这个路径是如何定义的呢?还记得上面创建完项目的初始结构么?里面有一个 resource 文件夹,这个文件夹就是根目录,比如 resource 文件夹中有一张图片“icon.png”,要构建这张图片的 Painter 就可以使用如下代码:

BitmapPainter(useResource("icon.png", ::loadImageBitmap))

当然可以在 resource 中创建不同的文件夹来存放不同的资源,图片也是一样的。

简单解释下这行代码吧,虽然看着就一行,其实使用到了好几个函数,首先说下 useResource ,它的作用是将传入的文件路径打开为 InputStream ,而 loadImageBitmap 函数是将 InputStream 转为 ImageBitmap ,最后通过使用 BitmapPainter 才构建出一个 Painter

光说不练假把式!来整一张图片试下吧!

Image(painter = BitmapPainter(useResource("image/icon.png", ::loadImageBitmap)),"Test")

由于我将图片放到 resource 中的 image 文件夹中,所以这块的路径做了一些修改,再来看下图片的目录吧:

image.png

下面来运行看下效果!

image.png

OK,没问题,图片展示出来了!又向成功迈进了一步!!!

SVG 图片?

咱们现在在安卓中使用的图片大多改为了 SVG 格式的,体积又小且清晰,接下来按照相同的方式试一下,先放一张 SVG 格式的图片到刚才创建的 image 文件夹中:

image.png

图片放好了,下面来修改下图片的路径:

Image(painter = BitmapPainter(useResource("image/ic_launcher.svg", ::loadImageBitmap)),"Test")

再运行下程序!

image.png

额。。。刚不是还好好的嘛!这改了个图片格式就不行了?来看下报错信息吧!

Exception in thread "AWT-EventQueue-0" java.lang.IllegalArgumentException: Failed to Image::makeFromEncoded
  at org.jetbrains.skia.Image$Companion.makeFromEncoded(Image.kt:139)
  at androidx.compose.ui.res.ImageResources_desktopKt.loadImageBitmap(ImageResources.desktop.kt:33)
  at MainKt$App$1.invoke(Main.kt:31)
  at MainKt$App$1.invoke(Main.kt:24)

可以看到报了编码错误,这应该咋么搞???

Compose Desktop 早就为我们想到了:

useResource(resourcePath) { loadSvgPainter(it, Density(2f)) }

Compose Desktop 为我们提供了一个叫 loadSvgPainter 的函数,专门用来处理 SVG 图片,接下来使用下看看:

Image(painter = useResource("image/ic_launcher.svg") { loadSvgPainter(it, Density(2f)) },"Test")

使用也很简单,运行看下效果吧:

image.png

嗯,没问题,正常展示!为了方便大家在 Compose Desktop 中使用图片,我写了一个构建 Painter 的函数:

/**
 * 构建Painter,为了图片使用
 * 
 * @param resourcePath 图片路径
 */
fun buildPainter(resourcePath: String): Painter {
    val painter: Painter = if (resourcePath.endsWith(".svg")) {
        useResource(resourcePath) {
            loadSvgPainter(it, Density(2f))
        }
    } else if (resourcePath.endsWith(".png") || resourcePath.endsWith(".jpg") ||
        resourcePath.endsWith(".jpeg") || resourcePath.endsWith(".webp") ||
        resourcePath.endsWith(".PNG") || resourcePath.endsWith(".JPG") ||
        resourcePath.endsWith(".JPEG") || resourcePath.endsWith(".WEBP") || resourcePath.endsWith(".ICO")
    ) {
        BitmapPainter(useResource(resourcePath, ::loadImageBitmap))
    } else {
        throw IllegalArgumentException("resource is illegal argument")
    }
    return painter
}

这里并没有列举全所有的图片的后缀,但咱们一般使用到的都列举了出来,如果有特殊需求的话大家可以自己加上需要的后缀即可。

柳暗花明

上面的一堆都是自己犯傻。。。其实 Compose Desktop 中也可以直接使用 painterResource 来构建图片。

Image(
    painter = painterResource(getWeatherIcon(dailyBean.iconDay)), "",
)

哈哈哈,为什么要写上面的一大堆呢,是让大家体会下我当时写的时候的经历。。。(内心:我是不是太坏了,哈哈哈😂)

网络图片

在安卓中加载网络图片一般都会使用 Glide ,但 Glide 是依赖于安卓的,所以无法在 Compose Desktop 中使用,不过也没有必要,下面来看下如何在 Compose Desktop 中使用网络图片吧:

@Composable
fun <T> AsyncImage(
    load: suspend () -> T,
    painterFor: @Composable (T) -> Painter,
    contentDescription: String,
    modifier: Modifier = Modifier,
    contentScale: ContentScale = ContentScale.Fit,
) {
    val image: T? by produceState<T?>(null) {
        value = withContext(Dispatchers.IO) {
            try {
                load()
            } catch (e: IOException) {
                e.printStackTrace()
                null
            }
        }
    }
    if (image != null) {
        Image(
            painter = painterFor(image!!),
            contentDescription = contentDescription,
            contentScale = contentScale,
            modifier = modifier
        )
    }
}
​
/* 加载网络图片 */fun loadImageBitmap(url: String): ImageBitmap =
    URL(url).openStream().buffered().use(::loadImageBitmap)
​
fun loadSvgPainter(url: String, density: Density): Painter =
    URL(url).openStream().buffered().use { loadSvgPainter(it, density) }
​
fun loadXmlImageVector(url: String, density: Density): ImageVector =
    URL(url).openStream().buffered().use { loadXmlImageVector(InputSource(it), density) }

加载图片的方法有了,如何使用呢?

AsyncImage(
    load = { loadImageBitmap("https://www.wanandroid.com/blogimgs/42da12d8-de56-4439-b40c-eab66c227a4b.png") },
    painterFor = { BitmapPainter(it) },
    contentDescription = "Sample",
    modifier = Modifier.width(200.dp)
)

使用并不难,将图片的网址放进去即可,下面来运行看下效果吧:

image.png

还不错,图片显示得也挺快,大家可以试试!

网络请求—Retrofit

咱们要编写的天气应用肯定是需要网络请求的,这个项目中使用的是和风天气的免费 API,但问题来了,在 Compose Desktop 中要如何使用网络请求呢?

难道要使用 HttpURLConnection 原生进行请求么?这根本不像一个安卓开发的风格啊!就算不能使用 Retrofit ,最不济也得使用 OKHttp 啊!那。。。在 Compose Desktop 中能使用这些安卓中的网络请求框架么?

答案是能!为什么不能?这些框架又没有依赖安卓中的一些特定东西!对吧?

说干就干!第一步当然还是添加依赖:

dependencies {
    implementation(compose.desktop.currentOs)
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
}

这代码是不是很熟悉?没错,在安卓项目中也是这么使用的啊!

接下来和在安卓中的使用方法其实就是一样的了,协程也可以使用!

object ServiceCreator {
​
    // 开发版
    private const val BASE_URL = "https://devapi.qweather.com/v7/"
    private const val CONNECT_TIMEOUT = 30L
    private const val READ_TIMEOUT = 10L
​
    private fun create(url: String = BASE_URL): Retrofit {
        val okHttpClientBuilder = OkHttpClient().newBuilder().apply {
            connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
            readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
        }
​
        return RetrofitBuild(
            url = url,
            client = okHttpClientBuilder.build(),
            gsonFactory = GsonConverterFactory.create()
        ).retrofit
    }
​
    fun <T> create(service: Class<T>): T = create().create(service)
}

先构建了一个 Retrofit 对象,然后创建一个接口:

interface CityWeatherService {
​
    /**
     * 实时天气
     *
     * @param key 用户认证key
     * @param location 需要查询地区的LocationID或以英文逗号分隔的经度,纬度坐标
     * @param lang 多语言设置,默认中文
     *
     * 实时温度、体感温度、风力风向、相对湿度、大气压强、降水量、能见度、露点温度、云量等数据。
     */
    @GET("weather/now")
    suspend fun getWeatherNow(
        @Query("key") key: String = WEATHER_KEY,
        @Query("location") location: String,
        @Query("lang") lang: String = Lang.ZH_HANS.code
    ): WeatherNowBean
​
}

这个没什么说的,接下来再创建一个调用的中间层:

object PlayWeatherNetwork {
​
    private val cityWeatherService = ServiceCreator.create(CityWeatherService::class.java)
​
    suspend fun getWeatherNow(location: String): WeatherNowBean =
        cityWeatherService.getWeatherNow(location = location)
​
}

OK,完事,最后调用下试试!

LaunchedEffect(text) {
    val weatherNow = PlayWeatherNetwork.getWeatherNow("CN101010100")
    println("weatherNow:$weatherNow")
}

运行看结果!

当然 Window 中显示还是刚才的截图,只不过这块打印出了网络请求的结果,来看下打印出的 Log 信息:

weatherNow:WeatherNowBean(fxLink=http://hfx.link/2ax1, code=200, refer=model.Refer@5a146688, now=NowBaseBean(vis=2, temp=3, obsTime=2022-12-12T15:56+08:00, icon=503, wind360=98, windDir=东风, pressure=1016, feelsLike=-3, cloud=0, precip=0.0, dew=-20, humidity=16, text=扬沙, windSpeed=11, windScale=2, city=null), updateTime=2022-12-12T16:02+08:00)

WeatherNowBean 就是网络请求的实体类,这块不做过多描述。

通过这个小例子是为了告诉大家在 Compose Desktop 项目中也能使用咱们熟悉的 Retrofit !大家知道这一点就够了!

这块我在写的时候还有一个小插曲,这块的实体类之前是直接复制和风天气开源项目中的,不是 kotlin 而是是 java 编写的,运行的时候就报错了,将实体类改为 Kotlin 后就能正常运行了。

Jetpack 库使用

刚才在上面提到了,我就是因为 GoogleJetpack 中的库之后会支持 Compose Desktop 才决定玩一玩 Compose Desktop 的,那肯定也要尝试使用下啊!

Google 目前只推出了两个库支持:CollectionDataStoreCollection 目前使用不到,那就先来看看如何使用 DataStore 吧!

第一步还是添加依赖:

dependencies {
    ......
    implementation("androidx.datastore:datastore-preferences-core:1.1.0-dev01")
}

其实 DataStore 的使用方法和之前在安卓中没有什么区别,唯一的区别就是创建的时候,先来看下在安卓中是如何创建 DataStore 的吧:

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

这里用到了 Context ,这就是安卓中独有的,Java 中并没有啊,Kotlin 中也没有啊!那应该如何创建呢?

val dataStores = PreferenceDataStoreFactory.create {
    File("${System.getProperty("user.home")}/playWeatherData.preferences_pb")
}

DataStore 早就为我们考虑到了,使用 PreferenceDataStoreFactory 中的 create 方法就可以创建 DataStore 了。这块需要说明下 System.getProperty("user.home") ,这个获取的是用户 home 的路径,这块踩坑踩了很久,搞了大半天!最开始使用的是 user.dir ,这是项目 work 的目录,但是如果使用 user.dir 的话打包运行的话就会报错,说无法进行读写,所以修改为了 "user.home" 。其实还有一点也会报错,这块文件的后缀名一定得是 preferences_pb ,如果不写或修改为别的就会报错。这块帮助大家避下坑吧!

剩下的使用方法就和安卓中一摸一样了!大家如果没有使用过的话可以看下我之前写 DataStore 的一篇文章:再抱一抱DataStore

绘制

动画的使用

通过上面的 GIF 图可以看到项目中使用到了一些动画,效果还是非常不错的,其实实现起来非常简单!

可见性动画

首先来看下可见性动画的使用,之前我写过一个专栏,里面专门说了下 Compose 中的动画的使用及原理,有兴趣的大家可以去看下:Compose 动画开发艺术探索

可见性动画在页面左边用到了,点击添加按钮出现搜索页面的时候就使用的是可见性动画,简单看下代码:

@Composable
fun LeftInformation() {
    var showSearch by rememberSaveable { mutableStateOf(false) }
    Box(
        Modifier.fillMaxHeight().width(300.dp).padding(end = 10.dp)
    ) {
        WeatherDetails(onAddClick = {
            showSearch = true
        })
​
        AnimatedVisibility(
            visible = showSearch,
            enter = slideInHorizontally(),
            exit = slideOutHorizontally()
        ) {
            SearchCity()
        }
    }
}

可以看到这块在进入的时候使用了 slideInHorizontally 动画,顾名思义,就是水平滑动展开,退出的时候使用了 slideOutHorizontally ,就是水平滑动退出。

实现效果这里就不展示了,就是文章左边的动画效果。

无限重复动画

无限重复动画在左边展示天气信息的天气图标上用到了,这块的重复动画使用了两种,如果是晴天的话就修改 Modifier.rotate ,因为晴天是太阳,旋转的话好看一些,如果不是晴天的话旋转不好看,所以改为 Modifier.offset ,这样平移的话好看一些。来看下实现代码吧:

@Composable
private fun RotateWeatherIcon(icon: String) {
    val infiniteTransition = rememberInfiniteTransition()
    val modifier = if (icon == "100") {
        val rotate by infiniteTransition.animateFloat(
            initialValue = 0f,
            targetValue = 360f,
            animationSpec = infiniteRepeatable(
                animation = tween(3500, easing = LinearOutSlowInEasing),
                repeatMode = RepeatMode.Reverse
            )
        )
        Modifier.rotate(rotate)
    } else {
        val offsetX by infiniteTransition.animateValue(
            initialValue = (-30).dp, // 初始值
            targetValue = 30.dp, // 目标值
            typeConverter = TwoWayConverter(
                { AnimationVector1D(it.value) },
                { it.value.dp }), // 类型转换
            animationSpec = infiniteRepeatable(  // 动画规格!!!
                animation = tween(3500, easing = LinearOutSlowInEasing),
                repeatMode = RepeatMode.Reverse
            )
        )
        Modifier.offset(x = offsetX)
    }
    Image(
        painter = painterResource(getWeatherIcon(icon)),
        "",
        modifier = modifier.size(170.dp).padding(10.dp)
    )
}

无限重复动画的使用方式也不难,在之前的章节中说过,感兴趣的可以去上面所说的专栏中查看,大家放心,Jetpack Compose 中动画的使用方式和 Compose Desktop 一致。

空气质量

空气质量就是右边天气详情中的第一个模块,样子如下图所示:

image.png

这块是一个 “自定义 View”,为什么要加引号呢?因为这是 Compose 啊,不是安卓的 View 系统😂。

下面来看下这个 “自定义 View” 如何实现的吧!

@Composable
private fun AirQualityProgress(aqiValue: Int) {
    Canvas {
        drawLine(
            brush = Brush.linearGradient(
                0.0f to Color(red = 139, green = 195, blue = 74),
                0.1f to Color(red = 255, green = 239, blue = 59),
                0.2f to Color(red = 255, green = 152, blue = 0),
                0.3f to Color(red = 244, green = 67, blue = 54),
                0.4f to Color(red = 156, green = 39, blue = 176),
                1.0f to Color(red = 143, green = 0, blue = 0),
            ),
            start = Offset.Zero,
            end = Offset(size.width, 0f),
            strokeWidth = 20f,
            cap = StrokeCap.Round,
        )
        drawPoints(
            points = arrayListOf(
                Offset(size.width / 500 * aqiValue, 0f)
            ),
            pointMode = PointMode.Points,
            color = Color.White,
            strokeWidth = 20f,
            cap = StrokeCap.Round,
        )
    }
}

因为我没有开发过桌面的应用,所以不太清楚在桌面程序中实现这样的一个控件需要写多少代码,我只开发过安卓,只能拿安卓原生 View 做对比,在安卓 View 中如果想实现这样的一个控件的话绝对不止这么一点代码。。。

来简单解释下这个控件吧:在 Compose 中绘制需要使用可组合项 Canvas ,然后来绘制下面的那条线,线上的颜色是渐变的,在 Compose 中只需要使用 Brush 就可以实现渐变,也可以控制在不同的进度显示不同颜色,空气质量一般分为六个等级:优、良、轻度污染、中度污染、重度污染和严重污染,所以上面对应有六种颜色。最后算出当前的 AQI 值应该绘制的地方进行绘制即可。

7 日天气预报

24 小时天气预报中没有什么需要说的,一个 LazyRow 就实现了,就直接跳过了。

接下来来看下 7 日天气预报,这里其实大部分也不难,但注意看右边的温度条,这是模仿苹果天气中的温度条实现的,下面来看下苹果的样子吧:

image.png

再来看下我模仿实现的效果:

image.png

不能说一模一样,只能说大差不离。

在模仿苹果这个小彩条的时候刚开始就犯了难,这是啥意思啊。。。这条里面都代表着什么啊,也看不太懂,后来网上找了半天才知道。

  • 小彩条的长度代表温差,彩条越长温差越大。
  • 根据最近 10 天的温度,分别设置最高值和最低值。例如上面的苹果截图,近十天的最高温度为4度,则这组彩条最右端代表 4 度。 近十天最低温为 -12 度,那么这组彩条最左端就代表 -12 度。左右两端的极值不是固定不变的。
  • 小白点代表了此时的温度。

搞明白这个小彩条的含义就好说了,来自定义下这个控件吧!

@Composable
private fun TemperatureChart(min: Int, max: Int, currentMin: Int, 
                             currentMax: Int, currentTemperature: Int = -100) {
    val currentMinColor: Color = getTemperatureColor(currentMin)
    val currentMaxColor: Color = getTemperatureColor(currentMax)
    // 计算周温差
    val num = max - min
    Canvas {
        // 绘制底条
        drawLine(
            color = Color.Gray,
            start = Offset.Zero,
            end = Offset(size.width, 0f),
            strokeWidth = 10f,
            cap = StrokeCap.Round,
        )
        // 绘制这一天的气温
        drawLine(
            brush = Brush.linearGradient(
                0.0f to currentMinColor,
                1.0f to currentMaxColor,
            ),
            start = Offset(size.width / num * (currentMin - min), 0f),
            end = Offset(size.width / num * (currentMax - min), 0f),
            strokeWidth = 10f,
            cap = StrokeCap.Round,
        )
        // 如果是当天,则绘制当前温度小白点
        if (currentTemperature > -100) {
            drawPoints(
                points = arrayListOf(
                    Offset(size.width / num * (currentTemperature - min), 0f)
                ),
                pointMode = PointMode.Points,
                color = Color.White,
                strokeWidth = 10f,
                cap = StrokeCap.Round,
            )
        }
    }
}

首先看下这个可组合项接收的几个参数:

  • min:未来几天最低温度
  • max:未来几天最高温度
  • currentMin:当前绘制天的最低温度
  • currentMax:当前绘制天的最高温度
  • currentTemperature:当前天的当前温度

再简单说下函数内容,先计算下这几天的温差,然后绘制温度底条,再然后绘制温度条,这个温度条是渐变的,需要根据不同温度换不同颜色,最后判断是不是当天,如果是当天的就绘制当前温度的小白点。

上面调用一个函数 getTemperatureColor ,这是为了计算不同温度的颜色的方法,来看下这个方法吧:

/**
 * 获取不同气温的颜色值,需要动态判断
 */
private fun getTemperatureColor(temperature: Int): Color {
    return if (temperature < -20) {
        Color(red = 26, green = 92, blue = 249)
    } else if (temperature < 30) {
        Color(red = 253, green = 138, blue = 11)
    } else {
        Color(red = 248, green = 60, blue = 30)
    }
}

这块没有写全这些颜色,其实写了挺多,篇幅原因就不写了,大家能理解就好。

太阳月亮

顾名思义,太阳月亮就是指的日出日落和月出月落,还是再来看下实现好的样式吧:

image.png

根据日出日落和月出月落的时间来展示当前太阳和月亮的状态。由上面图大概可以看出,需要使用到贝塞尔曲线,由于只是一段曲线,所以使用二阶贝塞尔曲线就可以了。

什么是贝塞尔曲线呢?来看下百度百科的描述吧:

贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。

下面来简单看下二阶贝塞尔曲线的简单动画吧:

二阶贝塞尔曲线.gif

二阶贝塞尔曲线的公式如下:

B(t)=(1−t)2P0+2t(1−t)P1+t2P2,t∈[0,1]

下面来看下在 Compose 中如何绘制二阶贝塞尔曲线吧:

Canvas {
    val path = Path()
    path.moveTo(0f, size.height)
    // 二阶贝塞尔曲线
    path.quadraticBezierTo(
        size.width / 2, -size.height,
        size.width, size.height
    )
​
    drawPath(
        path = path, color = Color(red = 255, green = 193, blue = 7, alpha = 255),
        style = Stroke(width = 3f)
    )
}

可以看到在 Compose 使用 PathquadraticBezierTo 函数来绘制二阶贝塞尔曲线,这块需要解释下,二阶贝塞尔曲线一共需要三个点,但 quadraticBezierTo 函数中只接收了两个点,那剩下一个点呢?其实 PathmoveTo 到的点就是第一个点,quadraticBezierTo 函数接收的第一个点是控制点,第二个参数是终点。绘制完后贝塞尔曲线后还要绘制曲线两边的圆点:

drawPoints(
    points = arrayListOf(
        Offset(0f, size.height),
        Offset(size.width, size.height)
    ),
    pointMode = PointMode.Points,
    color = Color(red = 255, green = 193, blue = 7, alpha = 255),
    strokeWidth = 20f,
    cap = StrokeCap.Round,
)

绘制完贝塞尔曲线和圆点之后就该绘制太阳和月亮图标了,这块需要使用贝塞尔曲线的公式来计算点的坐标了。绘制点之前需要计算当前时间占太阳或月亮在天上的百分比:

fun getAccounted(rise: String, set: String, isSun: Boolean = true): Double {
    val calendar = Calendar.getInstance()
    val currentMills = calendar.timeInMillis
    calendar.set(Calendar.HOUR_OF_DAY, getHour(rise))
    calendar.set(Calendar.MINUTE, getMinute(rise))
    val riseMills = calendar.timeInMillis
    if (!isSun) {
        calendar.set(Calendar.DAY_OF_MONTH, calendar.get(Calendar.DAY_OF_MONTH) + 1)
    }
    calendar.set(Calendar.HOUR_OF_DAY, getHour(set))
    calendar.set(Calendar.MINUTE, getMinute(set))
    val setMills = calendar.timeInMillis
    val result = (currentMills - riseMills) / (setMills - riseMills).toDouble()
    return if (currentMills < riseMills) 0.0 else if (result > 1) 1.0 else result
}

这块的代码不多,使用 Calendar 来获取当前毫秒值存下来,然后设置日出日落的小时分钟并记录下来毫秒值,最后进行计算即可。

现在百分比也有了,只剩下计算贝塞尔曲线上的坐标点了,先来看下计算坐标点的公式吧:

P0(起始点) , P1(控制点), P2 (终点)
P0(x1,y1),P2(x2,y2), P1(cx,cy)
val x = Math.pow(1-t, 2) * x1 + 2 * t * (1-t) * cx + Math.pow(t, 2) * x2
val y = Math.pow(1-t, 2) * y1 + 2 * t * (1-t) * cy + Math.pow(t, 2) * y2

公式是固定的,只需要往里套点即可:

val x = (1.0 - sunResult).pow(2.0) * 0f + 2 * sunResult * (1 - sunResult) * (size.width / 2) + sunResult.pow(2.0) * size.width
​
val y = (1.0 - sunResult).pow(2.0) * size.height + 2 * sunResult * (1 - sunResult) * (-size.height) + sunResult.pow(2.0) * size.height

计算出来贝塞尔曲线中的点后就该绘制月亮或太阳的图标了:

drawImage(
    image = sunImage,
    topLeft = Offset(
        x - sunImage.width / 2,
        x - sunImage.height / 2
    )
)

这块的图片需要 ImageBitmap 格式,直接使用上一篇文章中的 useResource 即可生成。drawImage 中的 topLeft 参数表示左上角的坐标,默认的话时(0,0),但图片有宽高,所以需要减去宽高的一半,这样太阳和月亮的图标才能显示在正中间。

跳转浏览器

在安卓中咱们可以使用 WebView 来展示网页,但是在桌面版的应用中就没有了,需要使用系统自带的浏览器,那使用 Compose Desktop 应该如何打开系统自带的浏览器呢?可以使用 Desktop 中的 browse 方法,下面是我写的一个扩展函数:

/**
 * 通过字符串打开系统默认浏览器
 */
fun String?.openBrowse() {
    if (this?.startsWith("http") == false && !this.startsWith("https")) {
        throw IllegalArgumentException("this illegal argument exception")
    }
    try {
        val uri = URI.create(this ?: "https://www.baidu.com")
        // 获取当前系统桌面
        val dp = Desktop.getDesktop()
        // 判断系统桌面是否支持要执行的功能
        if (dp.isSupported(Desktop.Action.BROWSE)) {
            // 获取系统默认浏览器打开链接
            dp.browse(uri)
        }
    } catch (e: Exception) {
        println(e.message)
    }
}

首先判断当前字符串前缀是否为 “http” 和 “https”,如果不是的话就证明这个字符串不是网络链接,就直接抛出异常,剩下代码中的注释写的已经比较全了,就不多说了。

函数有了再来看下如何调用吧:

Row {
    Image(painter = painterResource("image/ic_launcher.svg"), "", modifier = Modifier.size(15.dp))
​
    Spacer(modifier = Modifier.width(5.dp))
​
    Text(text = "数据来自和风天气", fontSize = 12.sp, modifier = Modifier.clickable {
        fxLink.openBrowse()
    })
}

很简单,直接调用即可。运行效果就不在这里进行展示了,大家可以下载代码运行看看。

对话框

在安卓中对话框的使用场景实在是太多了,就不一一列举了,随便打开一个应用里面都有一堆对话框,那么在 Compose Desktop 中该如何弹出对话框呢?先来看下 Dialog 的函数定义吧:

@Composable
fun Dialog(
    onCloseRequest: () -> Unit,
    state: DialogState = rememberDialogState(),
    visible: Boolean = true,
    title: String = "Untitled",
    icon: Painter? = null,
    undecorated: Boolean = false,
    transparent: Boolean = false,
    resizable: Boolean = true,
    enabled: Boolean = true,
    focusable: Boolean = true,
    onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
    onKeyEvent: ((KeyEvent) -> Boolean) = { false },
    content: @Composable DialogWindowScope.() -> Unit
)

看到这些参数眼熟么?和上一篇文章中提到的 Window 基本一致,不同的就是这块的 stateDialogState ,接下来看下 DialogState 吧:

interface DialogState {
    var position: WindowPosition
​
    var size: DpSize
}

可以看到通过定义 DialogState 可以定义对话框的位置和大小,大小可以直接通过 DpSize 设置,位置的话通过 WindowPosition 来设置,但 WindowPosition 可以通过绝对位置和相对位置来设置位置:

// 绝对位置,绝对坐标
fun WindowPosition(x: Dp, y: Dp) = WindowPosition.Absolute(x, y)
​
// 相对位置
fun WindowPosition(alignment: Alignment) = WindowPosition.Aligned(alignment)

可以看到对话框也可以设置标题和图标,剩下的参数都见过,就不过多介绍了。

来看看在 Compose Desktop 中如何使用对话框吧:

val alertDialog = rememberSaveable { mutableStateOf(false) }
Dialog(
    onCloseRequest = { alertDialog.value = false }, visible = alertDialog.value,
    state = rememberDialogState(size = DpSize(300.dp, 200.dp)),
    title = "Weather", icon = buildPainter("image/ic_launcher.svg")
) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.padding(top = 20.dp)
    ) {
        Text(
            text = title,
            fontSize = 16.sp,
            maxLines = 1,
            fontWeight = FontWeight.Bold,
            color = MaterialTheme.colors.onSecondary,
            modifier = Modifier.padding(horizontal = 20.dp)
        )
    }
}

代码中设置了下对话框的大小,对话框使用方式和 Jetpack Compose 基本一致,看下运行效果吧:

image.png

可以看到对话框使用很简单,有需要的可以在 Dialog 中添加一些别的可组合项进行使用。

桌面的 PopopWindow

在安卓中咱们经常使用的 PopopWindow 如何在 Compose Desktop 中使用呢?

Compose 中可以直接使用 Popup 来构建类似于安卓中 PopupWindow 的弹框,但我试着直接使用了下 Popup ,不太好控制弹出的地方,所以我就想着有没有能更简单控制弹出位置的方法,仔细找了下,果然有!可以使用 CursorDropdownMenu ,它可以将 Popup 在鼠标点击的地方弹出。

@Composable
fun CursorDropdownMenu(
    expanded: Boolean,
    onDismissRequest: () -> Unit,
    focusable: Boolean = true,
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit
) {
    ......
        Popup(
            focusable = focusable,
            onDismissRequest = onDismissRequest,
            popupPositionProvider = rememberCursorPositionProvider(),
            onKeyEvent = {
                handlePopupOnKeyEvent(it, onDismissRequest, focusManager!!, inputModeManager!!)
            },
        )
    ......
}

上面就是 CursorDropdownMenu 进行了一些删减的源码,可以看到里面也调用了 Popup

接下来看下使用方式吧:

var showPopupWindow by remember { mutableStateOf(false) }
​
CursorDropdownMenu(
    showPopupWindow,
    onDismissRequest = { showPopupWindow = false },
    modifier = modifier.width(300.dp).padding(horizontal = 15.dp).padding(bottom = 10.dp)
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(
            text = data.titleDetails,
            fontSize = 15.sp,
            fontWeight = FontWeight.Bold,
            color = MaterialTheme.colors.onSecondary
        )
        IconButton(onClick = { showPopupWindow = false }) {
            Icon(Icons.Sharp.Close, "Close")
        }
    }
}

其实使用方法和对话框是类似的,都是通过定义一个是否展开的变量,然后通过这个变量来确定当前弹框是否显示。

下面来看下运行效果:

image.png

可以看到还是挺好看的,哈哈哈!

系统菜单

在 Mac 中右上角会显示应用的菜单,如下图所示:

image.png

别的应用有,我们当然也想要!那咱们的 Compose Desktop 应该如何展示呢?

放心,Jetbrains 都为我们想到了!来看看如何使用吧!

Window(onCloseRequest = ::exitApplication, title = "天青色等烟雨") {
    MenuBar {
        Menu("文件", mnemonic = 'F') {
            Item("复制(假的)", onClick = { action = "Last action: Copy" }, shortcut = KeyShortcut(Key.C, ctrl = true))
            Item("粘贴(假的)", onClick = { action = "Last action: Paste" }, shortcut = KeyShortcut(Key.V, ctrl = true))
        }
        Menu("帮助", mnemonic = 'H') {
            Item("天气帮助", onClick = { action = "Last action: Help" })
        }
    }
    App()
}

直接使用 MenuBar 就可以展示类似于上方图片中的菜单了,需要注意的是 MenuBar 需要 FrameWindowScope ,上一篇文章中所说 Windowcontent 就是 FrameWindowScope ,所以可以进行使用,要直接拿出来就不行了,如果想拿出来的话需要添加一个扩展函数:

private fun FrameWindowScope.DemoMenu() {
    MenuBar {
        Menu("文件", mnemonic = 'F') {
            Item("复制(假的)", onClick = { action = "Last action: Copy" }, shortcut = KeyShortcut(Key.C, ctrl = true))
            Item("粘贴(假的)", onClick = { action = "Last action: Paste" }, shortcut = KeyShortcut(Key.V, ctrl = true))
        }
        Menu("帮助", mnemonic = 'H') {
            Item("天气帮助", onClick = { action = "Last action: Help" })
        }
    }
}

简单说下吧,先来看下 Menu 吧:

@Composable
fun Menu(
    text: String,
    mnemonic: Char? = null,
    enabled: Boolean = true,
    content: @Composable MenuScope.() -> Unit
)

函数参数并不多,只有 mnemonic 不太好理解,它对应于键盘上某个键的字符,当这个键和 Alt 被按下时菜单将打开。然后需要重点看下 content ,它的参数类型为 MenuScope ,那就来看下 MenuScope 中都能添加什么可组合项吧!

class MenuScope internal constructor(private val impl: MenuScopeImpl) {
​
    @Composable
    fun Menu()
  
    @Composable
    fun Separator() = impl.Separator()
​
    @Composable
    fun Item()
​
    @Composable
    fun CheckboxItem()
​
    @Composable
    fun RadioButtonItem()
}

可以看到,还能再添加 Menu ,剩下可添加的还有 ItemSeparatorCheckboxItemRadioButtonItem ,故名思义,分别是条目、分隔符、复选框和单选框。

废话不多说,运行看下效果吧!

image.png

大家在使用的时候可以根据需求选择需要使用的可组合项来组合系统菜单。

托盘及通知

托盘是什么呢?在 Mac 中右上角展示的就是托盘,如下图所示;Windows 中在右下角。

image.png

托盘

同样的,Jetbrains 也为我们想到了,使用方法也不难,直接来看下吧:

Tray(
    state = rememberTrayState(),
    icon = painterResource("image/launcher.png"),
    menu = {
        Item(
            "天气预报",
            onClick = {}
        )
        Separator()
        Item(
            "退出",
            onClick = {}
        )
    }
)

Compose Desktop 中使用 Tray 来为应用添加系统托盘,这里的 Menu 其实和上面系统菜单中的 Menu 是一回事,所以上面所描述的 ItemSeparatorCheckboxItemRadioButtonItem 都可以进行使用。

下面来运行看下实际效果吧:

image.png

这块还有一个小知识点,咱们有时候使用的一些工具其实都没有真正页面,只是在系统托盘中存在,Tray 也可以在没有窗口的情况下创建托盘应用程序:

fun main() = application {
    Tray(
        icon = painterResource("image/launcher.png"),
        menu = {
            Item(
                "退出",
                onClick = ::exitApplication
            )
        }
    )
}

这样就可以创建出一个没有窗口的程序了。

通知

咱们还可以使用系统托盘,也就是 Tray 向用户发送通知。一共有 3 种类型的通知:

  1. notify - 简单的通知
  2. warn - 警告通知
  3. Error - 错误通知

下面来看下使用方法:

val trayState = rememberTrayState()
val infoNotification = rememberNotification("天气预报", "明天的天气很好,建议出门遛弯", Notification.Type.Info)
​
Tray(
    state = trayState,
    icon = painterResource("image/launcher.png"),
    menu = {
        Item(
            "天气预报",
            onClick = {
                trayState.sendNotification(infoNotification)
            }
        )
        Separator()
        Item(
            "退出",
            onClick = {
                isOpen.value = false
            }
        )
    }
)

使用起来很简单,先使用 rememberNotification 来构建出一个 Notification ,然后直接使用 trayState 中的 sendNotification 进行发送通知即可。

我录制了一个完整的显示系统菜单、托盘以及通知的 GIF ,大家来看下效果吧。

菜单及通知.gif

打包

代码写好了,该弄的动画也弄了,该请求的网络数据也请求了,该实现的效果也都实现好了,但是!!!咱们得打包出来啊!不打包出来别人如何使用呢?难道说别人想用你开发的桌面应用,结果你给他说你先下载一个 IntelliJ Idae ,然后下载下我的源码,之后把环境配置好,最后运行就可以了!如果下次再想用的时候再运行一次就好了!

这说的是人话嘛😂,肯定不能这样,所以一定要打包!由于 Compose Desktop 不止可以运行在 Mac 中,还可以运行在 WindowsLinux 中,所以需要打多个包。那使用 Compose Desktop 应该如何打包呢?且听我慢慢道来!

基本用法

插件中的基本配置单元是一个 applicationapplication 是什么呢?在第一篇文章中也提到了,就是在 build.gradle.kts 文件中的代码,咱们再来看下:

compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "Demo"
            packageVersion = "1.0.0"
        }
    }
}

一个 application 定义了一组最终二进制文件的共享配置。换句话说,application DSL 允许将一堆文件连同 JDK 分发包打包成一组各种格式(.dmg.deb.msi.exe等)的压缩二进制安装程序。

该插件创建以下任务:

  • package<FormatName>(例如 packageDmgpackageMsi)用于将应用程序打包成相应的格式。这块需要注意的是,目前没有交叉编译支持,因此只能使用特定操作系统构建格式(例如,要构建.dmg 就必须使用 macOS)。默认情况下会跳过与当前操作系统不兼容的任务。
  • packageDistributionForCurrentOS 是一个生命周期任务,聚合了应用程序的所有包任务。
  • packageUberJarForCurrentOS用于创建单个 jar 文件,其中包含当前操作系统的所有依赖项。
  • run 用于在本地运行应用程序。需要定义一个 mainClass 包含该 main 函数的类。请注意, run 将启动具有完整运行时的非打包 JVM 应用程序。这比创建具有最小运行时间的紧凑二进制映像更快、更容易调试。要运行最终的二进制图像,需要改用 runDistributable
  • createDistributable用于在不创建安装程序的情况下创建预打包的应用程序映像和最终应用程序映像。
  • runDistributable 用于运行预先打包的应用程序映像。
  • 只有在脚本中使用 blockproperty 时才会创建任务。

光这么说其实有点懵,来一张图大家就明白我说的是什么了!

image.png

是不是有点恍然大明白的感觉!直接点击 IntelliJ IDEA 右侧边栏的 Gradle ,就会出现这个侧边栏,然后点击 Task 中的 compose desktop 就会出现上面描述的那些任务。

打包配置

Compose Desktop 打包有很多的配置项,下面来分别看下。

配置包含的 JDK 模块

Gradle 插件使用 jlink 通过仅包含必要的 JDK 模块来最小化可分发的大小。

此时,Gradle 插件不会自动确定必要的 JDK 模块。未能提供必要的模块不会导致编译问题,但会导致在运行时出现 ClassNotFoundException 的错误。

如果在运行打包的应用程序或任务时遇到 ClassNotFoundException ,可以使用DSL 方法runDistributable 来配置包含额外的 JDK 模块,需要使用 modules 来配置。

可以通过手动或运行 suggestModules 任务来确定哪些模块是必需的。suggestModules 使用 jdeps 静态分析工具来确定可能缺少的模块。

如果安装包的大小不重要的话,可以使用 includeAllModulesDSL 属性简单地包括所有运行时模块作为替代。

compose.desktop {
    application {
        nativeDistributions {
            modules("java.sql")
            // alternatively: includeAllModules = true
        }
    }
}

这块在我打包的时候搜了好久!最后在 Issue 中找到了解决方案!

可用格式

以下格式可用于支持的操作系统:

  • macOS.dmg ( TargetFormat.Dmg)、 .pkg( TargetFormat.Pkg)
  • Windows.exe ( TargetFormat.Exe)、 .msi( TargetFormat.Msi)
  • Linux.deb ( TargetFormat.Deb)、 .rpm( TargetFormat.Rpm)

指定包版本

由于可以打多种不同的包,也有可能需要区分不同的版本,所以可以指定包的版本。如何指定的呢?来看代码:

compose.desktop {
    application {
        nativeDistributions {
            packageVersion = "1.0.0" 
            
            linux {
              packageVersion = "1.0.0" 
              debPackageVersion = "1.0.0" 
              rpmPackageVersion = "1.0.0" 
            }
            macOS {
              packageVersion = "1.1.0"
              dmgPackageVersion = "1.1.0" 
              pkgPackageVersion = "1.1.0" 
              
              packageBuildVersion = "1.1.0"
              dmgPackageBuildVersion = "1.1.0" 
              pkgPackageBuildVersion = "1.1.0" 
            }
            windows {
              packageVersion = "1.2.0"  
              msiPackageVersion = "1.2.0"
              exePackageVersion = "1.2.0" 
            }
        }
    }
}

必须为本机分发包指定包版本,还可以使用以下 DSL 属性(按优先级降序排列):

  • nativeDistributions.<os>.<packageFormat>PackageVersion 指定单个包格式的版本;
  • nativeDistributions.<os>.packageVersion 指定单个目标操作系统的版本;
  • nativeDistributions.packageVersion 指定所有包的版本;

对于 macOS,还可以使用以下 DSL 属性指定构建版本(按优先级降序排列):

  • nativeDistributions.macOS.<packageFormat>PackageBuildVersion 指定单一包格式的构建版本;
  • nativeDistributions.macOS.packageBuildVersion 为所有 macOS 包指定构建版本。

需要注意的是,版本必须遵循以下规则:

  • dmgpkg :格式为 MAJOR.MINOR.PATCH

    其中:MAJOR是一个 > 0 的整数;MINOR是一个可选的非负整数;PATCH是一个可选的非负整数;

  • msiexe :格式为 MAJOR.MINOR.BUILD

    其中:MAJOR是一个非负整数,最大值为255;MINOR是一个非负整数,最大值为255;BUILD是一个非负整数,最大值为65535;

  • rpm :版本不得包含-(破折号)字符。

  • deb :格式为 EPOCH:UPSTREAM_VERSION-DEBIAN_REVISION

    其中:EPOCH是一个可选的非负整数;UPSTREAM_VERSION 只包含字母数字和字符., +, -, ~,必须以数字开头;DEBIAN_REVISION是可选的,可能只包含字母数字和字符., +, ~

自定义 JDK 版本

由于该插件使用jpackage,所以最低得使用 JDK 15

  • JAVA_HOME 环境变量指向兼容的 JDK 版本。
  • javaHome 通过 DSL 设置:
compose.desktop {
    application {
        javaHome = System.getenv("JDK_15")
    }
}

自定义输出目录

Compose Desktop 默认的打包路径在 /build/compose/binaries/main/app 中,如果想修改下打包路径的话,需要修改下配置:

compose.desktop {
    application {
        nativeDistributions {
            outputBaseDir.set(project.buildDir.resolve("customOutputDir"))
        }
    }
}

自定义基本数据

DSL 块中提供以下属性 nativeDistributions

  • packageName — 应用程序名称(默认值:Gradle 项目名称);
  • version — 应用程序的版本(默认值:Gradle 项目的版本);
  • description — 应用程序的描述(默认值:无);
  • copyright — 应用程序的版权(默认值:无);
  • vendor — 应用程序的供应商(默认值:无);
  • licenseFile — 应用程序的许可证(默认值:无)。
compose.desktop {
    application {
        nativeDistributions {
            packageName = "PlayWeather"
            version = "1.1.0"
            description = "PlayWeather"
            copyright = "© 2022 My Name. All rights reserved."
            vendor = "Example vendor"
            licenseFile.set(project.file("LICENSE.txt"))
        }
    }
}

这块大家可以根据需求来定义这些数据,如不需要不写即可。

特定平台选项

需要使用相应的 DSL 块设置特定于平台的选项,使用方法就是上面 maxOSwindowslinux ,不同平台可配置的选项都不太一样!

  • 所有平台

    • iconFile.set(File("PATH_TO_ICON"))— 应用程序特定于平台的图标的路径。
    • packageVersion = "1.0.0" — 特定于平台的包版本。
    • installationPath = "PATH_TO_INSTALL_DIR" 默认安装目录的绝对或相对路径;在 Windows 上dirChooser = true,可用于启用在安装过程中自定义路径。
  • Linux

    • packageName = "custom-package-name" 覆盖默认的应用程序名称;
    • debMaintainer = "maintainer@example.com" — deb 包维护者的电子邮件;
    • menuGroup = "my-example-menu-group"— 应用程序的菜单组;
    • appRelease = "1"— rpm 包的发布值,或 deb 包的修订值;
    • appCategory = "CATEGORY" — rpm 包的组值,或 deb 包的部分值;
    • rpmLicenseType = "TYPE_OF_LICENSE"— rpm 包的一种许可证;
    • debPackageVersion = "DEB_VERSION"``Specifying package version — 特定于 deb 的包版本;
    • rpmPackageVersion = "RPM_VERSION"``Specifying package version— 特定于 rpm 的软件包版本;
  • MacOS

    • bundleID — 唯一的应用标识符;

      • 只能包含字母数字字符 ( A-Z, a-z, 0-9)、连字符 ( -) 和句点 ( .) 字符;
      • com.mycompany.myapp建议使用反向 DNS 表示法(例如);
    • packageName — 应用名称;

    • dockName— 显示在菜单栏、“关于”菜单项、停靠栏等中的应用程序名称。packageName 等于默认情况下的名称;

    • signing, notarization, provisioningProfile, 和runtimeProvisioningProfile— 详见相应教程

    • appStore = true — 为 Apple App Store 构建和签名。至少需要 JDK 17;

    • appCategory — Apple App Store 的应用类别。默认值是 public.app-category.utilities

    • entitlementsFile.set(File("PATH_TO_ENTITLEMENTS")) — 包含签名时使用的权利的文件路径;

    • runtimeEntitlementsFile.set(File("PATH_TO_RUNTIME_ENTITLEMENTS")) — 包含在签署 JVM 运行时时使用的权利的文件路径;

    • dmgPackageVersion = "DMG_VERSION" — 一个特定于 dmg 的包版本(详见参考资料部分);

    • pkgPackageVersion = "PKG_VERSION" — 特定于 pkg 的包版本(详情请参阅参考资料部分);

    • packageBuildVersion = "DMG_VERSION" — 包构建版本(详见参考资料部分);

    • dmgPackageBuildVersion = "DMG_VERSION"— 特定于 dmg 的软件包构建版本(详情请参阅参考资料部分);

    • pkgPackageBuildVersion = "PKG_VERSION" — 特定于 pkg 的包构建版本;

    • infoPlist — 链接到别的程序。

  • Linux

    • console = true为应用程序添加一个控制台启动器;
    • dirChooser = true 允许在安装过程中自定义安装路径;
    • perUserInstall = true允许在每个用户的基础上安装应用程序
    • menuGroup = "start-menu-group" 将应用程序添加到指定的开始菜单组;
    • upgradeUuid = "UUID" — 一个唯一的 ID,当更新的版本比安装的版本更新时,它使用户能够通过安装程序更新应用程序。对于单个应用程序,该值必须保持不变;
    • msiPackageVersion = "MSI_VERSION" — 特定于 msi 的软件包版本;
    • exePackageVersion = "EXE_VERSION" — 特定于 pkg 的包版本

修改应用图标

这块为什么要单独拿出一大块来说呢?因为这个问题中困扰了我好久。。。所以才。。。。

还记得之前文章中说了 Window 可组合项中可以设定 Icon 么,但当时说的时候专门说了此 Icon 并非应用程序的图标!因为应用程序图标需要以特定于操作系统的格式提供:

  • .icns 对于 macOS
  • .ico 适用于 Windows
  • .png 对于 Linux

看下代码吧:

compose.desktop {
    application {
        nativeDistributions {
            macOS {
                iconFile.set(project.file("icon.icns"))
            }
            windows {
                iconFile.set(project.file("icon.ico"))
            }
            linux {
                iconFile.set(project.file("icon.png"))
            }
        }
    }
}

Linux 中的 png 格式的图片我们很常见,但是 MacWindows 中的格式是什么鬼。。。没见过啊!

如果知道这两种文件格式的话大家直接跳过后面的部分即可,这里还需要注意的是这里的文件路径指的是项目根目录。

ICNS 文件

  1. 什么是 .icns ?

.icns 是苹果的 macOS 操作系统的 App 图标文件的扩展名,大家在 macOS 的 Desktop 桌面Finder 访达Dock 程序坞 等看到应用程序的外观就是由一个内置在此 App 内部的扩展名为.icns的文件实现的。

可以通过鼠标“右键”点击 App - “显示包内容” - 进入 Contents 目录 - 进入Resources 目录,然后在目录下可以找到名为 Appicon.icns 或其他后缀为 .icns 的一个图标文件。

  1. 如何创建 .icns 扩展名的图标文件?

a. 准备一张图片,重命名为 icon.png,其他大小尺寸可以通过终端命令生成;

b. 通过鼠标右键或者命令,创建一个名为 icons.iconset 的文件夹

mkdir icons.iconset

c. 通过”终端“来快速创建各种不同尺寸要求的图片文件

sips -z 512 512 icon.png -o icons.iconset/icon_512x512.png

d. ”终端“中运行下面的命令,就可以获得名为icon.icns的图标文件了

iconutil -c icns icons.iconset -o icon.icns

注意:icon.png 图片文件和 icons.iconset 文件夹要保存在同一级目录下,”终端“启动后切换到相同目录。

ICO 文件

ico 文件是 Windows 系统的应用图标格式,我也不会制作,但找到了一个制作 ico 的网站:

www.butterpig.top/icopro/

需要的话可以进去制作。

混淆

Compose Desktop 1.2 版本开始,Compose Gradle 插件支持开箱即用的 ProGuardProGuard 是一个众所周知的用于缩小和混淆的开源工具Guardsquare

Gradle插件为每个对应的默认打包任务提供了发布任务:

默认任务(没有 ProGuard)发布任务(带 ProGuard)描述
createDistributablecreateReleaseDistributable使用捆绑的 JDK 和资源创建应用程序映像
runDistributablerunReleaseDistributable使用捆绑的 JDK 和资源运行应用程序映像
runrunReleasejar使用 Gradle JDK运行非打包应用程序
package<FORMAT_NAME>packageRelease<FORMAT_NAME>将应用程序映像打包到<FORMAT_NAME>文件中
packageForCurrentOSpackageReleaseForCurrentOS将应用程序映像打包成与当前操作系统兼容的格式
notarize<FORMAT_NAME>notarizeRelease<FORMAT_NAME>上传<FORMAT_NAME>用于公证的应用程序图像(仅限 macOS)
checkNotarizationStatuscheckReleaseNotarizationStatus检查公证是否成功(仅限 macOS)

默认配置添加了一些 ProGuard 规则:

  • 缩小应用程序图像,即删除未使用的类;
  • compose.desktop.application.mainClass 用作入口点;
  • keep 避免破坏 Compose 运行时的一些规则。

在许多情况下,获得缩小的 Compose 应用程序不需要任何额外的配置。但是,有时 ProGuard 可能无法跟踪字节码中的某些用法(例如,如果通过反射使用类,则可能会发生这种情况)。如果遇到仅在 ProGuard 处理后才会发生的问题,可能需要添加自定义规则。为此,通过 DSL 指定配置文件:

compose.desktop {
    application {
        buildTypes.release.proguard {
            configurationFiles.from(project.file("compose-desktop.pro"))
        }
    }
}

可以参考 Guardsquare 关于 ProGuard 规则和配置选项的综合手册

默认情况下禁用混淆。要启用它,请通过 Gradle DSL 设置以下属性:

compose.desktop {
    application {
        buildTypes.release.proguard {
            obfuscate.set(true)
        }
    }
}

混淆需要根据需要使用了,如果需要控制包体积的话尽量还是打开,可以减小包体积,还会减小代码泄漏的可能性;反之打不打开都行!

实际操作

上面都是理论知识,咱们得实操啊!有基础知识之后实操就很简单了,先来看下 build.gradle.kts 中的配置项吧:

compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "PlayWeather"
            packageVersion = "1.0.0"
            description = "Play Weather App"
            copyright = "© 2022 My Name. All rights reserved."
            vendor = "Lenovo"
            licenseFile.set(project.file("LICENSE.txt"))
            modules("java.instrument", "java.management", "java.naming", "java.sql", "jdk.unsupported")
​
            linux {
                packageVersion = "1.0.0"
                debPackageVersion = "1.0.0"
                rpmPackageVersion = "1.0.0"
                // 设置图标
                iconFile.set(project.file("launcher/icon.png"))
            }
            macOS {
                packageVersion = "1.1.0"
                dmgPackageVersion = "1.1.0"
                pkgPackageVersion = "1.1.0"
                dockName = "PlayWeather"
​
                packageBuildVersion = "1.1.0"
                dmgPackageBuildVersion = "1.1.0"
                pkgPackageBuildVersion = "1.1.0"
                // 设置图标
                iconFile.set(project.file("launcher/icon.icns"))
            }
            windows {
                packageVersion = "1.2.0"
                msiPackageVersion = "1.2.0"
                exePackageVersion = "1.2.0"
                // 设置图标
                iconFile.set(project.file("launcher/icon.ico"))
            }
        }
        buildTypes.release.proguard {
            obfuscate.set(false)
            configurationFiles.from(project.file("proguard-rules.pro"))
        }
    }
}

这里的配置项就不多说了,上面都有过介绍,下面来打包吧!

image.png

点击上图中蓝色箭头标注的进行打包,上面说过了,不能跨系统打包,Mac 只能打 Mac 中使用的包。。双击执行 packageDmg 任务:

image.png

没问题的话大概会出现上图的样子,由于没有配置自定义包路径,所以还在默认文件中,按照上面所描述的路径进行查看:

image.png

复制文件路径,在访达中打开:

image.png

双击进行安装即可:

image.png

然后在资源库中找到应用双击打开应该会遇到下面的错误:

image.png

这时点击取消,然后打开设置 -> 隐私与安全性,往下滑:

image.png

点击箭头标注的“仍要打开”按钮,会让你输入电脑的密码,输入完成后会弹出下面的对话框:

image.png

点击打开,这时应用就能正常使用了。

image.png

苹果端就不打 release 包了,还需要苹果的开发者账号那一大堆。。。目前先能正常在 Mac 中运行吧!

总结

本文从一个新建的 Compose Desktop 项目开始,列举了一些大家在开发过程中可能遇到的一些问题,提前帮助大家踩了踩坑,文中所有代码都在 Github 中,包括文章开始放的这回要编写的天气应用。

Github 地址:github.com/zhujiang521…

如果文中写的有误,欢迎在评论区提出,咱们一起探讨。

文章如果能帮助到大家,哪怕是一点,我也非常高兴,先这样。

本文正在参加「金石计划 . 瓜分6万现金大奖」