文章目录
- 前言
-
- 一、为什么需要这套架构?
-
- [1.1 业务背景](#1.1 业务背景)
- [1.2 方案选型](#1.2 方案选型)
- 二、整体架构
-
- [2.1 数据流向](#2.1 数据流向)
- [2.2 核心组件](#2.2 核心组件)
- [三、Java 后端:双模式路由](#三、Java 后端:双模式路由)
-
- [3.1 模式一:服务器直连(本地开发/测试)](#3.1 模式一:服务器直连(本地开发/测试))
- [3.2 模式二:本地服务代理(生产环境)](#3.2 模式二:本地服务代理(生产环境))
- [3.3 路由决策逻辑](#3.3 路由决策逻辑)
- [四、本地 Flask 服务:HTTP API 层](#四、本地 Flask 服务:HTTP API 层)
- [五、内网穿透:FRP 打通网络](#五、内网穿透:FRP 打通网络)
-
- [5.1 服务端(云服务器)](#5.1 服务端(云服务器))
- [5.2 客户端(本地电脑)](#5.2 客户端(本地电脑))
- [5.3 Nginx 反向代理](#5.3 Nginx 反向代理)
- [六、ADB + uiautomator2:手机自动化核心](#六、ADB + uiautomator2:手机自动化核心)
-
- [6.1 技术原理](#6.1 技术原理)
- [6.2 设备管理器](#6.2 设备管理器)
- [6.3 分步自动化流程](#6.3 分步自动化流程)
- [6.4 UI 元素定位策略](#6.4 UI 元素定位策略)
- [七、JVM 参数配置与启动](#七、JVM 参数配置与启动)
- 八、踩坑记录
-
- [坑 1:ProcessBuilder 中文乱码](#坑 1:ProcessBuilder 中文乱码)
- [坑 2:uiautomator2 的 timeout 参数陷阱](#坑 2:uiautomator2 的 timeout 参数陷阱)
- [坑 3:swipe 方向搞反](#坑 3:swipe 方向搞反)
- [坑 4:App 包名不对](#坑 4:App 包名不对)
- [坑 5:uiautomator2 的 .all() 方法不存在](#坑 5:uiautomator2 的 .all() 方法不存在)
- [坑 6:FRP 连接不稳定](#坑 6:FRP 连接不稳定)
- [坑 7:自动化脚本执行后 App 状态残留](#坑 7:自动化脚本执行后 App 状态残留)
- 九、完整调用时序
- 十、最佳实践总结
- 附录:环境准备清单
前言
从云服务器到物理手机的自动化链路打通:Spring Boot → HTTP 代理 → ADB → uiautomator2 → App 自动化
本文以一个真实的二手车估值项目为背景,详解如何让运行在云服务器的 Java 后端,通过内网穿透远程调用本地电脑上的 Python 服务,再由 Python 借助 ADB 指令操控物理手机完成 App 自动化操作。
一、为什么需要这套架构?
1.1 业务背景
项目需要自动操控手机上的App,完成车辆估值流程(选品牌→选城市→输入里程→获取报价)。问题在于:
- Java 后端部署在云服务器(Linux ECS),无法直接连接物理手机
- 手机必须通过 USB 连接到本地电脑(Windows),才能使用 ADB 调试
- 两端处于不同网络,需要打通通信链路
1.2 方案选型
| 方案 | 原理 | 优缺点 |
|---|---|---|
| Selenium + Chrome 爬取 | 模拟浏览器操作 | 被反爬虫机制拦截 |
| Playwright | 无头浏览器自动化 | 同样被反爬拦截 |
| App 自动化(最终方案) | ADB + uiautomator2 操控真实手机 | 绕过反爬,但需要物理设备 |
二、整体架构
#mermaid-svg-w54ccVNkaJCl7mq2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-w54ccVNkaJCl7mq2 .error-icon{fill:#552222;}#mermaid-svg-w54ccVNkaJCl7mq2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-w54ccVNkaJCl7mq2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-w54ccVNkaJCl7mq2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-w54ccVNkaJCl7mq2 .marker.cross{stroke:#333333;}#mermaid-svg-w54ccVNkaJCl7mq2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-w54ccVNkaJCl7mq2 p{margin:0;}#mermaid-svg-w54ccVNkaJCl7mq2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-w54ccVNkaJCl7mq2 .cluster-label text{fill:#333;}#mermaid-svg-w54ccVNkaJCl7mq2 .cluster-label span{color:#333;}#mermaid-svg-w54ccVNkaJCl7mq2 .cluster-label span p{background-color:transparent;}#mermaid-svg-w54ccVNkaJCl7mq2 .label text,#mermaid-svg-w54ccVNkaJCl7mq2 span{fill:#333;color:#333;}#mermaid-svg-w54ccVNkaJCl7mq2 .node rect,#mermaid-svg-w54ccVNkaJCl7mq2 .node circle,#mermaid-svg-w54ccVNkaJCl7mq2 .node ellipse,#mermaid-svg-w54ccVNkaJCl7mq2 .node polygon,#mermaid-svg-w54ccVNkaJCl7mq2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-w54ccVNkaJCl7mq2 .rough-node .label text,#mermaid-svg-w54ccVNkaJCl7mq2 .node .label text,#mermaid-svg-w54ccVNkaJCl7mq2 .image-shape .label,#mermaid-svg-w54ccVNkaJCl7mq2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-w54ccVNkaJCl7mq2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-w54ccVNkaJCl7mq2 .rough-node .label,#mermaid-svg-w54ccVNkaJCl7mq2 .node .label,#mermaid-svg-w54ccVNkaJCl7mq2 .image-shape .label,#mermaid-svg-w54ccVNkaJCl7mq2 .icon-shape .label{text-align:center;}#mermaid-svg-w54ccVNkaJCl7mq2 .node.clickable{cursor:pointer;}#mermaid-svg-w54ccVNkaJCl7mq2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-w54ccVNkaJCl7mq2 .arrowheadPath{fill:#333333;}#mermaid-svg-w54ccVNkaJCl7mq2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-w54ccVNkaJCl7mq2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-w54ccVNkaJCl7mq2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-w54ccVNkaJCl7mq2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-w54ccVNkaJCl7mq2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-w54ccVNkaJCl7mq2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-w54ccVNkaJCl7mq2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-w54ccVNkaJCl7mq2 .cluster text{fill:#333;}#mermaid-svg-w54ccVNkaJCl7mq2 .cluster span{color:#333;}#mermaid-svg-w54ccVNkaJCl7mq2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-w54ccVNkaJCl7mq2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-w54ccVNkaJCl7mq2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-w54ccVNkaJCl7mq2 .icon-shape,#mermaid-svg-w54ccVNkaJCl7mq2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-w54ccVNkaJCl7mq2 .icon-shape p,#mermaid-svg-w54ccVNkaJCl7mq2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-w54ccVNkaJCl7mq2 .icon-shape .label rect,#mermaid-svg-w54ccVNkaJCl7mq2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-w54ccVNkaJCl7mq2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-w54ccVNkaJCl7mq2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-w54ccVNkaJCl7mq2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 物理手机 Android
本地电脑 Windows
内网穿透
云服务器
HTTP 请求
判断模式
false: 服务器直连
true: 本地服务
公网域名
隧道转发
localhost:5001
subprocess
uiautomator2
USB 连接
UI 自动化
Selenium/Playwright
前端/企业微信 Bot
Spring Boot 后端
Che300ProxyController
useLocalService?
ProcessBuilder
执行 Python 脚本
HttpURLConnection
HTTP 代理调用
FRP Server
云服务器:7000
FRP Client
本地电脑
Flask 本地服务
local_phone_service.py
ADB 指令
Python 自动化脚本
main.py
车300 App
车300 网站
2.1 数据流向
用户请求 → Java 后端 → [HTTP 代理 → FRP 穿透 → 本地 Flask 服务] → ADB/uiautomator2 → 手机 App → 结果原路返回
2.2 核心组件
| 组件 | 技术栈 | 职责 |
|---|---|---|
| Java 后端 | Spring Boot 3 + JDK 17 | 接收请求、路由分发、结果处理 |
| 本地 Flask 服务 | Python Flask | HTTP API 包装 ADB 和自动化脚本 |
| FRP 内网穿透 | frps + frpc | 将本地 5001 端口映射到公网 |
| ADB | Android Debug Bridge | 底层设备通信(启动 App、截图、输入) |
| uiautomator2 | Python 库 | 高级 UI 自动化(元素定位、点击、滑动) |
| 自动化脚本 | Python + steps 模式 | 分步骤执行估值流程 |
三、Java 后端:双模式路由
Java 后端的核心控制器 Che300ProxyController 支持两种执行模式,通过 JVM 参数切换:
java
@RestController
@RequestMapping("/merchant/che300")
public class Che300ProxyController extends BaseController {
// 本地手机服务地址(通过 JVM 参数注入)
private static final String LOCAL_PHONE_SERVICE_URL =
System.getProperty("che300.localServiceUrl", "http://localhost:5001");
// 是否使用本地手机服务
private static final boolean USE_LOCAL_PHONE_SERVICE =
Boolean.parseBoolean(System.getProperty("che300.useLocalService", "false"));
}
3.1 模式一:服务器直连(本地开发/测试)
当 useLocalService=false 时,Java 后端通过 ProcessBuilder 直接在服务器上执行 Python 脚本:
java
private String executeMobileAppScript(String brand, String model, ...) {
List<String> command = new ArrayList<>();
command.add("python");
command.add("pythonMobile/main.py");
command.add("--brand");
command.add(brand);
command.add("--model");
command.add(model);
// ... 其他参数
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
// 关键:解决中文乱码
pb.environment().put("PYTHONIOENCODING", "utf-8");
pb.environment().put("LC_ALL", "zh_CN.UTF-8");
Process process = pb.start();
// 读取输出
StringBuilder output = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
log.info("[Mobile] {}", line);
output.append(line);
}
}
process.waitFor();
return output.toString().trim();
}
3.2 模式二:本地服务代理(生产环境)
当 useLocalService=true 时,Java 后端通过 HTTP 调用本地电脑上的 Flask 服务:
java
private String callLocalPhoneService(String path, Map<String, Object> requestBody) {
String urlString = LOCAL_PHONE_SERVICE_URL + path;
// 例如: https://nmcd114638699.vicp.fun/api/che300/valuation
URL url = new URL(urlString);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
conn.setConnectTimeout(10000); // 连接超时 10s
conn.setReadTimeout(300000); // 读取超时 5min(自动化操作耗时)
// 写入 JSON 请求体
String jsonBody = objectMapper.writeValueAsString(requestBody);
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
// 读取响应
int responseCode = conn.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
return response.toString();
}
}
return null;
}
3.3 路由决策逻辑
java
@PostMapping("/valuation/app/sync")
public AjaxResult valuationAppSync(@RequestBody Map<String, String> params) {
String brand = params.get("brand");
String model = params.get("model");
// ... 参数校验
// 优先尝试本地手机服务
if (USE_LOCAL_PHONE_SERVICE) {
try {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("brand", brand);
requestBody.put("model", model);
// ... 填充参数
String result = callLocalPhoneService("/api/che300/valuation", requestBody);
if (result != null) {
return AjaxResult.success("估值成功", parseJson(result));
}
} catch (Exception e) {
log.error("本地手机服务调用异常: {}", e.getMessage());
}
}
// 降级:服务器直接执行脚本
String result = executeMobileAppScript(brand, model, ...);
return AjaxResult.success("估值成功", parseJson(result));
}
四、本地 Flask 服务:HTTP API 层
本地电脑上运行的 Flask 服务,将 HTTP 请求转换为 ADB 指令和自动化脚本调用:
python
# local_phone_service.py
from flask import Flask, request, jsonify
import subprocess
import sys
app = Flask(__name__)
# 修复 Windows 终端编码问题
if sys.platform == 'win32':
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
def run_adb_command(cmd_list, timeout=30):
"""执行 ADB 命令"""
full_cmd = ['adb'] + cmd_list
result = subprocess.run(
full_cmd, capture_output=True, text=True,
timeout=timeout, encoding='utf-8'
)
return result.returncode, result.stdout, result.stderr
@app.route('/api/health', methods=['GET'])
def health():
"""健康检查 + 设备连接状态"""
returncode, stdout, stderr = run_adb_command(['devices'])
lines = stdout.strip().split('\n')
devices = [line for line in lines[1:] if line.strip() and 'device' in line]
if not devices:
return jsonify({"code": 500, "msg": "未检测到设备"})
return jsonify({
"code": 200,
"data": {"device_id": devices[0].split()[0]}
})
@app.route('/api/che300/valuation', methods=['POST'])
def che300_valuation():
"""车300估值 - 调用手机 App 自动化"""
data = request.get_json() or {}
brand = data.get('brand')
model = data.get('model')
year = data.get('year')
mileage = data.get('mileage')
# ...
# 调用自动化脚本
result = subprocess.run(
['python', 'pythonMobile/main.py',
'--brand', brand, '--model', model,
'--year', year, '--mileage', mileage, ...],
capture_output=True, text=True, timeout=300,
encoding='utf-8'
)
return jsonify({"code": 200, "data": json.loads(result.stdout)})
app.run(host='0.0.0.0', port=5001)
五、内网穿透:FRP 打通网络
云服务器无法直接访问本地电脑的 5001 端口,使用 FRP(Fast Reverse Proxy)实现内网穿透:
5.1 服务端(云服务器)
ini
# frps.ini
[common]
bind_port = 7000
5.2 客户端(本地电脑)
ini
# frpc.ini
[common]
server_addr = 你的云服务器公网IP
server_port = 7000
[che300-phone]
type = tcp
local_ip = 127.0.0.1
local_port = 5001 # 本地 Flask 服务端口
remote_port = 5002 # 云服务器映射端口
5.3 Nginx 反向代理
nginx
location /api/phone/ {
proxy_pass http://127.0.0.1:5002; # FRP 映射端口
proxy_set_header Host $host;
proxy_read_timeout 300s; # 自动化操作需要较长时间
}
或者使用花生壳等商业内网穿透服务,获得更稳定的域名:
bash
# JVM 启动参数指定花生壳域名
-Dche300.localServiceUrl=https://nmcd114638699.vicp.fun
六、ADB + uiautomator2:手机自动化核心
6.1 技术原理
Android 手机 ADB Server uiautomator2 Flask 服务 Android 手机 ADB Server uiautomator2 Flask 服务 #mermaid-svg-nx8Nmc2XU1qCHWq4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .error-icon{fill:#552222;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .marker.cross{stroke:#333333;}#mermaid-svg-nx8Nmc2XU1qCHWq4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nx8Nmc2XU1qCHWq4 p{margin:0;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nx8Nmc2XU1qCHWq4 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-nx8Nmc2XU1qCHWq4 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-nx8Nmc2XU1qCHWq4 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .sequenceNumber{fill:white;}#mermaid-svg-nx8Nmc2XU1qCHWq4 #sequencenumber{fill:#333;}#mermaid-svg-nx8Nmc2XU1qCHWq4 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .messageText{fill:#333;stroke:none;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .labelText,#mermaid-svg-nx8Nmc2XU1qCHWq4 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .loopText,#mermaid-svg-nx8Nmc2XU1qCHWq4 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-nx8Nmc2XU1qCHWq4 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .noteText,#mermaid-svg-nx8Nmc2XU1qCHWq4 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .actorPopupMenu{position:absolute;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-nx8Nmc2XU1qCHWq4 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nx8Nmc2XU1qCHWq4 .actor-man circle,#mermaid-svg-nx8Nmc2XU1qCHWq4 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-nx8Nmc2XU1qCHWq4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} u2.connect() TCP:5037 通信 USB 连接 d.app_start("com.che300") adb shell am start ... 启动 App d(text="估值").click() adb shell input tap x y 模拟触摸 d.dump_hierarchy() adb shell uiautomator dump 获取 UI 树 XML 布局数据 返回 XML 解析后的元素信息
6.2 设备管理器
python
# device_manager.py
import uiautomator2 as u2
class DeviceManager:
def __init__(self, device_id=None):
self.device_id = device_id
self.d = None
def connect(self) -> bool:
"""连接设备"""
if self.device_id:
self.d = u2.connect(self.device_id)
else:
self.d = u2.connect() # 自动连接第一个设备
return True
def restart_app(self) -> bool:
"""重启 App(确保从首页开始)"""
self.d.app_stop("com.car300.activity")
time.sleep(2)
self.d.app_start("com.car300.activity")
time.sleep(5)
return True
def find_and_click(self, **kwargs) -> bool:
"""查找并点击元素"""
element = self.d(**kwargs)
if element.wait(timeout=10):
element.click()
return True
return False
6.3 分步自动化流程
采用步骤模式,每个步骤独立为一个 Python 文件:
pythonMobile/
├── main.py # 主入口,串联所有步骤
├── device_manager.py # 设备管理(连接、启动、截图)
├── pricing_logic.py # 定价计算逻辑
├── config.py # 配置(包名、超时等)
├── steps/
│ ├── step_01_open_app.py # 打开车300 App
│ ├── step_02_close_ad.py # 关闭弹窗广告
│ ├── step_03_click_valuation.py # 点击估值按钮
│ ├── step_04_select_brand.py # 选择品牌
│ ├── step_06_select_city.py # 选择城市
│ ├── step_07_select_date.py # 选择上牌日期
│ ├── step_08_input_mileage.py # 输入里程
│ ├── step_09_select_color.py # 选择颜色
│ ├── step_10_click_valuate.py # 点击估值
│ ├── step_11_extract_result.py # 提取报价结果
│ └── step_15_handle_share.py # 处理分享弹窗
└── utils/
├── adb_helper.py # ADB 底层命令封装
└── ui_automation.py # UI 自动化高级封装
主流程串联:
python
# main.py
class Che300Automation:
def run(self, params: dict) -> dict:
# 步骤1:连接设备
self.device.connect()
# 步骤2:打开 App
step_01_open_app.execute(self.device)
# 步骤3:关闭广告
step_02_close_ad.execute(self.device)
# 步骤4:点击估值
step_03_click_valuation.execute(self.device, params)
# 步骤5-7:选择品牌、车型、年款
step_04_select_brand.execute(self.device, params)
# 步骤8:选择城市
step_06_select_city.execute(self.device, params)
# 步骤9:选择上牌时间
step_07_select_date.execute(self.device, params)
# 步骤10:输入里程
step_08_input_mileage.execute(self.device, params)
# 步骤11:点击估值
step_10_click_valuate.execute(self.device)
# 步骤12:提取结果
result = step_11_extract_result.execute(self.device, params)
return result
6.4 UI 元素定位策略
python
# utils/ui_automation.py
class UIAutomation:
def safe_click(self, **kwargs) -> bool:
"""安全点击:先等待元素出现,再点击"""
timeout = kwargs.pop('timeout', 10) # 关键:先提取 timeout
element = self.d(**kwargs) # 再传入选择器
if element.wait(timeout=timeout):
element.click()
return True
return False
def safe_input(self, text: str, **kwargs) -> bool:
"""安全输入"""
timeout = kwargs.pop('timeout', 10)
element = self.d(**kwargs)
if element.wait(timeout=timeout):
element.set_text(text)
return True
return False
def scroll_to_find(self, text: str, direction="up", max_scrolls=5):
"""滚动查找元素"""
for _ in range(max_scrolls):
if self.d(text=text).exists:
return self.d(text=text)
if direction == "up":
self.d.swipe(0.5, 0.8, 0.5, 0.2) # 向上滑:列表内容向上
else:
self.d.swipe(0.5, 0.2, 0.5, 0.8)
time.sleep(0.5)
return None
七、JVM 参数配置与启动
在 restart.sh 中通过 JVM 系统属性注入配置:
bash
#!/bin/bash
# restart.sh
# 车300 本地手机服务配置
CHE300_USE_LOCAL_SERVICE="true"
CHE300_LOCAL_SERVICE_URL="https://nmcd114638699.vicp.fun" # 花生壳域名
# 构建 JVM 参数
JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom \
-Dsecurerandom.source=file:/dev/./urandom \
-Dspring.devtools.restart.enabled=false"
if [ -n "$CHE300_USE_LOCAL_SERVICE" ]; then
JAVA_OPTS="$JAVA_OPTS -Dche300.useLocalService=$CHE300_USE_LOCAL_SERVICE"
fi
if [ -n "$CHE300_LOCAL_SERVICE_URL" ]; then
JAVA_OPTS="$JAVA_OPTS -Dche300.localServiceUrl=$CHE300_LOCAL_SERVICE_URL"
fi
# 启动
nohup java $JAVA_OPTS -jar ruoyi-admin.jar --spring.profiles.active=prod > nohup.out 2>&1 &
八、踩坑记录
坑 1:ProcessBuilder 中文乱码
现象:Java 调用 Python 脚本后,输出的中文变成乱码。
原因 :ProcessBuilder 创建的子进程默认使用系统编码(Linux 上可能是 C 或 ANSI_X3.4-1968),不支持 UTF-8。
解决:
java
ProcessBuilder pb = new ProcessBuilder(command);
// 关键:设置 Python 进程的环境变量
pb.environment().put("PYTHONIOENCODING", "utf-8");
pb.environment().put("LC_ALL", "zh_CN.UTF-8");
// 读取输出时也要指定 UTF-8
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)
坑 2:uiautomator2 的 timeout 参数陷阱
现象 :调用 self.d(text="估值", timeout=10) 报错或静默失败。
原因 :timeout 不是 UI 选择器的参数,而是 wait() 方法的参数。混入 **kwargs 会被错误解析。
解决:
python
# 错误写法
element = self.d(text="估值", timeout=10) # timeout 被当作选择器参数
# 正确写法
timeout = kwargs.pop('timeout', 10) # 先提取 timeout
element = self.d(**kwargs) # 再传入选择器
element.wait(timeout=timeout) # 单独调用 wait
坑 3:swipe 方向搞反
现象:想向上滚动列表,结果页面向下滑了。
原因 :swipe 的坐标参数描述的是手指滑动方向,而列表内容的滚动方向与手指方向相反。
python
# 手指从下往上滑 → 列表内容往下滚(看到上面的内容)
d.swipe(0.5, 0.8, 0.5, 0.2) # swipe up = 内容向上滚动
# 手指从上往下滑 → 列表内容往上滚(看到下面的内容)
d.swipe(0.5, 0.2, 0.5, 0.8) # swipe down = 内容向下滚动
坑 4:App 包名不对
现象 :d.app_start("com.che300") 启动失败。
原因 :车300 专业版的实际包名是 com.car300.activity,不是 com.che300。
解决:通过 ADB 查询真实包名:
bash
adb shell pm list packages | grep 300
# 输出: package:com.car300.activity
坑 5:uiautomator2 的 .all() 方法不存在
现象 :self.d(text="奔驰").all() 报 AttributeError。
原因 :uiautomator2 的 Python 版返回的是 UiObject,不支持 .all() 方法(那是 Java 版的 API)。
解决:
python
# 不支持
elements = self.d(text="奔驰").all()
# 替代方案:使用 xpath
elements = self.d.xpath('//node[@text="奔驰"]').all()
# 或者使用 info 获取元素数量
count = self.d(text="奔驰").count
坑 6:FRP 连接不稳定
现象:云服务器调用本地服务偶发超时。
解决:
- 使用花生壳等商业穿透服务替代自建 FRP
- 设置合理的超时时间(自动化操作可能需要 3-5 分钟)
- Java 端添加降级逻辑:本地服务不可用时回退到服务器脚本
坑 7:自动化脚本执行后 App 状态残留
现象:第二次执行时,App 停留在上次的页面,导致元素找不到。
解决:每次执行前先强制重启 App:
python
def restart_app(self):
self.d.app_stop(self.package_name)
time.sleep(2)
self.d.app_start(self.package_name)
time.sleep(5)
九、完整调用时序
手机 App uiautomator2 本地 Flask FRP Server Spring Boot 用户/前端 手机 App uiautomator2 本地 Flask FRP Server Spring Boot 用户/前端 #mermaid-svg-s5ig417rvYLBtuB2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s5ig417rvYLBtuB2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s5ig417rvYLBtuB2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s5ig417rvYLBtuB2 .error-icon{fill:#552222;}#mermaid-svg-s5ig417rvYLBtuB2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s5ig417rvYLBtuB2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s5ig417rvYLBtuB2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s5ig417rvYLBtuB2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s5ig417rvYLBtuB2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s5ig417rvYLBtuB2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s5ig417rvYLBtuB2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s5ig417rvYLBtuB2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s5ig417rvYLBtuB2 .marker.cross{stroke:#333333;}#mermaid-svg-s5ig417rvYLBtuB2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s5ig417rvYLBtuB2 p{margin:0;}#mermaid-svg-s5ig417rvYLBtuB2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-s5ig417rvYLBtuB2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-s5ig417rvYLBtuB2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-s5ig417rvYLBtuB2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-s5ig417rvYLBtuB2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-s5ig417rvYLBtuB2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-s5ig417rvYLBtuB2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-s5ig417rvYLBtuB2 .sequenceNumber{fill:white;}#mermaid-svg-s5ig417rvYLBtuB2 #sequencenumber{fill:#333;}#mermaid-svg-s5ig417rvYLBtuB2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-s5ig417rvYLBtuB2 .messageText{fill:#333;stroke:none;}#mermaid-svg-s5ig417rvYLBtuB2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-s5ig417rvYLBtuB2 .labelText,#mermaid-svg-s5ig417rvYLBtuB2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-s5ig417rvYLBtuB2 .loopText,#mermaid-svg-s5ig417rvYLBtuB2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-s5ig417rvYLBtuB2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-s5ig417rvYLBtuB2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-s5ig417rvYLBtuB2 .noteText,#mermaid-svg-s5ig417rvYLBtuB2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-s5ig417rvYLBtuB2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-s5ig417rvYLBtuB2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-s5ig417rvYLBtuB2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-s5ig417rvYLBtuB2 .actorPopupMenu{position:absolute;}#mermaid-svg-s5ig417rvYLBtuB2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-s5ig417rvYLBtuB2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-s5ig417rvYLBtuB2 .actor-man circle,#mermaid-svg-s5ig417rvYLBtuB2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-s5ig417rvYLBtuB2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 仅适用于本地开发 alt 本地服务模式 服务器直连模式 POST /merchant/che300/valuation/app/sync 检查 USE_LOCAL_PHONE_SERVICE HTTP POST (公网域名) TCP 转发 (localhost:5001) 调用自动化脚本 连接设备 + 启动 App 选品牌 → 选城市 → 输入里程 点击估值按钮 显示报价结果 截图 + OCR 提取价格 返回 JSON 结果 HTTP 200 响应数据 ProcessBuilder 执行 Python AjaxResult.success(估值结果)
十、最佳实践总结
| 实践 | 说明 |
|---|---|
| 双模式切换 | 通过 JVM 参数控制执行模式,开发用直连,生产用代理 |
| 降级策略 | 本地服务不可用时自动回退到服务器脚本执行 |
| 步骤模块化 | 每个自动化步骤独立文件,便于单独调试和维护 |
| 编码统一 | Java 端设置 PYTHONIOENCODING=utf-8,Python 端 reconfigure(encoding='utf-8') |
| 超时保护 | HTTP 读取超时设为 5 分钟,每步操作设 10 秒元素等待超时 |
| 状态重置 | 每次执行前重启 App,避免残留状态干扰 |
| 广告检测 | 每个步骤执行前检查并关闭弹窗广告 |
| 内网穿透 | 使用商业穿透服务(花生壳)获得更稳定的连接 |
附录:环境准备清单
本地电脑(Windows)
bash
# 1. 安装 Python 3.8+
python --version
# 2. 安装依赖
pip install flask uiautomator2
# 3. 安装 ADB(Android SDK Platform Tools)
# 下载: https://developer.android.com/studio/releases/platform-tools
# 添加到 PATH
# 4. 手机开启 USB 调试
# 设置 → 关于手机 → 连续点击"版本号"7次 → 开发者选项 → USB调试
# 5. 连接手机并授权
adb devices
# 手机上弹出"允许USB调试"→ 点击允许
# 6. 安装 uiautomator2 到手机
python -m uiautomator2 init
# 7. 启动本地服务
python local_phone_service.py --port 5001
# 8. 启动 FRP 客户端(或花生壳)
frpc.exe -c frpc.ini
云服务器(Linux)
bash
# 1. 启动 FRP 服务端
./frps -c frps.ini
# 2. JVM 参数启动 Java 应用
nohup java \
-Dche300.useLocalService=true \
-Dche300.localServiceUrl=https://你的穿透域名 \
-jar ruoyi-admin.jar \
--spring.profiles.active=prod > nohup.out 2>&1 &