最近一直在折腾安卓投屏到电脑这件事。命令行工具 scrcpy 确实好用,但参数一多就头疼,特别是要在多台设备之间来回切换、调画质、看日志的时候,终端操作既不直观也容易打错。所以我自己写了个可视化封装工具,技术栈是 Java 21 + Swing,把 adb 设备发现、scrcpy 进程启动、常用参数配置、日志输出全部收进一个图形界面。这个项目适合谁呢?一个是整天测多台安卓设备的测试同学,不用再背命令;另一个是正在做命令行工具 GUI 封装、想研究 Java 21 新特性的开发者。下面会从选型、架构、核心实现到坑位排查,完整讲一遍。
1. 为什么选 Java 21 + Swing 而不是其他组合
1.1 scrcpy 命令行工具的本质
scrcpy 本质是安卓设备与电脑之间的双向控制通道。它通过 adb 建立连接,在设备端启动一个服务端,利用手机自带的 MediaCodec 和屏幕采集能力把画面编码后传到电脑端解码显示,同时把电脑端的鼠标键盘事件通过 adb 注入回设备。换句话说,scrcpy 自己在底层解决投屏和控制,我们做可视化工具,根本不需要重写这套逻辑,核心工作是把这个复杂的命令行进程封装成友好、可管理、可监控的图形应用。
这就是整个项目最低成本也最稳的切入点。不少人在规划这类工具时会想自己写传输协议,那是把问题搞复杂了。正确思路是:让 scrcpy 继续干重活,我们只负责调度和展示。我在做工具时给自己定了一条规矩:凡是 scrcpy 已经提供的能力,一律不重复实现;凡是它没提供的体验层能力,比如设备发现、参数记忆、日志可视化、一键多开,才是我要花心思的地方。
1.2 Java 21 的新特性带来的红利
选 Java 21 不是追新,而是这些特性实在太对口。首先是虚拟线程,scrcpy 进程本身会持续输出日志,设备列表要定时轮询,我们还要同时管理多台设备。传统线程池一遇到阻塞 I/O 就容易占着线程不放,而虚拟线程可以轻松创建大量阻塞任务,成本极低。我在代码里给每台设备都开了一个虚拟线程去读子进程输出,完全不心疼。
其次是 record 和 switch 模式匹配。解析 adb devices 的输出、整理设备状态时可以用 record 定义不可变数据类,再用 switch 处理各种状态,代码会干净很多。还有文本块和 SequencedCollection 这些小特性,拼命令、维护设备列表时都很顺手。虽然大多数功能用 Java 8 也能写,但写起来烦琐,维护起来更想骂人。用 Java 21 还有一个隐藏好处:长期支持版本,公司内部做工具不会频繁被逼着升级基线。
1.3 Swing 为什么依然能打
唱衰 Swing 很多年,但它还没有退休,主要原因是它够轻、够直接。同样是做一个内部工具,Electron 打包出来动辄一两百兆,启动还要吃几百兆内存;而 Swing 程序在 JDK 21 下启动只要一两秒,内存占用几十兆。对于管理 adb/scrcpy 这类命令行工具来说,界面复杂度不高,Swing 的组件体系已经非常够用。JTable、JList、JTextPane、JToolBar 这些组件组合起来,已经能满足设备列表、参数面板、日志窗口的全部需求。
Swing 还有一个被低估的优势:跨平台一致性。内部工具通常要在 Windows 和 Linux 上并存,Swing 在这两个平台上的表现都非常稳定。相比 JavaFX 在 JDK 8 之后被拆出 JDK,Swing 始终活在标准库里,不需要额外装运行时,这对分发来说是个极大的省心点。
1.4 备选方案对比
这里做个选型对比,方便大家在类似项目里做决策。
| 方案 | 打包体积 | 启动速度 | 内存占用 | 与 Java 生态集成 | 备注 |
|---|---|---|---|---|---|
| Swing | 小(可用 jlink/jpackage 裁剪) | 快 | 低 | 原生 | 适合业务简单的内部工具 |
| JavaFX | 中 | 较快 | 中 | 官方但被剥离于 JDK | 界面能力强,需额外依赖 |
| Electron | 大 | 慢 | 高 | 通过 HTTP 或 node 桥接 | 界面美观,但重 |
| Tauri | 中 | 快 | 较低 | 通过本地服务桥接 | 需要 Rust 工具链,不是首选 |
考虑到这个工具要长期在 Windows 和 Linux 上跑,性能和维护成本优先,我最终选 Swing。如果界面要求特别高,再考虑换 JavaFX 也不迟,核心逻辑保持独立模块即可,切换 UI 不会伤筋动骨。
2. 整体设计与架构拆解
2.1 核心需求与模块划分
动手前我先把需求拆成了四个模块:运行时检测、设备管理、进程管理、界面展示。运行时检测负责找到 adb 和 scrcpy 的可执行文件,设备管理负责查询和缓存设备列表,进程管理负责启动、停止 scrcpy 子进程并读取日志,界面展示负责把前三者可视化。
模块之间通过一个简单的服务容器互相引用,界面只面向接口,方便后续替换实现。这个工具我不需要分布式、不需要插件化,用包目录结构划分就足够清晰。唯一要早做的是事件通知机制,设备列表变化、进程退出、日志到达都要通过监听器广播,避免模块之间直接调用彼此的内部方法,否则后期加功能会非常痛苦。
2.2 线程模型与 UI 冻结问题
Swing 是单线程模型,所有 UI 更新必须在 EDT(事件分发线程)上执行,这是 Java 老生常谈的问题。但很多人还是会在处理回调时直接更新组件,导致界面卡死。我在设计阶段就统一了规则:所有耗时操作丢给后台线程,后台线程完成后通过 SwingUtilities.invokeLater 把结果送回到 EDT 更新界面。
这样看起来多写了几行代码,但在多设备轮询、日志刷屏时 UI 能保持流畅。采用虚拟线程的好处在这里体现得尤其明显,传统固定线程池一旦某个任务卡在 adb 命令上,整个池子可能被拖垮;虚拟线程可以每个任务一条,阻塞时资源自动释放。我甚至不需要线程池,直接 Thread.ofVirtual().start() 即可,代码看着也简单。
2.3 设备发现与状态同步
设备列表需要实时反映手机连接和断开,但刷新频率又不能太高。我采用定时轮询加事件通知的组合,每三秒执行一次虚拟线程任务,扫描 adb devices,再和缓存的设备列表对比。如果发现新增或移除设备,就通过设备列表变更事件通知 UI 刷新。
在刷新过程中获取每台设备的详细属性,比如型号、系统版本、屏幕分辨率,这些信息在不同线程间传递时用 record 保存为不可变对象,避免共享可变状态。为了不给 adb 增加太多压力,详细属性只查询一次并缓存,只有在设备重连时才更新。这套机制虽然简单,但对后续的窗口管理非常关键,因为所有会话列表都基于这份设备缓存。
3. 关键功能实现与代码细节
3.1 环境检测这样做
环境检测看起来简单,踩过的坑不少。启动时我按优先级搜索:环境变量 PATH、用户配置的 adb/scrcpy 路径、平台常见安装目录、程序同目录下的 tools 目录。搜索到候选后再执行一次 version 命令确认工具可用。在 Windows 上需要注意可执行文件名可能是 adb.exe 和 scrcpy.exe,路径分割符也要兼容。我封装了一个 ToolLocator 工具类,返回结果包含绝对路径和版本号,如果找不到,就在主界面顶部显示黄条提示并引导用户手动指定。
public static Optional<ToolInfo> findTool(String name, List<String> extraPaths) { List<String> candidates = new ArrayList<>(); candidates.addAll(extraPaths); candidates.addAll(systemPathDirectories()); candidates.addAll(commonInstallDirs(name)); for (String dir : candidates) { Path file = Paths.get(dir, executableName(name)); if (Files.isExecutable(file)) { return Optional.of(new ToolInfo(file.toString(), readVersion(file))); } } return Optional.empty(); }这段代码的价值是快速失败,而不是等用户点击连接后才发现 adb 不存在。如果检测不到工具,后续所有按钮都置灰,界面不会处于一种看似能用但实际全崩的状态。环境检测顺便也把 adb server 启动确认给做了,确保后续设备扫描拿到的不是历史残留数据。
3.2 设备列表刷新
设备列表刷新本质上就是解析adb devices -l的输出。输出格式类似serial device product:xxx model:xxx device:xxx,所以用空白切分和 key:value 解析即可。可以用 record 定义:
public record DeviceInfo(String serial, String state, String model, String product) {}需要特别注意的是,adb 输出中会出现不可见字符,Windows 下换行符是 \r\n,最好在解析前先 normalize。同时 adb 可能返回 unauthorized 状态,这种设备不能直接控制,GUI 上要用明显颜色标记。我建议把设备列表做成 JTable,列分别是选择框、序列号、型号、系统版本、分辨率、状态和操作按钮。这样一台设备一行数据,操作按钮点击后弹出参数配置面板。
设备型号和系统版本通过adb -s serial shell getprop获取,分辨率通过wm size获取。这些命令都有延迟,如果逐个串行执行,三秒轮询根本跑不完。我改成并行虚拟线程拉取,每个设备一个任务,整体延迟可以控制在几百毫秒内。
3.3 启动与回收 scrcpy 子进程
启动 scrcpy 的关键是构造完整命令并正确处理子进程生命周期。常见的要求包括:指定设备序列号、限制画面尺寸、设置码率、关闭设备屏幕、开启剪贴板同步。我的参数面板会把用户选项转换成命令片段,再拼装成 List ,而不是直接拼字符串,避免转义问题。命令大致如下:
scrcpy -s ${serial} --max-size 1600 --video-bit-rate 4M --max-fps 60 --clipboard-autosyncJava 端用 ProcessBuilder 启动后,不能用 waitFor 一竿子等到底,因为 scrcpy 会一直运行直到窗口关闭。正确做法是把 Process 对象保存在一个 SessionHolder 中,启动一个虚拟线程读取输出流,另一个虚拟线程监控进程退出状态。等窗口关闭后,主界面会收到退出事件,更新设备状态、清理按钮。如果用户点击停止按钮,调用 process.destroy() 并等待超时,再决定是否 forceDestroy。
下面给一段进程启动的核心示意,注意用列表传参,不要用 shell 字符串:
List<String> cmd = new ArrayList<>(); cmd.add(scrcpyPath); cmd.add("-s"); cmd.add(serial); cmd.add("--max-size"); cmd.add("1600"); cmd.add("--video-bit-rate"); cmd.add("4M"); ProcessBuilder pb = new ProcessBuilder(cmd); pb.redirectErrorStream(true); Process proc = pb.start();读输出用虚拟线程:
Thread.ofVirtual().start(() -> { try (BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { logLine("[scrcpy] " + line); } } catch (IOException e) { // ignore after process exits } });这里日志行要经过过滤,scrcpy 的 INFO 级别和 WARNING 级别通常会混在 stdout 里,可以给关键信息加上颜色。比如 ERROR 显示红色,握手完成信息显示绿色,用户一眼就能看出投屏是否成功连接。
3.4 将 scrcpy 窗口嵌入 Swing
这个环节是整个工具最有视觉冲击力也最容易翻车的地方。scrcpy 的窗口由 SDL 创建,本质上是一个独立的外部窗口,Java 层没办法直接把它放进 JPanel。常规做法是使用 JNA 调用操作系统 API,在 Windows 上可以用 user32 的 SetParent 把外部窗口的父窗口设置为 Swing 组件对应的窗口句柄,这样外部窗口就会出现在 Swing 容器内部。流程是:启动 scrcpy 后,用进程句柄枚举窗口,找到 SDL 窗口句柄,然后调用 SetParent。
这里要提醒一句:SetParent 方案在 Windows 上表现尚可,在 macOS 和 Wayland 上受限很多。如果你只是想要一个能用的工具,没有必要强行追求嵌入式窗口。我实际项目的策略是默认使用 scrcpy 自己的窗口,GUI 扮演控制台和启动器;嵌入模式作为一个实验性开关,遇到兼容问题就切回独立窗口。这样既保证了功能可用,又给“可视化”增加了一个亮点。嵌入后还需要处理窗口尺寸变化和焦点问题,否则窗口会显示异常。
3.5 输入通路与剪贴板同步
很多人关心 Java 界面能不能直接把文字发送到手机屏幕。这其实完全可以通过 scrcpy 自身能力实现,不必自己写注入代码。启用--clipboard-autosync后,剪贴板会在电脑和手机之间自动同步,Java 端只需要在复制文字时正常写剪贴板,手机端就能读出来。如果还需要模拟键盘输入,可以用 scrcpy 的--keyboard=uhid参数,让电脑键盘事件直接注入设备。Java 这边要做的就是把这些参数封装成清晰的 checkbox 和下拉框,并给每个参数配上文字说明。
如果希望在这个工具内部显示一个额外的文本输入框,点击按钮后把文本送到手机焦点框,则可以调用 adb 的input text命令,但只能处理 ASCII 字符,中文需要特殊处理,通常要先设置剪贴板再在手机上粘贴。所以我的参数面板里只提供剪贴板同步和键盘注入选项,不做过多自以为是的输入转发,反而稳定。
3.6 日志输出与状态监控
日志面板是排查问题的关键,必须设计好。我用 JTextPane 实现一个简单的列表式日志:每行带时间戳和级别,超过 500 行就自动删除最旧的一行,防止内存无限增长。日志来源有两类,一类是工具自身运行过程中产生的,比如 adb 检测、设备刷新、参数校验;另一类是 scrcpy 子进程的输出。子进程日志读取放在虚拟线程里,每一行通过 SwingUtilities.invokeLater 传给日志面板。
这里要特别注意,scrcpy 进程退出后,读取循环可能读到 EOF,需要及时移除行监听器,否则界面会越来越卡。状态栏显示当前连接的设备数、正在运行的 scrcpy 进程数、adb 服务状态。在状态栏右侧还可以放一个“复制诊断信息”按钮,一键把工具版本、Java 版本、adb/scrcpy 路径、设备列表、最近 50 行日志全部复制到剪贴板,报问题时直接把这段信息贴出去,省去来回追问。
4. 高频问题与排查实录
4.1 画面黑洞与连接失败
最常见的现象是点击启动后 scrcpy 窗口弹出来,但里面一片漆黑,几秒后自动退出。绝大多数原因是手机的 USB 调试授权弹窗没有被确认。第一次连接时手机上一定要弹出 RSA 授权框,如果手快取消或没看到,设备在 adb devices 里会显示 unauthorized,此时 scrcpy 无法工作。我的工具会在启动前事先检查设备状态,发现 unauthorized 就直接提示,而不是默默启动。
还有一种情况是设备画面黑屏但进程还活着,多半是设备息屏进入了休眠,scrcpy 默认不会自动点亮屏幕,可以勾选--turn-screen-on参数在启动时唤醒屏幕。遇到这类问题时,先看日志面板里 scrcpy 输出的最后几行,通常会直接告诉你原因。我的经验是让日志先于界面存在,诊断效率会高很多。
4.2 多设备与端口冲突
同时投屏多台设备时,scrcpy 默认使用本地端口,多台会冲突。解决办法很简单,每条会话使用独立的本地端口,scrcpy 新版本提供了--port参数,我把当前会话的时间戳映射成高范围端口。需要注意端口范围要在 0~65535 内,同时避免和 adb 的端口冲突。
另一个坑是每台设备都对应一个 adb server 进程吗?不是,adb server 是全局的,一个即可。所以杀进程时不能连 adb 一起杀掉,否则其他设备列表会全部消失。我的进程管理模块在退出时会遍历当前所有会话,只销毁 scrcpy 相关进程,adb server 保持不动。
4.3 窗口嵌入后的焦点问题
嵌入式窗口最大的痛点是焦点。外部窗口通过 SetParent 挂到 Swing 面板后,Java 本身的输入框可能拿不到焦点,键盘事件会跑到 scrcpy 窗口里,导致你在 Java 界面输入参数时手机却收到了键盘输入。我的处理方案是给嵌入容器增加一个启用开关,并且只有在选择“嵌入投屏窗口”时才会用 SetParent。如果不嵌入,完全用 scrcpy 原生窗口,就不会有焦点抢用问题。
如果你非要嵌入,还需要在 Swing 组件获得或失去焦点时重新调用 SetFocus,把这个操作弱化到“实验性功能”是明智的。毕竟工具的稳定性比炫酷更重要,投屏画面放外面也不是不行。
4.4 打包后找不到 adb/scrcpy
把自己电脑上运行得好好的 jar 打包成安装包后,经常遇到点击按钮提示找不到 adb。原因很简单,安装包没有把 adb 和 scrcpy 的可执行文件带进去。用 jpackage 打包时,可以用--app-content指定额外文件目录,把 tools 文件夹放进安装包,然后在运行时优先从程序所在目录的 tools 子目录查找。如果用户本机已经装了 scrcpy,也可以通过配置文件手动指定路径。
诊断面板里强烈建议显示当前自动检测到的路径,用户一眼就能发现问题。我在这块还加了一个很土但实用的逻辑:在界面上显示“使用哪个目录下的 scrcpy”,方便对 SDK 工具目录敏感的用户快速切换。
5. 从能用到好用:优化与分发
5.1 配置项与默认值设计
参数配置面板很容易越做越复杂。我的原则是:常用参数给预设,高级参数放高级选项卡。比如画质预设可以提供“流畅”“均衡”“高清”三档,内部映射为 max-size 和 video-bit-rate 不同组合。用户选择档位后,高级选项自动填好对应数值,并且每次启动时读取上次配置。配置文件采用简单键值对保存到用户主目录下,不需要数据库。
这里的主要经验是:默认值要让新手能直接点启动,而不是面对一堆参数发呆。我甚至第一次启动时什么都不选,直接点“连接当前设备”也能工作,所有参数都有合理缺省值。此类工具的体验就是少一步操作,多一分好感。
5.2 日志与诊断面板
日志不只是给开发者看的,也可以做成给使用者看。我把日志按级别着色,并为“scrcpy 连接成功”“设备断开”这类事件单独加粗提示。诊断面板提供“一键导出诊断信息”功能,能显著减少沟通成本。之前帮同学排查问题时,他截图发来一小段日志,根本看不出是在哪一步失败的;改进后直接导出全量环境信息,问题立刻定位到路径检测顺序错误。
诊断信息的格式我也做了排版,分组清晰,方便直接贴到问题反馈里。包括系统版本、工具版本、adb 版本、scrcpy 版本、自动检测路径、配置路径、设备列表、最近日志。虽然实现很简单,但能解决实际使用中七成的沟通成本。
5.3 内存和帧率优化
这个工具自身不渲染视频流,内存不会太高,但也要防止无脑累积。设备列表的轮询线程每三秒跑一次 adb,如果手机长时间挂着,adb 输出会很稳定,但 JTable 模型更新频繁依然会占用 EDT。我做了两个优化:第一,只有设备列表变化时才触发 JTable 刷新;第二,设备详细属性缓存,不重复执行 getprop 查询。
日志面板超过阈值后裁剪,否则跑一天内存能涨到几百兆。如果你想把工具做成常驻后台,建议再用 -Xmx 限制堆大小,比如 256m 就够。UI 的顺滑程度不取决于机器性能,而取决于是不是频繁做无用刷新。
5.4 用 jpackage 制作安装包
最后一步是分发。JDK 21 的 jpackage 能直接生成平台安装包,但要做几个关键配置:首先保持项目为 jlink 可裁剪结构,不要依赖无关模块;然后在打包参数里加入 --runtime-image,使用 jlink 生成的最小运行时;再把 scrcpy 和 adb 目录通过 --app-content 打进去。Windows 上生成 exe/msi,macOS 上生成 dmg,Linux 上生成 deb/rpm。打包命令类似:
jlink --add-modules java.desktop,jdk.crypto.ec --output runtime jpackage --type msi --input lib --main-jar scrcpy-visual.jar --main-class com.example.Main --runtime-image runtime --app-content tools --name ScrcpyVisual注意 jpackage 需要平台相关的打包工具,Windows 上可能需要 WiX 工具集,这些在官方文档里都有说明。打包完最好在一台没有 JDK 的干净机器上跑一次,确保运行时裁剪没有漏掉模块。首次打包一定会遇到一两个鬼问题,比如缺模块、缺 manifest、图标格式不对,心态放平,逐个解决就行。
最后再分享一个小经验。做这种命令行工具可视化封装,最忌讳的是把 GUI 和逻辑揉在一起。我第一版把所有代码写在一个主类里,后加一个设备刷新功能时,界面卡到不能点。后来狠下心拆模块、统一走事件通知,才真正稳定下来。所以如果你也想做类似项目,先画清楚模块边界,再动手写界面,能省一大半返工的时间。这个工具对我来说价值很直接:给测试同学一装,省去了教他们背参数的过程,自己也少了点头响应。我自己接下来的计划是再往里面加截图录屏和自动化脚本编排,让投屏工具从“能看能控”变成“能测能查”。