
做手机云控开发的朋友应该都有过这种体会从网上下载或接手一套现成的云控系统源码界面、设备列表、任务中心、脚本管理看着都齐可真要接到自己的项目里反而处处别扭。要么系统里自带了一堆跟当前业务无关的自动化规则要么协议写得太死换一个客户端脚本环境就得大改底层。我后来想明白一件事真正能长期用的不是功能最全的系统而是一个足够空白的框架。它只做三件事——设备管理、任务下发、结果回收。基于这个思路我用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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个很容易被忽略的设计点。一是devices表不要存业务属性比如这设备是哪个账号这些信息归业务脚本自己维护框架只认device_iddevice_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_idxxx设备在心跳后顺带询问有没有新任务有就返回脚本详情。POST /api/task/report执行完成后上报结果。心跳接口的PHP逻辑很简单重点只有一个别写多余日志否则50台设备10秒一次心跳能把日志目录刷爆。public function heartbeat(string $deviceId): void { db()-prepare( UPDATE devices SET status1, 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的两个轮询请求同时到达都查到了同一行status0的任务然后各自执行最后一条任务被跑了两次。正确做法是让数据库替我们做并发控制$stmt db()-prepare( UPDATE tasks SET status1, started_atNOW() WHERE id:id AND status0 ); $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变成1last_heartbeat在持续刷新。只有这一步稳定了后面脚本下发才有意义。我见过不少人直接跳过验证结果任务创建了半天设备根本没上线白忙一场。4.2 创建一个批次任务的完整流程当脚本已经上传并测试通过设备分组就绪后一次批量化任务通常是这样的在后台选择脚本和版本。选择目标设备分组可以全组也可以只选部分设备。填写脚本参数参数用JSON格式下发比如{interval:300,loop:5}。保存任务任务进入0待执行状态。示例命令curl -X POST http://localhost:8080/api/admin/task/create \ -d script_id1target_groupcompatparams{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把连接池挤满了。解决思路不是买更大的数据库而是把高频但简单的状态更新挪出关系数据库。我把心跳写进RedisSET 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字符避免一个错误堆栈把整张表撑爆。我最后的体会是手机云控的复杂度从来不在某一个环节而在设备不稳定这四个字。网络会断、系统会杀进程、设备会重启、脚本会卡死。空白框架能做的就是把这些不确定性兜住让上层业务只需要关心脚本本身。这套框架跟我跑了两年多的自动化测试最大的价值不是某次调度跑得多快而是半夜设备集体离线时我能在五分钟内定位到是哪一层出了问题。