☰
PHP+AutoJS打造Android手机云控空白框架:设备与任务调度实践
2026/10/7 11:53:52 网站建设 项目流程

做手机云控开发的朋友,应该都有过这种体会:从网上下载或接手一套现成的云控系统源码,界面、设备列表、任务中心、脚本管理看着都齐,可真要接到自己的项目里,反而处处别扭。要么系统里自带了一堆跟当前业务无关的自动化规则,要么协议写得太死,换一个客户端脚本环境就得大改底层。我后来想明白一件事:真正能长期用的,不是功能最全的系统,而是一个足够"空白"的框架。它只做三件事——设备管理、任务下发、结果回收。基于这个思路,我用PHP搭了一套面向Android设备的手机云控空白框架,配合AutoJS这类脚本环境,可以接进任何业务项目的批量控制脚本运行。这篇文章就把这套框架的骨架、数据库、接口和踩坑过程完整拆开,适合想自己掌控设备调度逻辑的后端开发者,也适合刚接触云控、想弄懂原理的自动化测试工程师。

1. 为什么我把云控系统做成了"空白框架"

1.1 现成云控源码的通病:业务和通信层绑得太死

我最早接触云控,是给测试部门搭一套多设备回归平台。当时拿到的源码号称"全功能",里面设备管理、任务计划、脚本市场、充值计费全都齐了,甚至针对某些特定App的自动操作做了固化判断。刚开始觉得挺好,越用越难受。因为每个新项目都有自己的页面控件、自己的账号体系、自己的业务流程,而源码里那些写死的判断逻辑根本复用不上,想改又不敢乱动,生怕把通信模块改坏了。最后的结果是:核心的"设备连接-脚本执行-结果上报"只有很小一段,剩下大量代码都在跟具体业务纠缠。

那时候我就意识到,一套能适配任何项目的云控系统,最该保留的恰恰是那一小块通信和调度骨架,而不是各种业务功能。于是我把业务全部摘掉,只留下设备上线、任务下发、执行结果回传这条主干,也就是标题里说的"空白框架"。第一次用它接新项目时,整个适配过程愣是快了一倍还多。

1.2 手机云控真正需要抽象的东西只有三样

很多人一听空白框架就觉得什么都不能做,其实恰恰相反。手机云控不管场景怎么变,底层要处理的永远只有三样东西。

第一是设备抽象。一台手机在云控系统眼里就是一组稳定标识:设备ID、分组、在线状态、最近心跳时间。至于这台手机上装了什么App、登录了什么账号,框架一概不关心,那是上层业务脚本的事。

第二是任务抽象。一次批量化控制,本质上就是把某一段脚本带着参数,发给一批设备去执行。任务只关心脚本版本、目标分组、执行参数和执行状态。脚本里面的具体逻辑,框架同样不关心。

第三是结果抽象。设备执行完以后,框架需要拿到"成功或失败、耗时多少、日志在哪",而不是具体的业务结论。业务层拿到原始结果后想怎么统计都行。

这三个抽象定下来,协议就稳定了。不管今天接的是一个打卡脚本,明天接的是App压力测试,后天是一个设备巡检流程,框架都不用动,改动全在脚本和参数层。这就是空白框架能适配"任何平台项目"的原因。

1.3 它适合谁,不适合谁

从一个真实使用的角度说边界。这套框架适合自动化测试、App兼容性验证、批量安装卸载测试、企业内部设备巡检,也适合个人做定时自动化任务。它的前提是你得有一点点PHP或JavaScript的开发能力,至少能看懂脚本上报结果的逻辑。如果团队里完全没有会写脚本的人,那任何云控系统都救不了你,还是老老实实买商业产品。

反过来,如果指望拿到源码就能对某些平台做绕过规则的批量操作,我的建议是不要往这个方向想。框架本身只提供技术底座,自动化操作请务必用在合规的业务场景里。这一点后面所有的接口设计,也都是按"内部调度系统"的假设来的。

2. PHP服务端骨架:一张设备表加一张任务表跑通最小闭环

2.1 数据库:先设计出能跑50台设备的表结构

服务端我用的PHP 8 + MySQL,没上什么重型框架,就一个轻量的路由和数据库封装。核心表只有四张:设备表、脚本表、任务表、任务设备结果表。初期50台设备以内,这套设计完全够用。

CREATE TABLE devices ( id INT AUTO_INCREMENT PRIMARY KEY, device_id VARCHAR(64) NOT NULL UNIQUE COMMENT '客户端唯一标识', alias_name VARCHAR(64) DEFAULT '', group_name VARCHAR(32) DEFAULT 'default', platform VARCHAR(16) DEFAULT 'android', status TINYINT NOT NULL DEFAULT 0 COMMENT '0离线 1在线 2执行中', last_heartbeat INT UNSIGNED DEFAULT 0, ip VARCHAR(64) DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_group_status (group_name, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE scripts ( id INT AUTO_INCREMENT PRIMARY KEY, script_name VARCHAR(64) NOT NULL, version INT NOT NULL DEFAULT 1, file_path VARCHAR(255) NOT NULL, params_template VARCHAR(255) DEFAULT '', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE tasks ( id INT AUTO_INCREMENT PRIMARY KEY, task_name VARCHAR(64) DEFAULT '', script_id INT NOT NULL, target_group VARCHAR(32) DEFAULT 'default', target_devices TEXT, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待执行 1下发中 2执行中 3成功 4失败 5超时', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME DEFAULT NULL, finished_at DATETIME DEFAULT NULL, INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE task_device_results ( id INT AUTO_INCREMENT PRIMARY KEY, task_id INT NOT NULL, device_id VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0未执行 1执行中 2成功 3失败 4超时', result_log TEXT, log_file VARCHAR(255) DEFAULT '', execute_time INT UNSIGNED DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_task_device (task_id, device_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有两个很容易被忽略的设计点。一是devices表不要存业务属性,比如"这设备是哪个账号",这些信息归业务脚本自己维护;框架只认device_id,device_id最好用Android ID这类长期稳定的标识,不要用会随机变化的WiFi MAC。二是task_device_results表加了唯一索引(task_id, device_id),这能挡住客户端重复上报,后面并发那节还会展开讲。

2.2 设备接入层:四个接口撑起整条链路

设备端的接入我认为不需要上WebSocket,复杂而且容易断。短轮询就够了。四个接口:

  • POST /api/device/register,设备首次启动时注册,上报device_id、分组、平台信息,已存在则更新。
  • POST /api/device/heartbeat,设备每隔10秒上报心跳,服务端只更新状态和时间。
  • GET /api/task/poll?device_id=xxx,设备在心跳后顺带询问有没有新任务,有就返回脚本详情。
  • POST /api/task/report,执行完成后上报结果。

心跳接口的PHP逻辑很简单,重点只有一个:别写多余日志,否则50台设备10秒一次心跳能把日志目录刷爆。

public function heartbeat(string $deviceId): void { db()->prepare( 'UPDATE devices SET status=1, last_heartbeat=:time, ip=:ip WHERE device_id=:did' )->execute([ ':time' => time(), ':ip' => $_SERVER['REMOTE_ADDR'] ?? '', ':did' => $deviceId, ]); }

我之前实现的是在心跳时顺便拉任务,接口数量从四个变三个,客户端少一次HTTP请求。但我后来还是拆开了。原因是很多批量化场景要求设备"只心跳不干活",比如凌晨统一待命,这时候把任务查询绑在心跳接口里,服务端反而得在心跳接口里多做任务匹配。拆开后职责清楚,心跳就是心跳,任务就是任务。

2.3 脚本上传与版本管理

脚本管理这块,我没有把脚本内容直接塞进数据库,而是在服务器上维护一个scripts目录,按脚本ID和版本存放文件。数据库只记录文件名和版本号。客户端轮询到任务时,如果本地已经缓存了对应版本,服务端只回一个"直接执行"的信号,不用再传一遍脚本内容。这个优化在脚本普遍几百KB的场景下非常有效。

上传接口一样走PHP,脚本文件通过HTTP上传,服务端保存后自动版号加一。脚本的参数模板用JSON字符串存在params_template字段里,客户端执行前用这个模板校验参数,避免传错漏。

public function uploadScript(): void { $scriptId = (int)$_POST['script_id'] ?? 0; $version = (int)$_POST['version'] ?? 1; $tmpPath = $_FILES['script_file']['tmp_name']; $targetPath = SCRIPTS_DIR . '/' . $scriptId . '_v' . $version . '.js'; if (!move_uploaded_file($tmpPath, $targetPath)) { // 抛出上传失败异常 } db()->prepare( 'UPDATE scripts SET file_path=:path, version=:version WHERE id=:id' )->execute([ ':path' => $targetPath, ':version' => $version, ':id' => $scriptId, ]); }

实际使用时,脚本版本和任务最好绑定:创建任务时记录当时最新的script_id和version,不要任务创建之后脚本换了版本,导致同一批任务执行了新旧两套代码。这一点在批量调度里要特别注意。

2.4 任务状态机:用"抢占式更新"避免重复派发

任务表里的状态字段是整个云控系统最容易出事的地方。我的状态机定义是:0待执行、1下发中、2执行中、3成功、4失败、5超时。其中0到1是设备领取任务时发生的转换,这个转换必须用UPDATE去抢占,不能用"先SELECT再UPDATE"。

举一个典型错误:设备A的两个轮询请求同时到达,都查到了同一行status=0的任务,然后各自执行,最后一条任务被跑了两次。正确做法是让数据库替我们做并发控制:

$stmt = db()->prepare( 'UPDATE tasks SET status=1, started_at=NOW() WHERE id=:id AND status=0' ); $stmt->execute([':id' => $taskId]); if ($stmt->rowCount() === 1) { // 抢占成功,把这个任务返回给当前设备 }

这样即使同时来了10个请求,只有一个能影响一行,其余请求会自动落空。抢占成功后再把完整脚本信息返回给客户端。

3. 客户端接入:把AutoJS类脚本挂到云控中心

3.1 为什么客户端先走轮询,不走长连接

很多做物联网或IM出身的朋友,第一反应是用长连接推送任务,觉得轮询Low。但手机云控的场景跟聊天不一样。脚本任务不是实时强推消息,设备晚个5秒、10秒拿到任务完全无感;反而是手机端网络环境复杂,WiFi切4G、息屏休眠、App被系统清理,长连接在这种环境下维护成本极高。

我在客户端采用的是10秒心跳轮询,轮询时顺便拉任务。实测50台设备对PHP服务端的压力并不大,每秒不到20个请求,完全在承受范围内。如果真到了几百台设备,还可以把心跳放进Redis,服务端只在任务创建时发一个通知标记。框架初期没必要做这种优化,先把链路跑通。

3.2 一个可以直接改的AutoJS接入模板

客户端我以AutoJS/AutoX这类无障碍脚本环境为例,这类工具的脚本引擎是JavaScript,很容易跑起来。你手上如果用的是autois或者其他兼容环境,接入逻辑一样。

下面是客户端轮询循环的最小模板,核心就三块:拉任务、执行任务、报结果。

const CONFIG = { apiBase: 'http://192.168.1.100:8080', deviceId: '这里填设备唯一ID', pollInterval: 10000 }; function httpGet(url) { let res = http.get(url); return res.statusCode === 200 ? res.body.json() : null; } function httpPost(url, data) { http.postJson(url, data); } function executeTask(task) { // task里会带 script_content / script_url / params 三个字段 // 实际场景建议先下载脚本到本地再require执行,避免每次重复下载 engines.execScript(task.task_name, task.script_content, { arguments: JSON.parse(task.params) }); return { success: true, message: 'done' }; } function pollOnce() { let data = httpGet( CONFIG.apiBase + '/api/task/poll?device_id=' + CONFIG.deviceId ); if (!data || !data.task) return; let start = Date.now(); let result = executeTask(data.task); httpPost(CONFIG.apiBase + '/api/task/report', { task_id: data.task.task_id, device_id: CONFIG.deviceId, status: result.success ? 2 : 3, execute_time: Math.floor((Date.now() - start) / 1000), message: result.message }); } setInterval(pollOnce, CONFIG.pollInterval);

这里有一个必须警惕的点:如果任务脚本来自不可信来源,这个接口等于给客户端接了一个远程执行入口。所以poll接口一定要做设备身份校验,最简单的是给每台设备发一个token,请求头带上token,服务端校验通过才返回任务内容。别把这种接口裸奔到公网。

3.3 保活、息屏、异常重启:客户端稳定性的三件事

客户端光有逻辑还不够,手机上跑脚本最怕的是进程被系统回收、息屏后脚本卡死、设备重启后客户端没有自启动。我在这套项目里踩过的坑列一下。

无障碍服务是AutoJS类工具运转的基础,App被强杀后无障碍权限还在,但脚本引擎没了。所以第一件事是保活:把客户端做成前台服务,在通知栏显示一个常驻通知,同时周期性自检,发现脚本进程消失就自动重启。

第二件事是息屏。批量任务常在夜间跑,手机一旦息屏,部分设备会切断网络或暂停后台进程。我的处理是执行长任务前通过脚本点亮屏幕并申请WakeLock,任务结束后再释放。注意这只是常规自动化操作,不是绕过系统限制,我用在测试场景里没出过问题。

第三件事是设备重启。如果设备重启了,客户端必须能跟着自启。做法是在客户端里注册一个开机广播接收器,并在框架里加一个"启动延迟"参数,不同品牌手机开机后网络可用时间不同,统一延迟30秒再注册比较稳。

4. 一次批量调度实录:从1台到50台,任务怎么跑完的

4.1 准备阶段:让设备先形成规模

批量调度的前提是设备和分组在框架里都是可见的。设备可以手动录入,也可以用ADB批量导入。先通过adb devices拿到SN,再批量生成device_id列表,按不同项目打成几个分组。我这里习惯把一台设备唯一对应一个"测试角色",比如"回归机A组"、"兼容性验证组"、"稳定性巡检组",分组名尽量不带业务属性,这样脚本层好复用。

设备上线后,最直观的验证方式是看设备表里status从0变成1,last_heartbeat在持续刷新。只有这一步稳定了,后面脚本下发才有意义。我见过不少人直接跳过验证,结果任务创建了半天,设备根本没上线,白忙一场。

4.2 创建一个批次任务的完整流程

当脚本已经上传并测试通过,设备分组就绪后,一次批量化任务通常是这样的:

  1. 在后台选择脚本和版本。
  2. 选择目标设备分组,可以全组,也可以只选部分设备。
  3. 填写脚本参数,参数用JSON格式下发,比如{"interval":300,"loop":5}。
  4. 保存任务,任务进入0待执行状态。

示例命令:

curl -X POST http://localhost:8080/api/admin/task/create \ -d 'script_id=1&target_group=compat&params={"username":"tester","loop":3}'

任务一创建,设备在下一个10秒轮询周期就会陆续把任务领走。服务端只管在poll接口里返回"你有任务"以及对应脚本信息,执行细节完全由客户端驱动。这种设计的最大好处是设备根据自己的时间错峰执行,不会出现50台手机同时请求超大脚本的尖峰。

4.3 结果回传与统计

任务跑起来以后,最关心的无非两个问题:有多少设备完成了?失败的卡在哪?这时候task_device_results表就派上用场。一条SQL就能看到批次全貌:

SELECT d.group_name, r.status, COUNT(*) FROM task_device_results r JOIN devices d ON r.device_id = d.device_id WHERE r.task_id = 123 GROUP BY d.group_name, r.status;

还可以按设备看明细:

SELECT r.device_id, r.status, r.execute_time, r.log_file FROM task_device_results r WHERE r.task_id = 123 AND r.status <> 2;

批量调度中经常出现一个现象:任务状态显示成功,但业务脚本里的最后一个动作其实没跑完。原因是脚本没有主动退出,执行环境的onExit没触发,导致上报结果时状态已经置成成功,实际逻辑还挂在后台。我的做法是在业务脚本最后强制调用exit(),并把关键步骤的日志逐条写到本地日志文件,等任务结束统一把文件路径上报。这样出问题时,排查效率会高很多。

5. 批量运行后,PHP服务端最容易翻车的四个场景

5.1 心跳写入把数据库连接打满

设备10秒一次心跳,50台设备大概每分钟300个请求。单独看不多,但PHP-FPM每个请求都可能占用一个数据库连接,默认的连接池不够时就会报"Too many connections"。头一次跑50台测试时我就翻过车,并不是SQL写得慢,而是每个心跳请求都带上了一次UPDATE,把连接池挤满了。

解决思路不是买更大的数据库,而是把高频但简单的状态更新挪出关系数据库。我把心跳写进Redis,SET device:{id}:last_heartbeat {timestamp},然后由计划任务每30秒统一从Redis里取一遍心跳数据,批量UPDATE到MySQL。这样MySQL的连接占用就彻底降下去了。设备在线状态的读取也从Redis读,秒查。

5.2 任务被重复领取

前面讲过任务的0到1转换要用UPDATE抢占,但还有一个容易踩的坑:设备上报结果时,服务端如果只是简单地"按task_id更新状态",当同一个任务在实际执行中被重复上报了两次,最后一次会把前一次覆盖。比如设备执行超时后自动重试,第一次上报失败,第二次上报成功,最终记录是成功,可中间那次失败没留下来。

我做了一个折中:task_device_results表里保留"最近一次执行记录",但同时把每次上报追加到一个独立的执行流水表。这样统计正确率时用结果表,排查问题时翻流水表,两边都不耽误。

5.3 大脚本的传输超时与版本错位

脚本一旦超过1MB,每次轮询都拉全文就是一个灾难。有的设备网络差,拉到一半断掉,脚本执行直接失败。解决方案就是之前说的版本缓存:客户端轮询时带上local_version,服务端发现版本一致就不回脚本内容,只返回run指令。只有版本变化时才把全文下发给这台设备。

还有一个小坑是脚本文件在传输过程中被部分写入,客户端拿到半截脚本去执行,报出的错误五花八门。我的做法是上传时先写临时文件,文件完整校验后rename到正式目录;客户端下载脚本时也先下载到本地临时文件,校验完再覆盖缓存。两条链路都做完整,才能避免"半截脚本"问题。

5.4 日志上报变成日志风暴

批量跑脚本时,每一台设备的脚本内部都会输出日志。如果所有日志都通过report接口一条条POST上来,PHP服务端会非常忙,日志文件也会迅速膨胀,磁盘两天就能爆掉。我的处理是:设备端把本次执行的详细日志写进本地文件,report接口只上报日志文件的相对路径和最终状态;服务端需要看详细情况时,再从设备拉取或通过文件收集任务统一同步。如果一定要上报文本,也必须在设备端先截断,比如每条日志最多保留2000字符,避免一个错误堆栈把整张表撑爆。

我最后的体会是,手机云控的复杂度从来不在某一个环节,而在"设备不稳定"这四个字。网络会断、系统会杀进程、设备会重启、脚本会卡死。空白框架能做的,就是把这些不确定性兜住,让上层业务只需要关心脚本本身。这套框架跟我跑了两年多的自动化测试,最大的价值不是某次调度跑得多快,而是半夜设备集体离线时,我能在五分钟内定位到是哪一层出了问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询