造一个"录制回放 + LLM 自主操作手机"的 Android 自动化测试平台(一)整体架构与录制引擎

从 0 到 1:造一个"录制回放 + LLM 自主操作手机"的 Android 自动化测试平台(一)------整体架构与录制引擎

本系列共三篇,这是第一篇:

  1. 整体架构与录制引擎------把手指动作变成可靠脚本(本文)
  2. ReAct 引擎与调度对账------让 LLM 自己点手机
  3. 客户端与后台工程细节------那些让我熬夜的 Bug 们

一、为什么要造这个轮子

做 Android 测试的同学大概率经历过这几件事:

  • Appium / uiautomator2 环境又重又脆:装一套客户端、起一个 server、配desired capabilities,跑之前先折腾半小时,版本一升级全崩;
  • 录制脚本一跑就碎:录的时候好好的,回放时页面多转了个圈、控件多了个 loading,坐标一点,点到了无关的地方,脚本直接失败;
  • 探索式测试没法用脚本表达。"去删掉昨天创建的那条任务"------这种带自然语言理解的目标,写脚本根本无从下手,只能人肉点一遍。

所以我想做的平台形态是双引擎

  • 录制回放:真人拿手指在真机上点一遍,自动生成带多候选定位的脚本,回放稳定;
  • LLM 自主探索:给一句自然语言目标,让多模态大模型看着屏幕自己操作真机,一步步逼近目标。

再配上一个 Windows 客户端开箱即用(测试同学不需要懂命令行),一个 Web 后台做资产管理。整个项目的技术栈:

技术
后端 FastAPI + SQLite(WAL)
本地代理 Python agent(DroidRun + adb + scrcpy)
桌面客户端 Flutter for Windows
管理后台 Vue3 + Element Plus
真机执行 DroidRun Portal(无障碍服务)优先 + ADB 直连兜底(双引擎)

站在 DroidRun 的肩膀上

必须先说明:这个项目不是全部从零写的。真机端的动作执行依赖 DroidRun------一个 9k+ star 的开源移动 Agent 框架(主框架 MIT 协议)。它解决的是"怎么在真机上可靠地执行动作"这个问题:

  • DroidRun Portal :安装到真机上的无障碍服务 APK(我们设备上装的是 mobilerun 版本,包名 com.mobilerun.portal),能以无障碍服务身份直接对控件执行点击、输入、滑动------直接作用到控件而非坐标,且不占用 uiautomator 自动化通道。Portal 只通过本地 ADB 通信(TCP 端口转发或 content provider),不对外联网;
  • SDK 驱动 :本地 Agent 通过 DroidRun 的 Python SDK(包名 mobilerun)驱动 Portal 完成动作;
  • 内置分发 :Portal APK 直接打包在 Agent 的 assets/ 里,POST /install/portal 一键装到新接入的设备,免去手动下载开启无障碍服务的流程;
  • 双引擎设计 :执行引擎检测到 droidrun SDK 可用且设备已装 Portal 时走 engine="droidrun"(无障碍通道),否则自动降级 engine="adb"(adb + uiautomator dump 解析 + input 命令实现全部动作),两个引擎实现同一个 Engine 接口,上层无感。

License 合规提一句:DroidRun 主框架 MIT,但 Portal APK 据其文档演进可能切换协议(我们调研时记录过 AGPL-3.0 的提示),商业闭源集成前建议自行确认,内部使用不受影响。

那本文的重头戏------录制链路(getevent 采集、dump 解析、多候选定位)------为什么和 Portal 无关?因为录制走的是纯 ADB 通道 :真人手指的触摸事件来自内核,控件信息来自 uiautomator dump,这些都是系统原生能力,不依赖任何第三方 App。Portal 属于回放和 ReAct 的执行通道,第二篇会再见到它。

二、整体架构:一次点击的完整旅程

先上架构图:

四端各司其职:

  • Flutter 客户端:测试同学的日常入口,登录后自动拉起本地 Agent,提供录制脚本、scrcpy 真机投屏、任务执行、实时进度预览;
  • 本地 Agent(Python) :装在测试机上,独占所有设备操作------adb、getevent、uiautomator dump、scrcpy 全在这一层;
  • FastAPI 后端:资产管理 + 任务调度中心,SQLite 单文件存储,对 LLM 做统一网关代理(API key 不下发到客户端);
  • Vue3 Web 后台:项目/脚本/用例管理,任务下发,任务详情的步骤时间线与报告导出。

图里四条编号链路就是平台的核心工作流:

  1. 录制链路 :真人手指操作真机,Agent 通过 getevent 采集触摸事件,换算坐标、匹配 UI 控件,生成多候选定位脚本存到后台;
  2. 任务链路 :客户端建任务 → 后端调度器通过 /ws/agent WebSocket 直发 Agent → Agent 执行并实时上报 accepted / progress / done(进度消息里直接内嵌 base64 截图);
  3. 预览链路:客户端每 1 秒轮询 Agent 的 REST 接口,任务执行到哪一步、当前屏幕什么样,全部实时可见;
  4. ReAct 链路:Agent 截图标注后请求后端 LLM 代理,模型给出决策(点哪里/输什么/等一下),Agent 翻译成 adb 动作。

为什么是 agent 直连后端(架构 v2)?

初版走的是"客户端 WebSocket 中继":后端把 task_dispatch 发给客户端,客户端转发给 Agent。跑起来后问题一大堆------客户端一断线任务就悬空、中继逻辑和 UI 生命周期搅在一起、多任务并发时消息时序混乱。

重构后客户端 WS 退化为纯展示(只收连接状态),任务消息全部走 Agent 与后端之间的专用 WebSocket。这个改动让"任务执行"和"UI 存活"彻底解耦:客户端崩了任务照跑,Agent 重启了靠对账恢复。对账协议的细节留到第二篇展开。

三、录制回放引擎:把手指动作变成可靠脚本

这是整个项目干货密度最高的模块,分三步讲:采集、dump、定位。

3.1 getevent 采集与坐标换算:不换算必炸

录制时 Agent 起一个 getevent -ql 长进程流式读取内核触摸事件。第一个大坑就出在这里------getevent 上报的不是屏幕逻辑坐标,而是触摸轴原始值

bash 复制代码
/dev/input/event5: EV_ABS ABS_MT_POSITION_X 00001e5f
/dev/input/event5: EV_ABS ABS_MT_POSITION_Y 00000b2a
/dev/input/event5: EV_KEY BTN_TOUCH DOWN

RMX2202 这台设备上,X 轴 max 是 8639 ,对应屏幕逻辑宽度 1080。也就是说你手指点在屏幕正中间,getevent 报出来的是 4300 多,而不是 540。

换算公式很朴素:逻辑坐标 = 原始值 × 屏幕分辨率 ÷ 轴 max。先解析 getevent -pl 拿到每个输入设备的轴最大值:

python 复制代码
def touch_axes(self, serial: str) -> dict[str, dict[str, int]]:
    """触摸屏轴最大值:{"/dev/input/eventX": {"x": xmax, "y": ymax}}。

    getevent 上报的 ABS_MT_POSITION_* 是原始触摸轴坐标(常见为逻辑分辨率的
    整数倍,如 8639 对应 1080 逻辑宽),必须按轴 max 换算为屏幕逻辑坐标。
    解析失败返回空 dict(调用方跳过换算)。
    """
    out = self.shell(serial, ["getevent", "-pl"], timeout=15)
    axes = {}
    node, cur = None, {}
    for line in out.splitlines():
        m = re.match(r"add device \d+:\s*(/dev/input/\S+)", line.strip())
        if m:
            if node and cur.get("x") and cur.get("y"):
                axes[node] = cur
            node, cur = m.group(1), {}
            continue
        mx = re.search(r"ABS_MT_POSITION_X\s*:.*?max\s+(\d+)", line)
        my = re.search(r"ABS_MT_POSITION_Y\s*:.*?max\s+(\d+)", line)
        if mx: cur["x"] = int(mx.group(1))
        if my: cur["y"] = int(my.group(1))
    if node and cur.get("x") and cur.get("y"):
        axes[node] = cur
    return axes

这里有个容易被忽略的细节:一台设备可能有多个触摸设备节点 (电容屏、虚拟按键各自一个 /dev/input/eventX),必须按事件来源的设备路径分别取轴 max,不能全局只存一份。

3.2 uiautomator dump 深坑:exit 0 也可能是假失败

有了逻辑坐标,下一步是把点到的位置映射到真实控件上,这依赖 uiautomator dump 拿到当前 UI 层级 XML。这个命令的坑之密集,值得单独开一节:

坑 1:exit 0 的"假成功"。 在 RMX2202 / Android 14 上,dump 的输出行为完全不一致------有时 stdout 打印 ERROR: could not get idle state. 但退出码是 0,而文件其实写出来了 ;有时错误信息走 stderr、stdout 是空的。所以成功判定绝不能看命令输出,只能把文件读回来验证内容

python 复制代码
last_err = "未知原因"
for attempt in range(attempts):
    remote = f"/sdcard/atp_ui_{uuid.uuid4().hex[:8]}.xml"   # 每次唯一临时文件
    try:
        self.shell(serial, ["uiautomator", "dump", remote], timeout=timeout)
    except AdbError:
        pass  # 退出码/消息不可靠,成败以读回文件为准
    text = ""
    try:
        out = self.run(self._with_serial(serial, ["exec-out", "cat", remote]))
        text = out if isinstance(out, str) else out.decode("utf-8", "replace")
    except AdbError:
        pass  # 文件未产出
    self.shell(serial, ["rm", "-f", remote], timeout=5.0)
    if "<hierarchy" in text:
        return text

注意临时文件名带 uuid------早期版本用固定路径 /sdcard/atp_ui.xml,上次 dump 失败残留的旧文件会被下次误读成成功,排查了很久才发现是跨会话互踩

坑 2:动画饿死 idle。 uiautomator dump 前要等 UI idle,而系统的 window/transition/animator 三类动画缩放只要有一项是 1.0,带动画的页面 idle 永远等不到,dump 必然超时。所以失败自愈的第一步就是关掉三类动画缩放,测试机干脆长期保持 0.0。

坑 3:第三方 instrumentation 抢占自动化通道。 某天 dump 突然全部 OOM 被杀(exit 137),排查发现是设备上残留了一个第三方自动化服务的 instrumentation 进程占着 uiautomator 的通道,force-stop 之后恢复正常。

坑 4:视频流页面天然 dump 不稳定。 短视频/播放器页面在场景切换时会持续吐无障碍事件,实测 5 次里 3 次失败。这种页面不做跨调用重试(重试窗口追不上场景切换的持续时间),直接快速降级到纯截图感知。

3.3 多候选定位:一次点击,五种表达

坐标换算 + dump 就绪后,一次真人点击会被处理成这样一段脚本:

json 复制代码
{
  "action": "tap",
  "description": "点击「登录」",
  "selector": {"text": "登录", "index": 0},
  "candidates": [
    {"kind": "resource_id", "label": "resource_id: com.app:id/btn_login", "selector": {"resource_id": "com.app:id/btn_login", "index": 0}},
    {"kind": "text", "label": "文本: 登录", "selector": {"text": "登录", "index": 0}},
    {"kind": "inner_text", "label": "内部文本: 立即登录", "selector": {"text": "立即登录", "index": 0}},
    {"kind": "class_index", "label": "Button 第 3 个", "selector": {"class_name": "android.widget.Button", "index": 2}},
    {"kind": "point", "label": "坐标 (540, 950)", "params": {"x": 540, "y": 950}}
  ]
}

核心设计:坐标永远是最后的选择。 一次点击会同时采集所有可行的定位方式存进 candidates,用户在客户端保存时可以逐条切换,后台编辑脚本时也能改。默认选择逻辑(_on_touch_end):

python 复制代码
node = self._map_point_to_node(x, y)          # 点击点 → UI 节点
if node is not None and is_tap:
    candidates = self._build_candidates(nodes, node, x, y)
    selector = node.to_selector()
    if not _in_scrollable(nodes, node):
        # 非列表区域控件文本稳定:默认优先文本锚点(自身 → 内部子控件)
        text_sel = self._default_text_selector(nodes, node, x, y)
        if text_sel is not None:
            selector = text_sel
    step = {"action": "tap", "selector": selector, "candidates": candidates}
elif is_tap:
    step = {"action": "tap", "description": f"点击坐标 ({x},{y})", "params": {"x": x, "y": y}}

几个设计决策:

  • 列表区域(ListView/RecyclerView 内)默认不用文本------列表项文本高度雷同("任务一"、"任务二"),文本定位回放时会点错行,这种情况默认走 resource_id 或坐标;
  • 无文本容器很常见------图标按钮自己没 text,但它 bounds 内部往往有子控件带文本,所以候选里专门有一类"内部文本"锚点;
  • class+序号是弱锚点------同屏 Button 第 3 个,增删一个按钮就失效,排序永远在文本之后。

匹配节点的降级链是:nearest_clickable(最近的 clickable 控件)→ 内部子控件文本 → smallest_containing(包含点击点的最小节点,仅当它有语义锚点或是足够小的叶子节点)→ 坐标兜底。每一层都保证"宁可记坐标,不可错配"------错配比记坐标更糟,因为坐标失败得明明白白,错配点错得悄无声息。

客户端录制:

这是后台脚本编辑页面: 已录制好的脚本,可以在后台再次编辑

3.4 为什么大容器反而要保留坐标

有个反直觉的 case:点击落在整屏大的 ViewPager 上时,即使它有 resource-id 也故意记坐标 。原因:selector 回放时定位到的是容器中心,而用户实际点的是容器内的某个具体位置------回放点容器中心必偏。这跟"小图标叶子节点用 class+index 就够"正好是同一条原则的两面:定位表达必须还原用户的操作意图,而不是还原控件本身

四、本篇踩坑锦集

  • 坐标不换算的两种炸法 :一是 raw 值(8639)远超逻辑分辨率(1080),input tap 直接越界无效;二是即使换算系数恰好接近 1 的小屏设备上不越界,所有"用逻辑坐标找控件"的匹配也全失败------表现都是录制下来一堆坐标步骤,回放全靠蒙。
  • 录制必须真人手指input tap、scrcpy 投屏鼠标点击这类 ADB 注入的操作不会进入录制事件流(输入通道不同),所以录制期间必须人肉点------工具无法代替手指。
  • 断言能力是后补的 :刚开始录制的脚本只有操作没有断言,回放"跑完"≠"跑对"。后来补了两层------录制时采集可断言的控件锚点(assert_candidates),执行侧加了 LLM 兜底断言。教训:校验能力和操作能力一样,是引擎的一等公民,设计期就要想清楚
  • PowerShell 5.1 发中文 JSON 会变 ???Invoke-RestMethod 默认把 body 按 ASCII 编码,中文全变问号。要么 [System.Text.Encoding]::UTF8.GetBytes 手动编码,要么老实用 python 脚本调接口。

结尾

到这里,真人点一遍的操作已经变成了带多候选定位、可回放、可编辑的脚本。但还有个问题没解决------"去删掉昨天创建的那条任务"这种目标怎么测? 这就需要让 LLM 亲自动手了。

下一篇讲 ReAct 引擎:怎么把截图和控件列表喂给多模态模型(SoM 视觉标注)、三级定位优先级怎么设计、失败后怎么把错误信息"回灌"给模型让它自己换策略,以及多设备并发和同设备批量执行的调度设计。

觉得有价值的话点个赞催更,评论区聊聊你现在用的自动化方案踩过什么坑。

相关推荐
事圆则缓1 小时前
Android 声明式 API 的翻译过程
android
mmsx1 小时前
MapLibre 实战 13|让比例尺显示 100m 而不是 347.2m:屏幕距离换算与两个易错点
android·前端·app
邪修king1 小时前
Re:Linux 系统篇(三十一):库的制作与原理Chapter2:静态链接与程序加载 —— 从磁盘 ELF 到运行中进程的完整旅程
android·linux·运维·开发语言
邪修king3 小时前
Re:Linux 系统篇(二十九):动静态库Chapter2:动态库深度辨析 —— 核心本质、制作流程、双阶段查找模型与排错指南
android·java·linux·开发语言
Mr YiRan10 小时前
网络请求API监控与网络切换埋点
android·网络
美狐美颜sdk14 小时前
直播APP开发技术栈详解:视频美颜SDK、人脸识别与实时渲染
android·人工智能·音视频·美颜sdk·直播美颜sdk
奈斯先生Vector19 小时前
当模型版本不断变化,RelayRouter 能否帮助 AI 应用摆脱深度绑定
android·java·人工智能·开源·aigc
TimeFine19 小时前
让强模型做“总工”,让高性价比模型写代码
android
又见情义21 小时前
RK3568 Android 13 屏蔽 healthd 电池日志经验分享
android