这篇文章主要是跟大家分享一下使用Compose Desktop 开发一个桌面应用会遇到那些问题,以及我是如何解决的,顺便推广一下自己的开源项目 《小篆传包》
一、开发思路
公司要维护的App有点多,每个App都要发版,有时候某个应用市场的账号还需要找某个人要验证码才能登录成功,我就想开发一个工具,提升一下工作效率。 于是就开发了《小篆传包》这个工具
软件的基本功能:
- 查询应用审核状态
- 提交新版本到应用市场
界面截图
二、软件设计
1. 原型图
2. 核心代码:
1. ChannelTask
定义了:
- 需要什么参数,比如AppKey,AppSecret
- 获取APP应用市场状态
- 执行上传Apk操作的功能
部分代码示例
abstract class ChannelTask {
/**
* 声明需要的参数
*/
protected abstract val paramDefine: List<Param>
/**
* 执行上传操作
*/
@Throws
abstract suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit)
/**
* 获取APP应用市场状态
* @param applicationId 包名
*/
@Throws
abstract suspend fun getMarketState(applicationId: String): MarketInfo
}
2. MockChannelTask
模拟实现了ChannelTask,用来手动修改功能,进行测试
class MockChannelTask(
override val channelName: String,
override val fileNameIdentify: String
) : ChannelTask() {
override val paramDefine: List<Param> = listOf(
Param("AppId"),
Param("AppKey"),
)
override fun init(params: Map<Param, String?>) {
}
override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
AppLogger.info(LOG_TAG, "Mock ${channelName},开始上传")
repeat(100) {
delay(30)
progress(it)
}
throw ApiException(400, "获取token", "请检测api key")
AppLogger.info(LOG_TAG, "Mock ${channelName},上传完成")
}
override suspend fun getMarketState(applicationId: String): MarketInfo {
delay(1000)
throw ApiException(400, "获取token", "请检测api key")
return MarketInfo(ReviewState.Online, lastVersionCode = 100, lastVersionName = "1.1.0")
}
companion object {
private const val LOG_TAG = "模拟上传"
}
}
3. TaskLauncher
用来启动一个ChannelTask,把执行状态保存到属性中,供UI监听,用来在ViewModel中执行 部分代码示例
class TaskLauncher(private val task: ChannelTask) {
private val marketState: MutableState<MarketState?> = mutableStateOf(null)
suspend fun loadMarketState(applicationId: String) {
initParams()
val tag = task.channelName
val action = "获取应用市场状态:$applicationId"
AppLogger.info(tag, "${action}开始")
marketState.value = MarketState.Loading
marketState.value = try {
val info = task.getMarketState(applicationId)
AppLogger.info(tag, "${action}成功,${info}")
AppLogger.debug(tag, "${action}成功")
MarketState.Success(info)
} catch (e: Throwable) {
AppLogger.error(tag, "${action}失败")
MarketState.Error(e)
}
}
}
4. ChannelRegistry
功能是,持有所有的ChannelTask,并可以替换成MockTask
private const val DEBUG_TASK = false
object ChannelRegistry {
private val realChannels: List<ChannelTask> = listOf(
HuaweiChannelTask(),
MiChannelTask(),
OPPOChannelTask(),
VIVOChannelTask(),
HonorChannelTask()
)
private val mockChannels: List<ChannelTask> = listOf(
MockChannelTask("华为", "HUAWEI"),
MockChannelTask("小米", "MI"),
MockChannelTask("OPPO", "OPPO"),
MockChannelTask("VIVO", "VIVO"),
)
val channels: List<ChannelTask> = if (DEBUG_TASK && BuildConfig.debug) mockChannels else realChannels
fun getChannel(name: String): ChannelTask? {
return channels.firstOrNull { it.channelName == name }
}
}
5. 下面是华为应用市场的一个示例
5.1 HuaweiChannelTask
实现了ChannelTask,定义了华为应用市场需要什么参数,如果获取应用的审核状态,并如何上传Apk新版本
class HuaweiChannelTask : ChannelTask() {
override val channelName: String = "华为"
override val fileNameIdentify: String = "HUAWEI"
override val paramDefine: List<Param> = listOf(CLIENT_ID, CLIENT_SECRET)
private val connectClient = HuaweiConnectClient()
private var clientId = ""
private var clientSecret = ""
override fun init(params: Map<Param, String?>) {
clientId = params[CLIENT_ID] ?: ""
clientSecret = params[CLIENT_SECRET] ?: ""
}
override suspend fun performUpload(file: File, apkInfo: ApkInfo, updateDesc: String, progress: (Int) -> Unit) {
connectClient.uploadApk(file, apkInfo, clientId, clientSecret, updateDesc) {
progress((it * 100).roundToInt())
}
}
override suspend fun getMarketState(applicationId: String): MarketInfo {
val appInfo = connectClient.getAppInfo(clientId, clientSecret, applicationId)
AppLogger.info(channelName, "应用市场状态:${appInfo}")
return appInfo.toAppState()
}
companion object {
private val CLIENT_ID = Param("client_id", desc = "客户端ID")
private val CLIENT_SECRET = Param("client_secret", desc = "秘钥")
}
}
5.2 HuaweiConnectClient
华为应用市场的Api非常复杂,而且步骤比较多,这个类,对Api功能进行封装,把api接口进行了组合,变得易于使用
部分代码示例
class HuaweiConnectClient {
private val connectApi = HuaweiConnectApi()
/**
* @param file apk文件
* @param clientId 接口参数
* @param clientSecret 接口参数
* @param updateDesc 更新描述
* @param progressChange 上传进度回调
*/
@Throws
suspend fun uploadApk(
file: File,
apkInfo: ApkInfo,
clientId: String,
clientSecret: String,
updateDesc: String,
progressChange: ProgressChange
): Unit = AppLogger.action(LOG_TAG, "提交新版本") {
val rawToken = getToken(clientId, clientSecret)
val token = "Bearer $rawToken"
val appId = getAppId(clientId, token, apkInfo.applicationId)
val uploadUrl = getUploadUrl(clientId, token, appId, file)
uploadFile(file, uploadUrl, progressChange)
val bindResult = bindApk(clientId, token, appId, file, uploadUrl)
waitApkReady(clientId, token, appId, bindResult)
modifyUpdateDesc(clientId, token, appId, updateDesc)
submit(clientId, token, appId)
}
}
5.3 HuaweiConnectApi
这个类是Retrofit interface,定义了所有需要使用的api接口
部分代码:
fun HuaweiConnectApi(): HuaweiConnectApi {
return RetrofitFactory.create("https://connect-api.cloud.huawei.com/")
}
/**
* 华为提供的Api
* https://developer.huawei.com/consumer/cn/doc/AppGallery-connect-Guides/agcapi-getstarted-0000001111845114
*/
interface HuaweiConnectApi {
/**
* 获取token
*/
@POST("api/oauth2/v1/token")
suspend fun getToken(
@Body params: HWTokenParams
): HWTokenResp
/**
* 通过包名获取AppId
*/
@GET("api/publish/v2/appid-list")
suspend fun getAppId(
@Header("client_id") clientId: String,
@Header("Authorization") token: String,
@Query("packageName") packageName: String,
): HWAppIdResp
}
三、遇到的问题
1. 如何生成BuildConfig
compose 并不能像AGP插件提供的自动生成模块的BuildConfig.class 的功能 我的方法是构建时生成一个BuildConfig.json,合并到jar包中,然后运行时读取这个json文件
- 在build.gradle 中添加下面的代码
// processResources 这个task被jar task所依赖,有时候直接run可能未正确执行,可以clean一下
tasks.named("processResources") {
doLast {
val dir = outputs.files.first()
val file = File(dir, "BuildConfig.json")
val tasks = gradle.taskGraph.allTasks
val release = tasks.any { it.name.startsWith("package") }
writeBuildConfig(file, release)
}
}
/**
* 生成BuildConfig配置文件
*/
fun writeBuildConfig(file: File, release: Boolean) {
val type = if (release) "release" else "debug"
println("Write $type BuildConfig.json to ${file.absolutePath}")
val name = if (release) appName else "${appName}(测试)"
val buildConfig = BuildConfig(
versionCode = appVersion.versionCode.toLong(),
versionName = appVersion.versionName,
packageId = packageId,
appName = name,
release = release
)
file.writeText(buildConfig.toJson())
}
- 在工程的
buildSrc中添加BuildConfig类
data class BuildConfig(
// 版本号
val versionCode: Long,
// 版本名
val versionName: String,
// 安装包的唯一id
val packageId: String,
// app的名称
val appName: String,
// 是否是release包
val release: Boolean
) {
fun toJson(): String {
return Gson().toJson(this)
}
}
- 需要在
buildSrc的build.gradle.kts中引入Gson依赖
implementation("com.google.code.gson:gson:2.8.6")
- 在工程中读取配置
import androidx.compose.ui.res.useResource
import com.google.gson.Gson
import com.google.gson.JsonObject
object BuildConfig {
private val config = loadBuildConfig()
val debug: Boolean = !config.get("release").asBoolean
val versionCode: Long = config.get("versionCode").asLong
val versionName: String = config.get("versionName").asString
val packageId: String = config.get("packageId").asString
val appName: String = config.get("appName").asString
fun print() {
AppLogger.info("BuildConfig", "构建配置:$config")
}
}
private fun loadBuildConfig(): JsonObject {
return useResource("BuildConfig.json") {
Gson().fromJson(it.reader(), JsonObject::class.java)
}
}
2. 使用默认窗口设置,像素漂移的问题
这个问题只会在windows上出现,mac上正常,挺诡异的。
现象
就是点击某个按钮的时候,整个界面会出现忽然向上或向下抖动1,2个像素,录屏也不太能看清这种情况,而且这里也没有办法发视频,我就不贴视频了。
解决方案:
不使用默认的窗口状态
Window(
title = BuildConfig.appName,
icon = painterResource(BuildConfig.ICON),
resizable = false,
transparent = false, // 重点
undecorated = true, // 重点
state = rememberWindowState(
width = 1280.dp, height = 960.dp,
position = WindowPosition(Alignment.Center)
),
onCloseRequest = {
exitDialog = true
}
)
然后就没有这一条了,
那就得自己去实现一条了,解决办法也比较简单
@Composable
private fun FrameWindowScope.TopBar(closeClick: () -> Unit) {
WindowDraggableArea {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.height(40.dp)
.background(AppColors.auxiliary)
) {
Spacer(modifier = Modifier.width(20.dp))
Image(
painterResource(BuildConfig.ICON),
contentDescription = null,
modifier = Modifier.size(26.dp)
.clip(AppShapes.roundButton)
)
Spacer(modifier = Modifier.width(12.dp))
Text(BuildConfig.appName, fontSize = 14.sp, color = AppColors.fontBlack)
Spacer(modifier = Modifier.weight(1f))
configForWindows()
ImageButton("window_mini.png", 20.dp) {
minimized()
}
ImageButton("window_close.png", 14.dp, closeClick)
}
}
}
自己实现了一个更漂亮了顶部栏,并且支持鼠标按住拖动
又出来了一个新的问题,经过测试发现了,在windows上窗口最小化没有动画,在mac上是正常的,可以参考下面这个链接的方式进行解决,有一些你可能认为不必要的代码步骤,可能违反直觉,实际也需要加上,亲测有效
3. 打包-Windows
1. 生成Windows 安装包无法覆盖安装的问题
在Windows 支持两种安装包格式,msi和exe,但问题是在Windows 并没有像安卓的applicationId 或者是iOS 的bundleId 一样的东西,有个类似的upgradeUuid,但是即使配置了这个,还是无法覆盖安装,必须先卸载之前的,才能安装新版本。
最后不得已使用了 TargetFormat.AppImage绿色版,免安装包。
但是呢,Mac 下配置 TargetFormat.AppImage 会报错,最终的方案代码判断了一下
compose.desktop {
application {
mainClass = "Main"
nativeDistributions {
// 这么写,是因为在Mac上,如果存在AppImage类型会报错
if (isWindows()) {
targetFormats(TargetFormat.AppImage)
} else {
targetFormats(TargetFormat.Dmg)
}
}
...
}
}
/**
* 当前系统是不是windows
*/
fun isWindows(): Boolean {
return System.getProperty("os.name")
.lowercase(Locale.getDefault())
.contains("win")
}
2. 提示 "JVM 启动失败的问题"
打包后,双击exe启动,会提示"Failed to launch JVM"
这个问题的原因是jvm module 少配置了,导致启动报错了
解决方案1:
在build.gradle.kts 中配置
compose.desktop {
application {
mainClass = "Main"
nativeDistributions {
// 设置包含所有的jvm模块
includeAllModules = true
}
...
}
}
不过这么做,虽然可以解决问题,但是会导致打包出来的文件很大,经过测试我这个101MB
解决方案2: 执行下面的命令,会输出需要依赖的modle
./gradlew suggestRuntimeModules
可以得到下面的输出
> Task :suggestRuntimeModules
Suggested runtime modules to include:
modules("java.instrument", "java.naming", "java.sql", "jdk.unsupported")
那我们修改一下build.gradle.kts
compose.desktop {
application {
mainClass = "Main"
nativeDistributions {
// includeAllModules = true
modules("java.instrument", "java.naming", "java.sql", "jdk.unsupported")
}
...
}
}
当然也可以使用proguard 来进一步优化安装包体积,但是我这是个开源项目,不需要考虑代码的安全性问题,而且为了方便排查错误,所以我就没有开启混淆设置。
4. 应用图标问题
1. 如何制作一个图标
我的办法是使用 "即时设计"这个软件来操作 对于软件的操作和一些UI设计的知识,推荐一下B站“梅干菜超人老师”的视频教程
这个图标的制作技巧:
- 苹果圆角的使用
- 多重渐变
- 特殊字体
2. Windows 的图标
Windows 的图标比较简单,使用png格式直接制作
然后在下面这个网站制作成windows需要的格式ico
3. Mac 的图标
Mac的图标格式是icns,我使用了脚本来生成
#!/usr/bin/env bash
set -e
#创建苹果启动图
rm -rf tmp.iconset
mkdir tmp.iconset
# 注意mac-icon.png 是有内边距的,需符合苹果的设计规范
sips -z 16 16 mac-icon.png --out tmp.iconset/icon_16x16.png
sips -z 32 32 mac-icon.png --out tmp.iconset/icon_16x16@2x.png
sips -z 32 32 mac-icon.png --out tmp.iconset/icon_32x32.png
sips -z 64 64 mac-icon.png --out tmp.iconset/icon_32x32@2x.png
sips -z 128 128 mac-icon.png --out tmp.iconset/icon_128x128.png
sips -z 256 256 mac-icon.png --out tmp.iconset/icon_128x128@2x.png
sips -z 256 256 mac-icon.png --out tmp.iconset/icon_256x256.png
sips -z 512 512 mac-icon.png --out tmp.iconset/icon_256x256@2x.png
sips -z 512 512 mac-icon.png --out tmp.iconset/icon_512x512.png
sips -z 1024 1024 mac-icon.png --out tmp.iconset/icon_512x512@2x.png
iconutil -c icns tmp.iconset -o icon.icns
rm -rf tmp.iconset
echo "已生成icon.icns"
需要注意的是苹果icon的原始图标,需要用一定的办法处理,不然会比其他的图标看起来大一些
这是苹果官网提供的设计素材的截图,可以看到,整个方框是完整图片,中间的白色才是真实图标的大小。
四、 我的想法
使用compose desktop 写完这个项目后,我的一些感悟
- compose desktop 有不少bug,而且文档和第三方库也比较少
- compose 构建UI的方案和传统xml的方式很大的区别,需要适应,我觉得开发效率并不高,因为compose desktop 的预览功能真的很难用,而且你需要将mock的对象传入Compose 方法,才能预览出来,复杂对象的构建是比较麻烦的,感觉不如xml直观
- navigation的导航方式,不像activity栈导航那样,进入到新的Activity,上一个Activity的View其实都在内存中的,而navigation 在页面跳转后,会把上一个页面的UI的内存全部清空,你使用remember临时保存的数据也没有了,这个是比较烦人的
- kotlin 或者java 写的桌面应用,会内置jre,打包基本上就快100MB,而
Tauri才10MB,所以我很想使用Tauri重新一下这个项目
大概就是这些了,如果觉得对你有帮助,请到GitHub上帮我点一下star,非常感谢 《小篆传包》