Java 后端驱动远程手机自动化:ADB + Python + 内网穿透实战全解

文章目录

  • 前言
    • 一、为什么需要这套架构?
      • [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 &
相关推荐
前端小万1 小时前
探索副业五个月,最终以放弃告终
前端·后端·面试
用户87359865635871 小时前
Bias-Variance Tradeoff
后端
今天早睡1 小时前
TypedDict 还是 Pydantic?
后端
薛定谔的悦1 小时前
从论文公式到 BMS 固件:FreeRTOS 与 EKF SOC 的工程结合
后端
用户1494484813201 小时前
从入口拦截到最终对账:我如何设计一套高并发秒杀系统
后端·架构
Jeromefromhk1 小时前
把服务器当代码管
后端
十年Java程序媛1 小时前
JVM 内存区域与对象内存布局|new 对象底层流程,对象头 MarkWord 详解
java·jvm·spring boot
闽林致远1 小时前
C++ 插件化后台服务架构实践
后端
用户125758524361 小时前
GoFrame Vue3开源后台管理系统推荐:数据库与Worker要求怎么验
数据库·后端·go