前语

由于方针的改动,现在的App必需求经过存案才能上架运用商店,存案需求获取签名的md5modules,刚开端都是在运用jadx这款东西来获取,后来在运用中发现,他会先把apk解析出来,当我点击Apk signature时才开端签名的校验,进程过于繁琐,而且解析apk还需求时刻。后来就想着能不能自己做一款桌面端东西出来,将我想要的功用都集成进去呢。

说干就干,由于自己是Android开发,寻觅解决方案时发现了compose-multiplatform,由于工作繁忙,没有学习过compose,可是对compose又十分感兴趣,就想着借着这次时机好好的学一学,所以,AndroidToolKit就诞生了。

功用一览

AndroidToolKit是支撑windows和mac的,而且支撑深色和淡色形式,下面的截图都是在淡色形式下。

签名信息

该东西的主功用,也是自己最常用的功用之一。

运用Compose Desktop开发一款适用于安卓开发的桌面东西

上传APK文件后运用ApkVerifier进行签名校验,并拿到X509Certificate,从中获取到modules、md5、sha-1、sha-256等信息。

当然,图中能够看到是支撑上传签名文件的,运用KeyStore获取签名的证书,并将获取到的证书转成X509Certificate类型,后续的信息获取就与上面共同了(当上传签名文件时是需求输入签名暗码的)。

运用Compose Desktop开发一款适用于安卓开发的桌面东西

APK信息

运用aapt东西解析apk的AndroidManifest.xml文件,提取部分信息,这个没什么好说的,网上一大堆教程。支撑自定义aapt,内置的也有,能够直接用。指令如下:

aapt dump badging 文件路径
运用Compose Desktop开发一款适用于安卓开发的桌面东西

APK签名

望文生义,对单个APK进行签名,运用的是ApkSigner,与ApkVerifier在同一个包中。大概用法如下:

val signerBuild = ApkSigner.Builder()
val apkSigner = signerBuild
                ...
                .build()
apkSigner.sign() // 开端签名
运用Compose Desktop开发一款适用于安卓开发的桌面东西

签名生成

现在的最终一个功用(后续还会持续更新,添加新功用)。运用keytool东西生成签名,用的也是指令的方式,支撑自定义keytool,支撑挑选方针密钥类型。这个我们应该都很熟悉,详细指令如下

keytool -genkeypair -keyalg RSA
		-keystore 输出签名路径
		-storepass 密钥暗码
		-alias 密钥别号
		-keypass 别号暗码(当指定方针密钥类型为PKCS12时,-keypass的值会被疏忽,别号暗码将与-storepass保持共同)
		-validity 有效期,单位:天
		-dname CN=?,OU=?,O=?,L=?,S=?, C=? 依次对应作者名称、组织单位、组织、城市、省份、国家编码
		-deststoretype 方针密钥类型(JKS/PKCS12)
		-keysize 密钥巨细(1024/2048)
运用Compose Desktop开发一款适用于安卓开发的桌面东西

开发

下面说一说开发进程吧,由于边做边学的原因,进展很慢,做了好几个月。参阅的大部分文档是compose-multiplatformcompsoe

文件拖拽

本运用是支撑文件拖拽的,就不演示,我们懂得都懂。运用的是官方的API,详细代码如下:

    var isDragging by remember { mutableStateOf(false) }
    Box(
        modifier = modifier.padding(6.dp).onExternalDrag(
            onDragStart = { isDragging = true },
            onDragExit = { isDragging = false },
            onDrop = { state ->
                val dragData = state.dragData
                if (dragData is DragData.FilesList) {
                    dragData.readFiles().first().let {
                        if (it.endsWith(".apk")) {
                            val path = File(URI.create(it)).path
                            // 逻辑处理
                        } else if (it.endsWith(".jks") || it.endsWith(".keystore")) {
                            val path = File(URI.create(it)).path
                            // 逻辑处理
                        } else {
                        }
                    }
                }
                isDragging = false
            }),
        contentAlignment = Alignment.TopCenter
    )

能够用isDragging标识判别当时有没有选中文件拖拽到窗口的正上方,来做一些UI的调整。onExternalDrag现在是在实验期。

文件挑选

详细办法如下:

/**
 * 显示文件挑选器
 * @param isApk 是APK仍是签名
 * @param isAll 可选APK或签名
 * @param onFileSelected 挑选回调
 */
fun showFileSelector(
    isApk: Boolean = true,
    isAll: Boolean = false,
    onFileSelected: (String) -> Unit
) {
    val fileDialog = FileDialog(ComposeWindow())
    fileDialog.isMultipleMode = false
    fileDialog.setFilenameFilter { file, name ->
        val sourceFile = File(file, name)
        sourceFile.isFile && if (isAll) {
            sourceFile.name.endsWith(".apk") || sourceFile.name.endsWith(".keystore") || sourceFile.name.endsWith(".jks")
        } else {
            if (isApk) sourceFile.name.endsWith(".apk") else (sourceFile.name.endsWith(".keystore") || sourceFile.name.endsWith(".jks"))
        }
    }
    fileDialog.isVisible = true
    val directory = fileDialog.directory
    val file = fileDialog.file
    if (directory != null && file != null) {
        onFileSelected("$directory$file")
    }
}

至于文件夹挑选,FileDialog是不支撑的,可是在mac端能够经过apple.awt.fileDialogForDirectories来使FileDialog挑选文件夹。用法如下:

/**
 * 显示文件夹挑选器
 * @param onFolderSelected 挑选回调
 */
fun showFolderSelector(
    onFolderSelected: (String) -> Unit
) {
    System.setProperty("apple.awt.fileDialogForDirectories", "true")
    val fileDialog = FileDialog(ComposeWindow())
    fileDialog.isMultipleMode = false
    fileDialog.isVisible = true
    val directory = fileDialog.directory
    val file = fileDialog.file
    if (directory != null && file != null) {
        onFolderSelected("$directory$file")
    }
    System.setProperty("apple.awt.fileDialogForDirectories", "false")
}

compose-multiplatform-file-picker就不多说了,我们能够看他自己的文档,里边说的都很详细。

数据库

运用sqldelight方案对数据进行保存,他是支撑Android、Native、JVM、JS等客户端的,挑选他的原因也是在官方示例demo内看到大部分项目都是用的此方案,详细运用下来仍是很便利的。运用办法:

引进依靠

plugins {
  id("app.cash.sqldelight") version "2.0.1"
}
repositories {
  google()
  mavenCentral()
}
sqldelight {
    databases {
        create("ToolsKitDatabase") {
            packageName.set("kit")
        }
    }
}
kotlin {
    jvm("desktop")
    sourceSets {
        val desktopMain by getting
        commonMain.dependencies {
            ...
            implementation(libs.sqlDelight.coroutine)
            implementation(libs.sqlDelight.runtime)
            implementation(libs.slf4j.api)
            implementation(libs.slf4j.simple)
        }
        desktopMain.dependencies {
            ...
            implementation(libs.sqlDelight.driver)
        }
    }
}

创立sq文件

目录:commonMain/sqldelight/kit/Config.sq

import kotlin.Boolean;
CREATE TABLE IF NOT EXISTS Config (
   id INTEGER NOT NULL PRIMARY KEY,
   dark_mode INTEGER NOT NULL,
   aapt_path TEXT NOT NULL,
   flag_delete INTEGER AS Boolean NOT NULL,
   signer_suffix TEXT NOT NULL,
   output_path TEXT NOT NULL,
   is_align_file_size INTEGER AS Boolean NOT NULL,
   keytool_path TEXT NOT NULL DEFAULT '',
   dest_store_type TEXT NOT NULL DEFAULT 'JKS'
);
INSERT INTO Config(id, dark_mode, aapt_path, flag_delete, signer_suffix, output_path, is_align_file_size)
SELECT 0, 0, "", 1, "_sign", "", 1
WHERE (SELECT COUNT(*) FROM Config WHERE id = 0) = 0;
initInternal:
UPDATE Config
SET aapt_path = CASE WHEN aapt_path = '' THEN ? ELSE aapt_path END
WHERE id = 0;
...

实例化驱动程序

actual fun createDriver(): SqlDriver {
    val dbFile = getDatabaseFile()
    return JdbcSqliteDriver(
        url = "jdbc:sqlite:${dbFile.absolutePath}",
        properties = Properties(),
        schema = ToolsKitDatabase.Schema,
        migrateEmptySchema = dbFile.exists(),
    ).also {
        ToolsKitDatabase.Schema.create(it)
    }
}

办法调用

经过dbQuery就能够调用到sq文件中命名的办法,仍是很便利的。

    private val database = createDatabase(createDriver())
    private val dbQuery = database.configQueries
    internal fun initInternal(aapt: String) {
        dbQuery.initInternal(aapt)
    }

迁移

上面的sq文件,Config表中有两个字段keytool_pathdest_store_type为后续晋级数据后添加的。详细晋级办法官方文档中阐明的也很详细。

创立commonMain/sqldelight/migrations/1.sqm文件,在1.sqm中添加迁移语句

ALTER TABLE Config ADD COLUMN keytool_path TEXT NOT NULL DEFAULT '';
ALTER TABLE Config ADD COLUMN dest_store_type TEXT NOT NULL DEFAULT 'JKS';

Json动画

本来打算运用lottie来完成的,后来发现并不支撑多端,后来在官方的Issues中发现能够运用skiko来加载动画。详细用法如下:

引进依靠

val osName: String = System.getProperty("os.name")
val targetOs = when {
    osName == "Mac OS X" -> "macos"
    osName.startsWith("Win") -> "windows"
    osName.startsWith("Linux") -> "linux"
    else -> error("Unsupported OS: $osName")
}
var targetArch = when (val osArch = System.getProperty("os.arch")) {
    "x86_64", "amd64" -> "x64"
    "aarch64" -> "arm64"
    else -> error("Unsupported arch: $osArch")
}
val target = "${targetOs}-${targetArch}"
kotlin {
    sourceSets {
      	...
        desktopMain.dependencies {
          	...
            implementation("org.jetbrains.skiko:skiko-awt-runtime-$target:0.7.9")
        }
    }
}

运用

@OptIn(ExperimentalResourceApi::class)
@Composable
fun LottieAnimation(scope: CoroutineScope, path: String, modifier: Modifier = Modifier) {
    var animation by remember { mutableStateOf<Animation?>(null) }
    scope.launch {
        val json = Res.readBytes(path).decodeToString()
        animation = Animation.makeFromString(json)
    }
    animation?.let { InfiniteAnimation(it, modifier.fillMaxSize()) }
}
@Composable
private fun InfiniteAnimation(animation: Animation, modifier: Modifier) {
    val infiniteTransition = rememberInfiniteTransition()
    val time by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = animation.duration,
        animationSpec = infiniteRepeatable(
            animation = tween((animation.duration * 1000).roundToInt(), easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        )
    )
    val invalidationController = remember { InvalidationController() }
    animation.seekFrameTime(time, invalidationController)
    Canvas(modifier) {
        drawIntoCanvas {
            animation.render(
                canvas = it.nativeCanvas,
                dst = Rect.makeWH(size.width, size.height)
            )
        }
    }
}

调用

LottieAnimation(scope, "files/lottie_main_1.json", modifier)

打包

最终说一下打包吧,用的是github的action完成的,经过./gradlew packageReleaseDistributionForCurrentOS指令就能够将当时环境的release包打出来。部分装备如下:

val kitVersion by extra("1.3.0")
val kitPackageName = "AndroidToolsKit"
val kitDescription = "Desktop tools for Android development, supports Windows and Mac"
val kitCopyright = "Copyright (c) 2024 LazyIonEs"
val kitVendor = "LazyIonEs"
val kitLicenseFile = project.rootProject.file("LICENSE")
compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = kitPackageName
            packageVersion = kitVersion
            description = kitDescription
            copyright = kitCopyright
            vendor = kitVendor
            licenseFile.set(kitLicenseFile)
            modules("jdk.unsupported", "java.sql")
            outputBaseDir.set(project.layout.projectDirectory.dir("output"))
            appResourcesRootDir.set(project.layout.projectDirectory.dir("resources"))
            linux {
                debPackageVersion = packageVersion
                rpmPackageVersion = packageVersion
                iconFile.set(project.file("launcher/icon.png"))
            }
            macOS {
                dmgPackageVersion = packageVersion
                pkgPackageVersion = packageVersion
                packageBuildVersion = packageVersion
                dmgPackageBuildVersion = packageVersion
                pkgPackageBuildVersion = packageVersion
                bundleID = "org.apk.tools"
                dockName = kitPackageName
                iconFile.set(project.file("launcher/icon.icns"))
            }
            windows {
                msiPackageVersion = packageVersion
                exePackageVersion = packageVersion
                menuGroup = packageName
                perUserInstall = true
                shortcut = true
                upgradeUuid = "2B0C6D0B-BEB7-4E64-807E-BEE0F91C7B04"
                iconFile.set(project.file("launcher/icon.ico"))
            }
        }
        buildTypes.release.proguard {
            obfuscate.set(true)
            configurationFiles.from(project.file("compose-desktop.pro"))
        }
    }
}

装备什么的,参阅了从 0 到 1 搞一个 Compose Desktop 版别的气候运用(附源码),感兴趣的能够去看一下。

总结

说实话,第一次运用compose,给了我许多惊喜,当然,对于multiplatform来说,compose-multiplatform现在还并不算完善,可是官方解决问题的速度很快,而且会给到解决方案等,期望compose-multiplatform越来越好。

源码地址

AndroidToolsKit

releases中提供了安装文件,欢迎体验支撑

参阅:

compose-multiplatform

从 0 到 1 搞一个 Compose Desktop 版别的气候运用(附源码)

运用ComposeDesktop开发一款桌面端多功用APK东西

Compose for Desktop桌面端简单的APK东西