使用Compose Multiplatform开发跨平台的Android调试工具

news2025/8/16 15:46:51

背景

最近对CMP跨平台很感兴趣,为了练手,在移动端做了一个Android和IOS共享UI和逻辑代码的天气软件,简单适配了一下双端的深浅主题切换,网络状态监测,刷新调用振动器接口。

做了两年多车机Android开发,偶尔玩下手机端跨平台也蛮有意思。

然后又了解到CMP不仅仅是移动端的,还可以做web和desktop端。

在我们日常的开发过程中,对于车机设备的adb调试操作很多,一大半全是固定的流程。使用bat脚本的话又不那么灵活,体验也不好。所以我很早就想要做一个带界面的Android设备调试工具。在移动端上写纯原生的Compose界面比较熟悉了,想着这个估计也差不多的,就开启了为期一个多月的Compose for Desktop开发。开发体验可以算中上,很多的问题在stackoverflow和官网上都能找到方案。软件命名为DebugManager。

架构设计

我没有开发Desktop端的经验,不知道最优的架构设计是什么样的。使用CMP的话Google推崇的MVI模式依然可以通用,所以最初制定的技术路线就是使用响应式的架构。

由于功能单一,几乎所有操作都是执行一些命令行,获取反馈结果,所以没有抽象的很厉害,数据层直接使用单例类,使用adb工具获取数据透传到StateHolder。StateHolder为界面的状态State管理层,在Composable方法初入时,触发StateHolder的数据获取逻辑,数据拿取到之后,更新State状态,通过界面监听的stateflow通知composable方法刷新UI。

d527c900dcad4310893739a225da66c1.png

事件从上到下,数据状态从下到上,确保唯一可信数据流。

gradle配置

这一步决定DebugManager项目面向的各个平台的配置,软件版本,安装包。

由于这个软件面向不同岗位,不同操作系统,目标是一套代码适配Windows,Linux,MacOS,达到多端通用。而且目前没有交叉编译,只能在各自的系统上打包,windows打exe,ubuntu上打deb,macos上打dmg,所以我现在给使用不同系统的同事发布软件时,都是三端各打一遍。

Windows端有配置是否显示在开始菜单,桌面快捷方式,uuid用于更新识别,自行选择安装目录。

menu = true
shortcut = true
// 可自行选择安装目录
dirChooser = true
// 可单独为当前用户安装,不需要管理员权限
perUserInstall = true
// 设置图标
iconFile.set(project.file("launcher/icon.ico"))
upgradeUuid = "xxxx-xxxxxxx-xxxxx"

1. 更详细的Gradle属性配置参考可以看官方github仓库的教程文档:

compose-multiplatform/tutorials/Native_distributions_and_local_execution/README.md at master · JetBrains/compose-multiplatform · GitHub

2. 关于三个平台应用图标的设置,是参考C上一位大佬的,制作三端的图标文件,大家可以自行搜索配置

目前还发现一个奇怪的bug,就是当我首次配置完,然后过一段时间想再换个应用图标的时候,打包后的安装包大小直接从80M到了2个G,不确定什么原因导致的。

Multiplatform适配

属性配置

Desktop跨平台的第一个难点就是不同平台的路径连接符不一致:

在Windows上是两个反斜杠  \\

在unix like的系统上是一个正斜杠  /

这一点Java给我们提供的 System.getProperty可以用来区分平台类型。

首先,定义一个枚举类来设定平台类型:

enum class PlatformType {
    UNKNOWN,
    WINDOWS,
    MAC,
    LINUX,
}

在应用初始化时,通过接口获取平台名称,解析出哪一个平台:

 /**
     * 获取当前平台类型
     */
    private fun getPlatformType(): PlatformType {
        val osName = System.getProperty("os.name").lowercase(Locale.getDefault())
        return when {
            osName.contains("win") -> PlatformType.WINDOWS
            osName.contains("mac") -> PlatformType.MAC
            osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> PlatformType.LINUX
            else -> PlatformType.UNKNOWN
        }
    }

后面在涉及平台差分化的时候,可以使用此方法来获取,执行不同操作。

比如路径拼接时的符号:

    // 路径分隔符
    val dp =
        when (getPlatformType()) {
            PlatformType.WINDOWS, PlatformType.UNKNOWN -> "\\"
            PlatformType.MAC, PlatformType.LINUX -> "/"
        }

打开不同平台上的文件管理器:

    fun openFolder(path: String) {
        when (getPlatformType()) {
            PlatformType.WINDOWS, PlatformType.UNKNOWN -> {
                executeTerminalCommand("explorer.exe $path")
            }

            PlatformType.MAC -> {
                executeTerminalCommand("open $path")
            }

            PlatformType.LINUX -> {
                executeTerminalCommand("xdg-open $path")
            }
        }
    }

对于各个平台上执行终端命令,使用的两个方法是相同的,无需结果就直接exec(),需要执行结果就是用ProcessBuilder来执行,等待结果。

    /**
     * 执行终端命令
     */
    fun executeTerminalCommand(command: String) {
        runCatching {
            Runtime.getRuntime().exec(command)
        }.onFailure { e ->
            LogUtils.printLog("执行出错:${e.message}", LogUtils.LogLevel.ERROR)
        }
    }

    /**
     * 执行命令,获取输出
     */
    suspend fun executeCommandWithResult(command: String) = withContext(Dispatchers.IO) {
        val processBuilder = ProcessBuilder(*command.split(" ").toTypedArray())
        val process = processBuilder.start()

        val reader = BufferedReader(InputStreamReader(process.inputStream))
        val output = StringBuilder()
        var line: String?
        while (reader.readLine().also { line = it } != null) {
            output.append(line).append("\n")
        }
        // 等待进程结束
        process.waitFor()
        // 关闭输入流
        reader.close()
        output.toString()
    }

窗口框架

新项目的应用入口如下:

fun main() = application {

    Window(
        onCloseRequest = {

        },
        title = "DebugManager",
        undecorated = true,
        state = windowState,
        icon = painterResource("image/icon.png"),
    ) {
       ....
    }
}

我们主要的内容区就在Window这个Composable方法里。

通过windowState,我们可以设置窗口初始大小,窗口最大最小化。

undecorated参数,这个可以配置软件界面是否选择系统默认的标题栏。我希望在三端上都使用我自定义的标题栏,所以设置false。

有意思的一点是,上面这个参数如果设置true就是系统默认的标题栏,我们可以使用鼠标拖动标题栏来移动窗口。最开始设为false后,我发现自定义的标题栏无法使用鼠标拖动了,一度试了很多方案都不行,最后还是 GeminiAI 展示了一个Composable方法,居然直接套用即可,里面的区域就是支持拖动移动的。把标题栏的Composable方法放在这个WindowDraggableArea里面,就可以鼠标拖动标题栏来移动窗口了。

源码的方法声明如下:

@androidx.compose.runtime.Composable
@androidx.compose.runtime.ComposableInferredTarget
public fun androidx.compose.ui.window.WindowScope.WindowDraggableArea(
    modifier: androidx.compose.ui.Modifier = COMPILED_CODE,
    content: @androidx.compose.runtime.Composable () -> kotlin.Unit = COMPILED_CODE
): kotlin.Unit { /* compiled code */
}

由于各个页面之间的关联不大,无需导航传参,所以我没有用官方的navigation组件,直接在切换tab时切换对应区域的Composable函数。

功能划分

下面简单介绍下各个页面的调试功能,一般的开发流程里有产品设计,有交互设计,UI设计,给我传达需求,输出资源。

1. 功能设计上,这个软件自己心血来潮要做,只能自己设计了,中间结合日常工作中的调试痛点,还参考了adb的命令介绍,选取了一些组合功能和单次功能,分类添加到了界面内。

2. 在界面UI设计风格上,我是直接参考了每天打开的AndroidStudio里的主题插件,Atom One Dark的颜色风格。

设备信息展示

首页当然是所连接设备的基本信息展示。

2236398f71b94b6897201e338beb1e73.png

定义UiState

data class DeviceState(
    val name: String? = null,
    val manufacturer: String? = null,
    val sdkVersion: String? = null,
    val systemVersion: String? = null,
    val buildType: String? = null,
    val innerName: String? = null,
    val resolution: String? = null,
    val density: String? = null,
    val cpuArch: String? = null,
    val serial:String? = null,
    val isConnected: Boolean = false
) {
    fun toUiState() =
        DeviceState(
            name = name,
            systemVersion = systemVersion,
            manufacturer = manufacturer,
            sdkVersion = sdkVersion,
            buildType = buildType,
            innerName = innerName,
            resolution = resolution,
            cpuArch = cpuArch,
            density = density,
            serial = serial,
            isConnected = isConnected
        )
}

定义好界面所需要展示的字段,再在StateHolder里维护一个StateFlow,同时对界面层暴露一个只读的字段,用于刷新界面数据。

 // 单个设备信息
    private val _deviceState = MutableStateFlow(DeviceState())
    val deviceStateStateFlow = _deviceState.asStateFlow()

进来界面后,在协程中获取数据,界面拿到update后的数据之后自动更新信息:

   CoroutineScope(Dispatchers.IO).launch {
                prepareEnv()
                val deviceName = .....

                _deviceState.update {
                    it.copy(
                        name = deviceName,
                        manufacturer = manufacturer,
                        sdkVersion = sdkVersion,
                        systemVersion = systemVersion,
                        buildType = buildType,
                        density = displayDensity,
                        innerName = innerName,
                        resolution = displayResolution,
                        cpuArch = architecture,
                        serial = serialNum
                    )
                }
                _deviceState.value = _deviceState.value.toUiState()
                // 初始化获取文件列表
                getFileList()
            }

右侧的一堆按钮,是一些高频使用的功能。

简单的像reboot,root等,还有使用am打开隐藏app的界面,使用perfetto抓取trace,自动拉取到电脑端。

其中执行qnx命令为车机特有,现在市面上车机Android大多是运行在QNX系统上的子系统,DebugManager还可以直接桥接到QNX系统,执行更底层更精准的命令,比如执行reset重启整个IVI系统,而不只是reboot重启Android子系统。

录屏,截屏很实用,不用掏出手机到处找角度。我们提前设置好时长,通过自动执行多条指令,等操作完毕,可以直接将截屏录屏文件导出到电脑进行分享,也是我认为最好用的功能之一。

最下面还有一些基础的音量加减,模拟输入法输入等。

轮询查询机制

值得一提的是,我加入了循环获取连接设备数量和当前连接状态的机制,当电脑端的adb服务一初始化成功,我就开启一个死循环的协程,里面每2s会查询两个状态。

 private fun recycleCheckConnection() {
        CoroutineScope(Dispatchers.IO).launch {
            while (true) {
                delay(2000L)
                runCatching {
                    // 通过系统命令,检索连接设备的数量是否变化
                    val deviceCount = ....
                    if (deviceCount != _deviceMapState.value.deviceMap.size) {
                        getDeviceMap()
                        MainScope().launch {
                            delay(800L)
                            getCurrentDeviceInfo()
                        }
                    }

                    // 检索当前设备连接状态
                    val result = ....
                    // 从断开到成功连接,主动刷新一次设备信息
                    if (!isConnected) {
                        getCurrentDeviceInfo()
                    }
                    isConnected = true
                    _deviceState.update {
                        it.copy(
                            isConnected = true,
                        )
                    }
                    _deviceState.value = _deviceState.value.toUiState()
                }.onFailure { error ->
                    LogUtils.printLog("${error.message}", LogUtils.LogLevel.ERROR)
                    isConnected = false
                    _deviceState.update {
                        it.copy(
                            isConnected = false,
                        )
                    }
                    _deviceState.value = _deviceState.value.toUiState()
                }
            }
        }
    }

1. 当增减设备时,刷新设备列表,左上角展开后可以选择不同的设备进行调试。

2. 当现在操作的设备断开连接时,会自动切换成其他设备,如果没有其他设备,就弹出警告弹窗,不允许继续操作页面了。

这两个都是轮询的。所以在重新连接设备后,会将当前状态通过state发送到界面,警告弹窗会自动消失。

软件安装管理

这个功能是耗时最长的板块之一,主要是Android系统里面每个包的信息如何展示,如何进一步对其进行替换,收集了很多指令。APP列表加入了全部包扫描和三方包扫描,对于公司定制的包,也添加到了单独的筛选规则,可以自由选择查看全量信息和精简信息。

199b2b0fcd574de1ae2e63524a054f0b.png

最上面是安装功能,是使用adb install进行的操作,适合第三方app进行验证时,或者改bug进行非正式环境的验证时使用。下拉框展开后,可以选择覆盖安装,测试安装等,对应-r,-t等带参数的install操作。

界面展示了app的图标,版本号,包名,更新时间等。

应用图标怎么拿到的?

网上大多数的方案是说抠出apk,使用apktool解包,找到图标文件,再拿来显示。可行的确可行,但是这个速度要等到天荒地老了。

因为我之前做过一个Android端的简单的app管理应用,我选择的路线是,提前在AndroidStudio里开发一个服务app,里面设置一个Service,启动后扫描所有的已安装的app,将应用图标,应用label,包名都存到Android本地。再将这个apk内置到DebugManager安装目录的resources目录下,将其安装进系统,准备好资源后,通过adb pull拉出所需要的资源到电脑端,再读取png文件来显示到界面上。

单个app的操作

0ebe1c56741f4343a5a4345b6c702ffa.png

对于选中的单个app,提供了打开应用界面,卸载,提取apk,对于系统应用,还可以push替换apk等操作。我们的测试同事在做非全量的发版验证时非常有用,不用再使用一条条繁琐的命令来替换apk升级了。

文件管理器

由于我在Android端也没有写过文件管理器应用,所以在这个页面,有些操作也是一拍脑袋想出来的,可能不算规范的解法。仍然是MVI架构,界面去监听StateHolder里面的UiState的Flow,切换目录时重新获取列表数据,update到界面来刷新UI。

a4bc2fcd7080449da070d5345929e763.png

最开始的展示列表我是直接执行了"ls /"将列表发送到界面,显示根目录,解析出其中的文件文件夹,继续往子目录的话就把路径拼接起来,比如进入sdcard,就执行"ls /sdcard",继续深入则再次拼接。同时最上方设置了返回上级,回到根目录和priv-app快捷按钮。

展示文件列表的就是@Composable LazyComuln方法。

有意思的是,我在加入item的双击和单击的区分时,最初想给Modifier定义一个扩展方法,直接实现双击回调。但是发现必须经过clickable方法来实现,这样会把外部的单机的clickable给挤掉。所以双击判断还是写在了同一个clickable里面,通过时间间隔判断的工具类来区分,单击则选中对应的文件/文件夹,双击则进入文件夹。

modifier = Modifier.clickable {
    // 点击则设置即将操作的path
    MainStateHolder.setSelectedFilePath(it.path)
    androidSelectedFile = MainStateHolder.selectedFilePath
    // 双击,执行操作
    if (DoubleClickUtils.isFastDoubleClick()) {
        if (it.isDirectory)
            destinationCall(it.path)
        else
            println("点击文件:${it.path}")
    }
}

android内的文件操作也是使用命令行的形式,cp mv rm等。

还可以将文件pull到电脑端,将电脑端的文件推送到Android端等。

命令模式

这一页比较简单,大家看到的输入框也是Compose原生的TextField方法,还自带动画,性价比蛮高。

主要实现就是将输入框的内容,拼接后直接通过Runtime.getRuntime().exec(command)执行即可。

除了最基础的adb命令透传,配合系统厂商Android端的可执行二进制程序,可以模拟车载信号的回调操作。还有语音部门的通过广播来调试的路径,整合到了DebugManager里面,一键发送广播,模拟可见扫描的点击。

c493d4877e8847dbb79a0daf84863a76.png

关于页

最后就是关于页了,显示软件版本,缓存文件目录等。通过PlatformAdapter工具类获取路径,执行打开界面即可。

2e9e471d6c754833b23924e3c2a288fc.png

开源计划

这个软件最初是基于公司业务来设计开发的,有关于公司内部的信息需要抹除。

等后续有时间我会将其功能进行略微删减,改成通用性质的Android调试工具之后,会开源到Github。对CMP跨平台感兴趣的朋友,可以加关注稍作等待,后面一起进行技术交流。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2249461.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

[MRCTF2020]Transform

查壳,拖入64位IDA LOBYTE8位就是一个字节,在此处无意义,因为我们输入的本来就是按字节输入的 设 a byte_414040,bdword_40F040,cbyte_40F0E0,输入的字符串为flag; 从题目里得到 加密代码 a[i] flag[b[i]]; a[i] ^ b[i]; c a 即c[i] a[i…

podman 源码 5.3.1编译

1. 构建环境 在麒麟V10服务器操作系统上构建:Kylin-Server-V10-GFB-Release-2204-Build03-ARM64.iso。由于只是编译 podman 源码,没必要特地在物理机或服务上安装一个这样的操作系统,故采用在虚拟机里验证。 2. 安装依赖 参考资料&#xf…

Llmcad: Fast and scalable on-device large language model inference

题目:Llmcad: Fast and scalable on-device large language model inference 发表于2023.09 链接:https://arxiv.org/pdf/2309.04255 声称是第一篇speculative decoding边缘设备的论文(不一定是绝对的第一篇),不开源…

用Java爬虫“搜刮”工厂数据:一场数据的寻宝之旅

引言:数据的宝藏 在这个数字化的时代,数据就像是隐藏在数字丛林中的宝藏,等待着勇敢的探险家去发掘。而我们,就是那些手持Java魔杖的现代海盗,准备用我们的爬虫船去征服那些数据的海洋。今天,我们将一起踏…

14、保存与加载PyTorch训练的模型和超参数

文章目录 1. state_dict2. 模型保存3. check_point4. 详细保存5. Docker6. 机器学习常用库 1. state_dict nn.Module 类是所有神经网络构建的基类,即自己构建一个深度神经网络也是需要继承自nn.Module类才行,并且nn.Module中的state_dict包含神经网络中…

【计算机网络】多路转接之poll

poll也是一种linux中的多路转接方案(poll也是只负责IO过程中的"等") 解决:1.select的fd有上限的问题;2.每次调用都要重新设置关心的fd 一、poll的使用 int poll(struct pollfd *fds, nfds_t nfds, int timeout); ① struct pollfd *fds&…

矩阵重新排列——sort函数

s o r t sort sort函数表示排序,对向量和矩阵都成立 向量 s o r t ( a ) sort(a) sort(a)将向量 a a a中元素从小到大排序 s o r t ( a , ′ d e s c e n d ′ ) sort(a,descend) sort(a,′descend′)将向量 a a a中元素从大到小排序 [ s o r t a , i d ] s o r…

深入解密 K 均值聚类:从理论基础到 Python 实践

1. 引言 在机器学习领域,聚类是一种无监督学习的技术,用于将数据集分组成若干个类别,使得同组数据之间具有更高的相似性。这种技术在各个领域都有广泛的应用,比如客户细分、图像压缩和市场分析等。聚类的目标是使得同类样本之间的…

Leetcode322.零钱兑换(HOT100)

链接 代码&#xff1a; class Solution { public:int coinChange(vector<int>& coins, int amount) {vector<int> dp(amount1,amount1);//要兑换amount元硬币&#xff0c;我们就算是全选择1元的硬币&#xff0c;也不过是amount个&#xff0c;所以初始化amoun…

【61-70期】Java面试题深度解析:从集合框架到线程安全的最佳实践

&#x1f680; 作者 &#xff1a;“码上有前” &#x1f680; 文章简介 &#xff1a;Java &#x1f680; 欢迎小伙伴们 点赞&#x1f44d;、收藏⭐、留言&#x1f4ac; 文章题目&#xff1a;Java面试题深度解析&#xff1a;从集合框架到线程安全的最佳实践 摘要&#xff1a; 本…

关闭AWS账号后,服务是否仍会继续运行?

在使用亚马逊网络服务&#xff08;AWS&#xff09;时&#xff0c;用户有时可能会考虑关闭自己的AWS账户。这可能是因为项目结束、费用过高&#xff0c;或是转向使用其他云服务平台。然而&#xff0c;许多人对关闭账户后的服务状态感到困惑&#xff0c;我们九河云和大家一起探讨…

JVM_垃圾收集器详解

1、 前言 JVM就是Java虚拟机&#xff0c;说白了就是为了屏蔽底层操作系统的不一致而设计出来的一个虚拟机&#xff0c;让用户更加专注上层&#xff0c;而不用在乎下层的一个产品。这就是JVM的跨平台&#xff0c;一次编译&#xff0c;到处运行。 而JVM中的核心功能其实就是自动…

若依框架部署在网站一个子目录下(/admin)问题(

部署在子目录下首先修改vue.config.js文件&#xff1a; 问题一&#xff1a;登陆之后跳转到了404页面问题&#xff0c;解决办法如下&#xff1a; src/router/index.js 把404页面直接变成了首页&#xff08;大佬有啥优雅的解决办法求告知&#xff09; 问题二&#xff1a;退出登录…

BERT解析

BERT项目 我在BERT添加注释和部分推理代码 main.py vocab WordVocab.load_vocab(args.vocab_path)#加载vocab那么这个加载的二进制是什么呢&#xff1f; 1. 加载数据集 继承关系&#xff1a;TorchVocab --> Vocab --> WordVocab TorchVocab 该类主要是定义了一个词…

连接共享打印机0X0000011B错误多种解决方法

打印机故障一直是一个热门话题&#xff0c;特别是共享打印机0x0000011b错误特别头疼&#xff0c;有很多网友经常遇到共享打印机0x0000011b错误。0x0000011b有更新补丁导致的、有访问共享打印机服务异常、有访问共享打印机驱动异常等问题导致的&#xff0c;针对共享打印机0x0000…

spring +fastjson 的 rce

前言 众所周知&#xff0c;spring 下是不可以上传 jsp 的木马来 rce 的&#xff0c;一般都是控制加载 class 或者 jar 包来 rce 的&#xff0c;我们的 fastjson 的高版本正好可以完成这些&#xff0c;这里来简单分析一手 环境搭建 <dependency><groupId>org.spr…

jeecgbootvue2重新整理数组数据或者添加合并数组并遍历背景图片或者背景颜色

想要实现处理后端返回数据并处理&#xff0c;添加已有静态数据并遍历快捷菜单背景图 遍历数组并使用代码 需要注意&#xff1a; 1、静态数组的图片url需要的格式为 require(../../assets/b.png) 2、设置遍历背景图的代码必须是: :style"{ background-image: url( item…

15分钟做完一个小程序,腾讯这个工具有点东西

我记得很久之前&#xff0c;我们都在讲什么低代码/无代码平台&#xff0c;这个概念很久了&#xff0c;但是&#xff0c;一直没有很好的落地&#xff0c;整体的效果也不算好。 自从去年 ChatGPT 这类大模型大火以来&#xff0c;各大科技公司也都推出了很多 AI 代码助手&#xff…

jenkins 2.346.1最后一个支持java8的版本搭建

1.jenkins下载 下载地址&#xff1a;Index of /war-stable/2.346.1 2.部署 创建目标文件夹&#xff0c;移动到指定位置 创建一个启动脚本&#xff0c;deploy.sh #!/bin/bash set -eDATE$(date %Y%m%d%H%M) # 基础路径 BASE_PATH/opt/projects/jenkins # 服务名称。同时约定部…

3D建筑模型的 LOD 规范

LOD&#xff08;细节层次&#xff09; 是3D城市建模中用于表示建筑模型精细程度的标准化描述不同的LOD适用于不同的应用场景 LOD是3D建模中重要的分级标准&#xff0c;不同层级适合不同精度和用途的需求。 从LOD0到LOD4&#xff0c;细节逐渐丰富&#xff0c;复杂性和精度也逐…