你有没有遇到过这种情况:手里摆着十几台、几十台Android手机,要批量装同一个App、跑同一组测试用例、挨个查看屏幕状态,结果只能一台一台拿起来手动操作?我反正被这个流程折磨过很久。后来我干脆花时间梳理了一套可行的方案:用云端控制手机,把每台Android设备变成可远程调度、可批量管理、可自动执行指令的节点。这类系统业内通常叫“云控系统”,本质就是“服务端控制平面 + 手机端Agent + 通信协议”的组合。这篇内容我打算把一套完整可用的云控框架源码拆开讲,重点解析它的整体架构、通信设计、核心执行器,以及我在实际落地中踩过的一些坑。适合准备做设备集群管理、自动化测试平台或App稳定性测试的团队参考。
1. 项目定位与整体架构设计
1.1 云控系统到底在解决什么问题
先聊清楚需求,不然容易把系统做复杂。我接触到的云控需求,绝大多数集中在三个场景。
第一个是自动化测试:同一个App要跑兼容性用例,需要在多台不同品牌、不同系统版本的手机上安装、启动、操作、卸载,反复循环。手工操作不仅慢,而且人容易疲劳,测试结果也不稳定。第二个是设备集群管理:机房或者测试室内有一批手机,需要统一查看在线状态、电量、内存占用、当前前台应用,偶尔还要远程操作。第三个是无人值守运维:设备在偏远位置或者无人值守环境,需要定时重启、拉取日志、执行脚本,出现问题能快速恢复或告警。
这三个场景有一个共同麻烦:设备是分离的,状态不可见,指令执行不可追踪。如果你只是靠一个人手动去弄,第一台手机装到一半,第二台可能已经开始弹权限框了,完全没法并行管理。云控系统的目标就是把“设备分散、操作手工、状态黑盒”变成“设备集中、指令自动化、状态实时可见”。
所以做这个系统,第一个关键点不是选多炫的技术,而是先把设备注册、指令下发、结果上报这条主链路设计清楚。主链路通了,后面的自动化测试、远程运维都是在它之上堆业务能力。
1.2 控制平面、Agent与协议的三层清晰划分
我见过不少初版实现,喜欢把指令的执行逻辑直接写死在服务端,通过ADB命令去控制设备,结果一旦做多、频繁断连就非常痛苦。云控系统必须做分层,我推荐按“控制平面、设备Agent、通信协议”三层去拆。
控制平面是系统的指挥中心,负责设备管理、任务下派、状态汇总。它不需要关心Android内部某个指令具体怎么执行,只需要把控制意图封装成一条标准指令,交给目标设备。设备Agent是安装在手机上的常驻进程,负责接收指令、解析指令、调用系统能力执行、然后把结果上报。它是整个系统离Android系统最近的一层,也是最容易出问题的一层。通信协议是连接控制平面和Agent的桥梁,规定指令怎么编码、怎么分包、怎么确认、怎么处理超时和重连。
这三层各管各的,好处很明显。设备Agent只做执行和上报,服务端只做调度和展示,协议一旦定了,两端可以独立演进。比如控制台要从网页版改成小程序版,服务端改一改,Agent不用动;再比如要给Agent加一个“屏幕投屏”能力,只需要在协议里新增一个指令类型,服务端也能无缝兼容旧设备。
1.3 技术选型:为什么是Netty + Spring Boot + Redis
技术栈选择我踩过很多坑,这里直接说结论:手机端Agent用Java/Kotlin,服务端用Spring Boot + Netty,Macroz中间加Redis做任务队列和在线状态缓存。
先看通信层。云控最忌讳用短轮询,因为设备数量一旦上来,几秒一次轮询会把服务端打死,而且指令实时性也没保障。用长连接是必须的,而长连接如果要自研,那就是在重复造轮子,Netty是不错的选择。服务端用Netty做TCP网关,Agent用Netty客户端或者轻量的Socket连接,Web管理端则通过WebSocket接入服务端拿实时状态。
有人会问,为什么不直接用MQTT?MQTT确实很适合物联网设备通信,但它的消息模型偏“发布/订阅”,做设备指令这种一对一的请求/响应模型,还得自己维护topic和会话映射,反而更绕。还有人问,为什么不用HTTP/2或者gRPC?它们在普通App业务里很好用,但在Agent这种需要长时间在线、低帧率心跳、轻量消息的场景下,Netty的自定义协议更直接,可控性也更高。
后端选Spring Boot是团队普遍熟悉,生态成熟,做权限、接口、管理后台都方便。Redis用来存设备在线状态、指令去重标记和批量任务队列,性能高,语义也简单。如果你们团队更熟悉别的框架,比如若依这类带权限管理的脚手架,也可以直接借用来做控制台,省下不少基础功能开发时间。
2. Android端Agent框架源码拆解
2.1 Agent的模块地图
Agent是云控系统里最容易“越写越乱”的部分,因为Android系统本身能力太杂了:指令可能涉及Shell命令、应用安装卸载、Activity启动、屏幕点击、文件拉取、日志收集……如果所有逻辑堆在一块,一个上万行的类就会慢慢变成没人敢动的代码。
我推荐的模块划分是:连接管理器、消息编解码、指令分发器、执行器集合、设备信息采集器、心跳定时任务、屏幕采集模块。
连接管理器负责和服务端建立TCP连接、处理断线重连;消息编解码负责把协议包转成Java对象;指令分发器是核心,负责把每一条指令路由到对应的执行器;执行器集合里每个执行器只做一件事,比如Shell执行器只负责执行命令,包管理器只负责App安装卸载;设备信息采集器定时采集电量、内存、网络、当前应用等基础状态;屏幕采集模块则独立成模块,避免截屏逻辑拖慢主线程。
这样划分之后,新增一个“远程录屏”功能时,只需要新增一个ScreenRecordExecutor并在分发器里注册,其他地方基本不用动。
2.2 指令分发器:从协议包到执行器注册表
很多初学者喜欢在收到一条指令后写一个巨大的switch-case,根据type字段调不同方法。这在指令类型少于10个时还行,一旦超过20个就开始变得不可维护。我改用执行器注册表的方式,核心思路很简单:每个执行器实现同一个接口,注册时告诉分发器“我处理什么类型的指令”,分发器收到指令后直接查表。
先定义执行器接口。
public interface ICommandExecutor { String type(); CommandResult execute(RemoteCommand command); }接口里只暴露两个方法:type()返回这个执行器负责的指令类型,execute()接收RemoteCommand并返回CommandResult。分发器的实现非常简单。
public class CommandDispatcher { private final Map<String, ICommandExecutor> executorMap = new ConcurrentHashMap<>(); public void register(ICommandExecutor executor) { executorMap.put(executor.type(), executor); } public CommandResult dispatch(RemoteCommand command) { ICommandExecutor executor = executorMap.get(command.getType()); if (executor == null) { return CommandResult.fail("unsupported command type: " + command.getType()); } return executor.execute(command); } }由于executorMap是线程安全的ConcurrentHashMap,多个连接线程同时下发指令也不会出问题。Agent启动时在初始化方法里把所有执行器注册进去。
dispatcher.register(new DeviceInfoExecutor()); dispatcher.register(new ShellExecutor()); dispatcher.register(new PackageExecutor()); dispatcher.register(new UiActionExecutor()); dispatcher.register(new FileExecutor());注册表的好处显而易见:要加新功能,新增类、注册一行代码、重启Agent,开关老指令互不影响;要下线某个功能,直接在注册表里注释掉。而且这种模式很好写单元测试,把Mock出来的RemoteCommand丢给dispatch,断言返回值即可。
RemoteCommand本身设计成不可变对象会更安全。构造时传入类型、参数、指令ID即可。指令ID非常关键,后面讲ACK时会详细说。
2.3 执行器:Shell命令、包管理、UI操控怎么做
指令分发器只是路由,真正干活的是执行器。我先说Shell执行器,因为它是云控里最常用的执行器,很多能力都可以通过Shell命令组合出来。
Shell执行器有一个很容易犯的错误:直接用Runtime.getRuntime().exec()拼接字符串。这样做有两个问题:一是多参数指令容易因空格分割导致执行失败,二是参数如果来自不可信来源,可能被塞入多余命令,有注入风险。正确的做法是用ProcessBuilder,把命令和参数以列表形式传入。
public class ShellExecutor implements ICommandExecutor { @Override public String type() { return "shell"; } @Override public CommandResult execute(RemoteCommand command) { String[] args = ((List<String>) command.getParams().get("args")).toArray(new String[0]); Process process = null; ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ByteArrayOutputStream errorStream = new ByteArrayOutputStream(); try { ProcessBuilder builder = new ProcessBuilder(args); builder.redirectErrorStream(false); process = builder.start(); boolean finished = process.waitFor(15, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); return CommandResult.fail("shell execute timeout"); } readStream(process.getInputStream(), outputStream); readStream(process.getErrorStream(), errorStream); int exitCode = process.exitValue(); return CommandResult.ok() .extField("exitCode", exitCode) .extField("stdout", outputStream.toString(StandardCharsets.UTF_8)) .extField("stderr", errorStream.toString(StandardCharsets.UTF_8)); } catch (Exception e) { return CommandResult.fail(e.getMessage()); } finally { if (process != null) { process.destroy(); } } } }注意waitFor那行,我设置了15秒超时。这是血的教训,早期版本没有超时,某台低端设备因为系统卡顿,一条cmd命令直接卡了几分钟,任务队列被它堵住,后面的指令全部超时。Shell执行器一旦超时,必须强制销毁进程,并且及时返回错误信息。
包管理执行器的实现思路类似。常见的操作有查询已安装包列表、安装APK、卸载应用、启动Activity、停用应用等。核心命令如下。
# 查询应用列表 pm list packages # 安装APK pm install -r /data/local/tmp/app.apk # 卸载应用 pm uninstall -k com.example.package # 启动指定Activity am start -n com.example.package/.MainActivity # 强制停止应用 am force-stop com.example.package这些命令在Shell执行器基础上可以二次封装,把参数透传即可。要特别注意不同Android版本的行为差异,比如Android 14对部分pm命令做了权限限制,需要单独处理。
UI操控执行器比较复杂,它不是一条Shell命令那么简单,需要根据设备权限分成几种方案。第一种是input命令,在部分授权环境下可以直接模拟点击、滑动、按键。
input tap x y input swipe x1 y1 x2 y2 durationMs input keyevent KEYCODE_HOME input keyevent KEYCODE_BACK第二种是用AccessibilityService的dispatchGesture能力,不需要Shell权限,但需要用户手动开启无障碍服务。这种方案适合无特殊权限的大规模设备,后面第5章会展开讲代码实现。UI操控执行器要做的是根据设备能力自动选择方案,优先用input命令,权限不足时回退到dispatchGesture。
3. 通信协议与链路可靠性设计
3.1 长连接与消息编解码
云控的通信协议是整个系统的生命线。我最初的版本用的是HTTP短轮询,设备每隔3秒请求一次服务端“有没有新指令”,服务端返回一次“没有”后连接就断开了。几十台设备还能撑住,几百台设备时服务端负载直线上升,而且指令下发到设备执行之间的延迟最高能达到3秒,远程操控手机时完全没法用。
改用长连接后,服务端可以主动向设备推送指令,设备也能持续上报状态。移动网络上长连接容易被回收,所以要搭配心跳保活。Netty天然支持长连接开发,手机端用Netty或者原生Socket都可以,但建议直接在Agent里内置Netty客户端,编解码和处理都方便很多。
消息编解码我用的是“4字节长度字段 + JSON内容”的方式,而不是简单用换行符分包。因为云控消息不只是指令,后面可能还要传截屏文件等较大的二进制数据,以长度字段分包更稳妥,Netty里可以直接用LengthFieldBasedFrameDecoder。
LengthFieldBasedFrameDecoder lengthDecoder = new LengthFieldBasedFrameDecoder( 10 * 1024 * 1024, // 最大帧长度,留足余量 0, // 长度字段偏移量 4, // 长度字段长度 0, // 长度字段调节值 4 // 剥离长度字段 );最大帧长度我设置成10MB,是为了给截屏文件或APK分块传输留空间。如果只传文本指令,这个值可以缩小到1MB,避免恶意大包打爆内存。
3.2 指令模型与ACK回执
协议设计里最重要的就是指令模型。一个合格的指令包必须包含指令ID、指令类型、时间戳和业务参数。
{ "cmdId": "a3f2c1d9-8b7e-4f0a-9c2d-1e4f5a6b7c8d", "type": "shell", "timestamp": 1730000000, "params": { "args": ["ls", "-l", "/sdcard/"] } }cmdId非常重要。它有两个用途:第一是唯一标识一条指令,方便做端到端追踪;第二是天然支持幂等。设备端收到指令后,执行前先检查这个cmdId是否执行过,如果执行过就直接返回上次结果,避免超时重发导致同一操作被执行两次。比如“安装APK”这种操作,如果服务端没收到ACK就重发,设备端不判重就会装两遍。
ACK回包模型也要统一。我建议每个指令都有回执,成功和失败的格式一致,方便服务端做任务表更新。
{ "cmdId": "a3f2c1d9-8b7e-4f0a-9c2d-1e4f5a6b7c8d", "status": "ok", "result": { "exitCode": 0, "stdout": "...", "stderr": "" } }status只有ok和fail两种,所有业务分支都映射到这两个状态。服务端收到ACK后,把回执写入任务表,再通过WebSocket推给控制台前端。一套流程下来,用户在网页上能看到每一条指令从“已下发”到“执行中”再到“已完成/失败”的完整状态。
3.3 心跳、超时与断线重连
长连接不是建立后就万事大吉,移动网络下连接随时可能被系统回收。心跳机制要区分两层:应用层心跳和传输层空闲检测。我参考了很多成熟方案,最终确定的心跳参数如下。
| 场景 | 推荐参数 | 说明 |
|---|---|---|
| 应用层心跳间隔 | 30秒 | Agent每30秒发送一个Ping包 |
| 服务端心跳超时 | 90秒 | 超过90秒未收到任何数据就判离线 |
| 客户端读超时 | 45秒 | 客户端45秒没收到服务端数据就断开重连 |
| 断线重连初始间隔 | 1秒 | 第一次重连延迟1秒 |
| 重连最大间隔 | 30秒 | 指数退避封顶30秒 |
Netty服务端用IdleStateHandler实现空闲检测。
ch.pipeline().addLast(new IdleStateHandler(90, 0, 0, TimeUnit.SECONDS));这个Handler会在90秒内没有读到任何数据时触发一个事件,服务端收到事件后把这个Channel关闭,并从在线设备列表中移除。相比之下,如果只靠Agent主动上报心跳,服务端很难区分“设备主动断开”和“网络丢包”,容易残留大量僵尸连接。
Agent端的断线重连采用指数退避策略。第一次断线等1秒再连,第二次等2秒,第三次4秒,依次翻倍,最多30秒封顶。代码里很简单,用一个定时器叠加在重连逻辑上。
private void scheduleReconnect(int retryTimes) { long delay = Math.min(30, (long) Math.pow(2, retryTimes)); reconnectFuture = bootstrap.connect(serverHost, serverPort) .addListener(future -> { if (!future.isSuccess()) { scheduleReconnect(retryTimes + 1); } else { retryTimes = 0; } }); }注意重连时必须重新走完整的设备注册流程,Agent连接上服务端后要立即上报设备基本信息和当前状态,不然服务端即使知道“某个Channel连上了”,也无法把它对应到具体设备。
4. 服务端控制平台与设备调度
4.1 Spring Boot + Netty 如何承载设备连接
服务端要同时搞定两类连接:一类是成千上万的Agent设备连接,另一类是浏览器控制台的WebSocket连接。Netty作为独立网关服务承载Agent连接,业务状态落到Redis和MySQL,Spring Boot负责提供管理API和控制台后端。
如果Agent连接数和业务API放在同一个Tomcat里,Tomcat的线程池很容易被长连接占满,导致管理API响应变慢。所以我在部署层面把它们拆开:Netty网关开一个端口处理Agent流量,Spring Boot开另一个端口提供API,网关和Spring Boot之间通过Redis或内部RPC通信。这个拆分让系统的承载力提升非常明显。
Netty服务端核心逻辑是维护一个设备Channel管理器。每个Agent登录后分配一个Channel实例,用设备编号作为Key存储到ConcurrentHashMap里,同时把设备信息写入Redis。
public class DeviceChannelManager { private final ConcurrentHashMap<String, Channel> channels = new ConcurrentHashMap<>(); public void add(String deviceId, Channel channel) { channels.put(deviceId, channel); } public Channel get(String deviceId) { return channels.get(deviceId); } public void remove(String deviceId) { channels.remove(deviceId); } public int onlineCount() { return channels.size(); } }向设备下发指令时,先从Map里拿到Channel,然后通过Channel.writeAndFlush指令包。指令包写入失败说明设备已经断开,要同步清理Map里的记录。
4.2 设备库表与任务调度的核心模型
云控系统一定会有两张核心表:设备表和任务表。设备表保存每一台设备的元信息,任务表保存每一次指令的下发和执行结果。
设备表的字段设计如下。
CREATE TABLE device ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_id VARCHAR(64) NOT NULL UNIQUE, model VARCHAR(64), android_version VARCHAR(32), agent_version VARCHAR(32), status TINYINT DEFAULT 0, last_heartbeat_at DATETIME, group_id BIGINT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_status (status), INDEX idx_group_id (group_id) );任务表的字段需要能支撑“批量指令”和“单设备指令”两种模式。
CREATE TABLE command_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(64) NOT NULL, cmd_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL, command_type VARCHAR(32), payload TEXT, status VARCHAR(16), result TEXT, retry_count INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, finished_at DATETIME, INDEX idx_task_id (task_id), INDEX idx_device_id (device_id), INDEX idx_cmd_id (cmd_id) );批量指令的逻辑是这样的:用户在前端选中50台设备,下发一条“采集设备信息”指令。服务端先把这条指令拆成50个子任务,每个子任务对应一台设备和一条cmdId,然后遍历在线设备依次下发。因为每台设备有自己的Channel,下发互不阻塞,网络好的设备先执行完先上报,网络差的设备慢慢来,结果都能落库。
Redis在这里的作用主要有两个。一是保存设备在线状态,用set device:online:{deviceId} {channelInfo},并设置过期时间,心跳续期;二是用作任务队列,批量任务可以丢到Redis列表里,由后端线程池消费并按设备分发。这个模型可以平滑扩展到上千台设备。
4.3 事件上报:从设备状态到控制台实时刷新
除了指令下发,云控系统还需要设备主动上报事件。比如设备开机、应用安装完成、应用崩溃、电量低于阈值、Agent启动完成等。事件上报走的是和指令ACK不同的通道,因为它是一对多、异步的,服务端收到事件后要广播给多个控制台用户。
我这边是Agent通过长连接发送一个event消息到服务端,服务端解析后先落Redis做去重,然后通过WebSocket推给控制台前端。WebSocket连接由Spring Boot应用统一管理,每个浏览器会话和用户绑定。这样控制台就能实时刷新设备状态,不用每5秒拉一次接口。
事件消息结构可以复用指令模型,多加一个eventType字段。
{ "eventId": "uuid", "eventType": "device_battery_low", "deviceId": "device001", "timestamp": 1730000000, "payload": { "level": 15 } }事件模型一定要轻量,不要塞太多业务字段。我见过有人把设备完整状态都塞进事件里,Redis和网络带宽都扛不住。事件只放关键数据和关联ID,需要完整信息时前端再查接口。
5. 实操复现:一个最小可用闭环
5.1 环境准备与工程骨架
现在把零散的设计串起来,实现一个最小可用的闭环。目标是:一台Android手机通过Agent连接服务端,在网页控制台里可以看到设备在线,然后下发一条采集设备信息的指令,手机执行后把结果回传到前端展示。
工程建议拆成三个模块:agent-android是手机端工程,server-gateway是Netty网关,server-web是Spring Boot管理端。
cloud-control-demo/ ├── agent-android/ # Android Agent,Kotlin + Netty ├── server-gateway/ # Netty TCP网关 └── server-web/ # Spring Boot管理端 + WebSocket先不要一上来就全量开发,先把Agent到网关的长连接打通,能互相收发字符串即可。我建议用最朴素的方式调试,比如Agent发送“hello”,网关收到后打印日志,再回一条字符串。网络链路通了,再开始设计协议、注册执行器。
5.2 让设备“上线并可见”
Agent启动后连接网关,连接成功后马上发送设备注册消息。
{ "cmdId": "reg-001", "type": "device_register", "params": { "deviceId": "device001", "model": "Pixel 7", "androidVersion": "13", "agentVersion": "1.0.0" } }网关收到注册消息后,在DeviceChannelManager里保存channel,同时把设备状态写入Redis,然后通知server-web刷新在线列表。控制台此时应该能看到device001上线。
这里有一个细节值得注意:设备注册消息不需要走指令ACK链路,它是Agent主动发起的,服务端只需要返回一个“注册成功”即可。不要把它和普通指令混在一个队列里,否则注册逻辑会被业务指令的流量困住。
5.3 下发一条真实指令:采集设备信息
设备在线后,从控制台点一下“采集设备信息”按钮。服务端生成一条指令,通过Netty Channel发给Agent。
Agent收到指令后,由CommandDispatcher路由到DeviceInfoExecutor。这个执行器的逻辑很简单,读取Build.MODEL、Build.VERSION.RELEASE、内存信息、电量信息,组装成一个JSON。
{ "cmdId": "uuid", "type": "device_info", "status": "ok", "result": { "model": "Pixel 7", "androidVersion": "13", "batteryLevel": 86, "memoryUsed": 5120, "foregroundApp": "com.android.settings" } }Agent把回执发送给网关,网关识别出这是一个指令ACK,写入Redis的待处理队列并通知server-web,server-web的WebSocket服务把结果推给前端。整条链路最理想的状态是在1秒内完成。
5.4 屏幕监控与远程触控的细节优化
最小闭环跑通后,大家最想加的往往是“远程看屏幕”和“远程点一下”。这里有两个容易踩坑的地方。
屏幕监控我建议用MediaProjection方案,它在Android 5.0之后就是官方支持的截屏投屏API。如果是在自动化测试场景,也可以考虑minicap这类基于系统底层的方案,帧率更高、延迟更低,但兼容性维护成本也高。MediaProjection需要先弹窗让用户授权,云控场景下建议在Agent初始化时主动引导授权,并把授权状态上报给服务端。
拿到屏幕帧后,不要直接传原始PNG,太大,传一次就要几MB,几十台设备同时看屏幕能把网络打爆。我习惯先压缩,JPEG质量压到70,长边缩到1080以内,单帧控制在300KB以内。如果控制台只是偶尔看一眼屏幕,推荐用“拉取模式”而不是“持续推流”,前端需要画面时再发指令获取一帧。要做实时远控时再切到推流模式,帧率控制在每秒10帧以内。
远程触控方面,无系统权限的普通设备建议走AccessibilityService的dispatchGesture接口,代码大致如下。
GestureDescription.Builder builder = new GestureDescription.Builder(); Path clickPath = new Path(); clickPath.moveTo(x, y); builder.addStroke(new GestureDescription.StrokeDescription(clickPath, 0, 100)); boolean dispatched = accessibilityService.dispatchGesture( builder.build(), callback, null );这条路线的优点是普通应用就能跑,不需要特殊权限,缺点是需要用户手动开启无障碍服务,而且Android在部分品牌设备上对GestureDescription的坐标精度有差异,需要做兼容测试。另外,触摸指令必须等屏幕亮起后再执行,很多所谓“指令失灵”其实是屏幕休眠或者锁屏界面拦截了触摸事件。
6. 常见问题与排障实录
6.1 设备总是掉线?
设备频繁掉线是最常见的投诉。我在实施过程中总结出几个高频原因,排查顺序建议按表格从上到下。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 每次上线几十秒就掉 | 心跳包未发送或服务端空闲判定时间过短 | 看Agent日志是否有心跳发送记录,调整IdleStateHandler参数 |
| 熄屏一段时间后掉线 | Android系统省电策略冻结了Agent进程 | 申请前台服务,并引导用户关闭该App的电池优化 |
| 在网络切换后掉线 | Wi-Fi切移动网络导致TCP连接失效 | Agent监听网络变化事件,网络切换后主动触发重连 |
| 多设备同时掉线 | 服务端GC停顿或网关线程池满 | 检查Netty线程池配置,增加机器或拆分网关实例 |
还有一个容易被忽视的坑:Agent在后台运行时被系统回收,即使前台服务也会在某些国产ROM上失效。可行方案是引导用户把App加入白名单,同时在重连里做“自启动恢复”。
6.2 指令执行超时或卡死
指令执行卡死通常发生在Shell命令执行器或文件操作上。命令本身没有超时控制、进程等待时间设置太长,都会造成线程阻塞。我的做法是统一给所有外部调用加超时,Shell执行器用Process.waitFor(timeout, unit),网络请求用OkHttp的connectTimeout和readTimeout。一切外部调用都可以超时失败,而不是无限期卡住。
另一个高频问题是“指令重发导致重复执行”。比如设备端执行了安装操作但ACK丢包了,服务端超时后重发一次,结果装了两次。解决方案就是前面说的cmdId幂等判断。设备端在执行前先查Redis或本地缓存,如果cmdId已存在就直接返回历史结果。这个功能必须做成基础能力,而不是每个执行器自己去判断。
6.3 触摸指令“失灵”的几大原因
远程触控失灵,多半不是代码逻辑错,而是下面几种情况。
第一,屏幕没亮或者锁屏。很多设备黑屏后,input命令和dispatchGesture都不会生效。执行触摸指令之前,务必先发一条唤醒指令,比如input keyevent KEYCODE_WAKEUP,必要时先解锁屏幕。第二,坐标没有做分辨率适配。不同设备分辨率差异很大,如果用1080x2400的坐标去点1280x720的设备,位置完全不对。服务端下发坐标时建议传归一化坐标,也就是0到1之间的比例值,设备端乘以本地屏幕宽高,能适配绝大多数机型。第三,应用没有获得SYSTEM_ALERT_WINDOW权限,在部分Android版本上会导致注入屏幕事件被系统拦截。第四,无障碍服务被系统关闭,dispatchGesture静默失效,执行器要监听AccessibilityService生命周期,及时上报服务状态。
6.4 从10台到100台的性能优化清单
如果你只管理10台设备,很多问题不会暴露,能跑通就行。一旦上到100台甚至更多,下面这几点就需要提前考虑。
Netty服务端设置合理线程数,不要默认开一堆EventLoop线程,机器核数有限,线程不是越多越好。网关和Spring Boot分开部署,避免长连接占满业务线程池。指令下发不要全局一个队列,每台设备维护独立队列,某个设备执行慢只影响它自己,不影响其他设备。屏幕推流要严格限频,建议一台设备最多同时只有两个控制台观看,否则带宽会迅速打满。日志方面,Agent上报的执行结果不要全部同步写数据库,先写Redis列表,再由任务异步落库。
还有一个压测建议:先拿10台真实设备跑通全链路,再逐渐加到30台、50台,一边加一边观察Netty的连接数、内存占用和指令平均耗时。我见过不少团队一上来就模拟500台设备连接,结果网络层的参数调优没做完,压测报告一点参考价值都没有。
我个人做完这套框架最大的体会是:云控系统的难点不在某个单点技术,而在整个链路的稳定性和可观测性。协议定义清晰、指令有唯一ID、执行有超时、断线能重连,这四个基础能力做到位,系统的基本盘就稳了。接下来再往上面加屏幕远控、自动化脚本编排、设备分组策略这些业务能力,会轻松很多。如果你正打算从零做一个设备集群管理系统,我的建议是先别急着写花哨功能,照着最小闭环一步一步来,跑通之后你就懂整个系统该怎么长了。