Compose Destop 开发一个桌面端的传包工具

292 阅读8分钟

这篇文章主要是跟大家分享一下使用Compose Desktop 开发一个桌面应用会遇到那些问题,以及我是如何解决的,顺便推广一下自己的开源项目 《小篆传包》

一、开发思路

公司要维护的App有点多,每个App都要发版,有时候某个应用市场的账号还需要找某个人要验证码才能登录成功,我就想开发一个工具,提升一下工作效率。 于是就开发了《小篆传包》这个工具

软件的基本功能:

  1. 查询应用审核状态
  2. 提交新版本到应用市场

界面截图

image.png

image.png

二、软件设计

1. 原型图

image.png

2. 核心代码:

1. ChannelTask

定义了:

  1. 需要什么参数,比如AppKey,AppSecret
  2. 获取APP应用市场状态
  3. 执行上传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文件

  1. 在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())
} 


  1. 在工程的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)
    }
} 
  1. 需要在 buildSrcbuild.gradle.kts 中引入Gson依赖
implementation("com.google.code.gson:gson:2.8.6")
  1. 在工程中读取配置
 
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
    }
) 

然后就没有这一条了,

image.png

那就得自己去实现一条了,解决办法也比较简单

@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)
        }
    }
}

自己实现了一个更漂亮了顶部栏,并且支持鼠标按住拖动 image.png

又出来了一个新的问题,经过测试发现了,在windows上窗口最小化没有动画,在mac上是正常的,可以参考下面这个链接的方式进行解决,有一些你可能认为不必要的代码步骤,可能违反直觉,实际也需要加上,亲测有效

github.com/JetBrains/c…

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"

image.png

这个问题的原因是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站“梅干菜超人老师”的视频教程

icon.png

这个图标的制作技巧:

  1. 苹果圆角的使用
  2. 多重渐变
  3. 特殊字体

2. Windows 的图标

Windows 的图标比较简单,使用png格式直接制作 然后在下面这个网站制作成windows需要的格式ico

ico.nyaasu.top/

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的原始图标,需要用一定的办法处理,不然会比其他的图标看起来大一些

这是苹果官网提供的设计素材的截图,可以看到,整个方框是完整图片,中间的白色才是真实图标的大小。 image.png

四、 我的想法

使用compose desktop 写完这个项目后,我的一些感悟

  1. compose desktop 有不少bug,而且文档和第三方库也比较少
  2. compose 构建UI的方案和传统xml的方式很大的区别,需要适应,我觉得开发效率并不高,因为compose desktop 的预览功能真的很难用,而且你需要将mock的对象传入Compose 方法,才能预览出来,复杂对象的构建是比较麻烦的,感觉不如xml直观
  3. navigation的导航方式,不像activity栈导航那样,进入到新的Activity,上一个Activity的View其实都在内存中的,而navigation 在页面跳转后,会把上一个页面的UI的内存全部清空,你使用remember临时保存的数据也没有了,这个是比较烦人的
  4. kotlin 或者java 写的桌面应用,会内置jre,打包基本上就快100MB,而Tauri才10MB,所以我很想使用Tauri重新一下这个项目

大概就是这些了,如果觉得对你有帮助,请到GitHub上帮我点一下star,非常感谢 《小篆传包》