从 0 到 1 搞一个 Compose Desktop 版本的玩天气之踩坑
大家好,好久不见,接下来一段时间我会系统性地写一套关于 Compose Desktop
的文章,带大家从头到尾写一个桌面版的天气应用,并且打好包让别人也可以进行使用,接下来就开始吧!先来看下最终的实现效果吧!
视频
效果是不是挺好?哈哈哈!
其实作为一个安卓开发来说,当运行起第一个桌面版程序的时候内心突然感觉回到了最开始学习编程的时候,那种感觉就好像一个多年未见的老友对你说:久违了!特别是使用的技术还都是安卓开发的技术,只是有一些平台原因需要稍做修改的地方就能开发出一个完整的桌面版软件,内心还是非常激动,非常地有成就感,这种感觉太舒服了!
缘起 Compose
为什么会搞 Compose Desktop
,这还得从 Jetpack Compose
说起: Google
从 2017 年开始立项开搞 Compose
到第一个正式版本用了四年的时间,那么久的时间,投入了那么多的人力,以及后面投入了大量经费宣传,无一不在告诉安卓开发者 Compose
很重要,这也是之后安卓开发的新方向!所以当第一个 alpha
版本的 Compose
出现的时候我就坐不住了,立马加上依赖尝试了下!刚开始写的时候感觉有点奇怪,毕竟从之前的开发模式变为了全新的声明式开发,但写了不到一周就感受到了 Compose
的优势,编写起来太快了,动画实现起来也太简单了,声明式编程也太方便了。。。。
其实 Compose Desktop
出现的也很早, Jetpack Compose
出来没多久它也就出来了,有很多同行在 Compose Desktop
出来第一个 alpha
版本的时候就开始研究,不过由于我是做安卓开发的,日常工作也不会涉及到桌面开发,况且 Compose Desktop
是 Jetbrains
开发的,并不 Google
开发的,毕竟是模仿 Jetpack Compose
的,未来两边的 API
都有可能对应不上,所以也就一直没有关注。
但是到后来转折点来了,公众号:《Android 开发者》在 2022 年 11 月 30 日早上发了一篇文章,名为:”Jetpack 更新一览 | 2022 Android 开发者峰会“,这篇文章的前半部分没有什么新鲜感,因为这些库我也一直在用,也经常关注着新版本和新功能,但看到文章最后的时候,里面有一段话是这么写的:
我们一直在尝试使 Jetbrains 的 Kotlin 多平台移动版支持跨平台共享代码。我们针对 Android 和 iOS 应用推出了实验性的 Collection 预览版和 DataStore 库。期待您的反馈!您可以查看相关博文,了解更多内容。
这话说的,你们一直在尝试, Jetpack Compose
正式版都出来快一年半了! Compose Desktop
正式版也都一年多了!这么久怎么连点信都没有。。。不过好在现在有信了! Google
这也算放出了几个信号,也不知道我的理解是否正确😂:
- 他们也一直在关注
Jetbrains
的Compose Desktop
- 目前已经有两个
Jetpack
的库支持了Compose Desktop
- 后续也会将更多的
Jetpack
中的库支持Compose Desktop
对标 Flutter ?
Flutter
现在已经比较成熟了,它最大的优势就是跨平台, Flutter
虽然宣称的是原生的性能,一套代码多端实现,但其实对于跨平台来说一套代码并不能完全实现需求,肯定需要各种适配,只不过看框架适配的好与坏, Compose Desktop
也是如此,但 Flutter
的性能也只是媲美原生,而 Compose
就是原生啊! Compose Desktop
其实并不是和 Flutter
抢饭碗,它只是告诉广大安卓开发:你们并不需要学习安卓之外的东西就能开发各种设备上的应用!这也是 Kotlin
的辉煌,我个人认为这也是 Jetbrains
公司开发 Compose Desktop
的初衷。
基于上面的分析,打开了 Jetbrains
的 Compose Desktop
的官网:https://www.jetbrains.com/zh-cn/lp/compose-desktop/ ,也开始试着玩一玩桌面版的应用!
初探 Compose Desktop
我本来还想着使用 Android Studio
来使用 Compose Desktop
来着,结果打开 Android Studio
新建项目一看并没有找到创建 Compose Desktop
的入口,后来想想也对, Android Studio
嘛!本来就是为了构建 Android
项目的,并不是为了构建别的东西,对吧!(内心独白:可能是我自己没找到)
那就使用 IntelliJ Idea
来看看吧,点击 New -> Project 就会出现下面的页面:
选择 Kotlin 之后就可以看到右边有
Compose Multiplatform
的选项,里面有三种,第一种就是这段时间要搞的 Compose Desktop
,第二种就是多平台了,里面有 Android
,也有 Compose Desktop
,第三种是 Compose Web
。不得不说太强了!桌面、Web、移动端, Compose
一套搞定!目前 IOS
也支持了,不过这不是咱们要看的重点,还是来看 Compose Desktop
吧!接下来点击 Next ,之后选择配置项之后点击 Finish 后第一个 Compose Desktop
项目就创建好了!
; 项目结构
接下来看下初始项目的结构吧:
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 是这里独有的,其实这块就是对桌面项目的一些属性的配置,可以看到有包名和版本号等信息,这块在这里先不进行过多介绍,因为这块的内容很多, Windows
、 Mac
、 Linux
各个系统的配置都不太相同,在之后的文章中会着重来介绍,这里先跳过。
初始代码
最后来看下 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
可组合项咱们都是非常了解的,这块不太清楚的其实就是 application
和 Window
,因为这两个在之前 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)
}
}
application
是 Compose Desktop
应用程序的入口点,这块需要注意的是:不要在这个函数中使用任何动画(例如, withframamanos
或 animatefloatasstate
等),因为底层的 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
上方运行,但现在看下:
没有了,灰着的!不用担心,不还有 main 函数呢嘛!直接运行 main 函数!
点击运行按钮看下:
直接弹出了一个 Java
程序,里面放着一个按钮,刚才咱们看可组合项 Window
的时候不是可以修改名字嘛,下面修改下看看!
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "天青色等烟雨") {
App()
}
}
改了下名字,第二次运行的时候可以点击 main 函数,也可以点击 Idea
的上方运行按钮了,因为刚才运行的记录已经被保存下来了!
OK,点击运行查看结果:
没错,和想的一样!
到现在为止已经可以使用咱们之前学习的 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
文件夹中,所以这块的路径做了一些修改,再来看下图片的目录吧:
下面来运行看下效果!
OK,没问题,图片展示出来了!又向成功迈进了一步!!!
SVG 图片?
咱们现在在安卓中使用的图片大多改为了 SVG 格式的,体积又小且清晰,接下来按照相同的方式试一下,先放一张 SVG 格式的图片到刚才创建的 image
文件夹中:
图片放好了,下面来修改下图片的路径:
Image(painter = BitmapPainter(useResource("image/ic_launcher.svg", ::loadImageBitmap)),"Test")
再运行下程序!
额。。。刚不是还好好的嘛!这改了个图片格式就不行了?来看下报错信息吧!
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")
使用也很简单,运行看下效果吧:
嗯,没问题,正常展示!为了方便大家在 Compose Desktop
中使用图片,我写了一个构建 Painter
的函数:
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)
)
使用并不难,将图片的网址放进去即可,下面来运行看下效果吧:
还不错,图片显示得也挺快,大家可以试试!
网络请求—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 {
@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 库使用
刚才在上面提到了,我就是因为 Google
说 Jetpack
中的库之后会支持 Compose Desktop
才决定玩一玩 Compose Desktop
的,那肯定也要尝试使用下啊!
Google
目前只推出了两个库支持: Collection
和 DataStore
, Collection
目前使用不到,那就先来看看如何使用 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 。
小结
本文从一个新建的 Compose Desktop
项目开始,列举了一些大家在开发过程中可能遇到的一些问题,提前帮助大家踩了踩坑,文中所有代码都在 Github
中,包括文章开始放的这回要编写的天气应用。
Github
地址:https://github.com/zhujiang521/PlayWeather/tree/desktop
如果文中写的有误,欢迎在评论区提出,咱们一起探讨。
文章如果能帮助到大家,哪怕是一点,我也非常高兴,先这样。
Original: https://blog.csdn.net/haojiagou/article/details/128332097
Author: 朱 江
Title: 从 0 到 1 搞一个 Compose Desktop 版本的玩天气之踩坑
原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/795152/
转载文章受原作者版权保护。转载请注明原作者出处!