Files
auto_control/core/uiauto_helper.py
T
butubb 468e86f0a9 feat: 新增通用步骤任务+元素抓取,完善全部项目文档
- 新增 generic_steps 通用步骤任务(可视化步骤编辑器编排流程,支持 open_app/click/swipe/input_text/wait/loop/group)

- 新增 core/uiauto_helper.py 封装 uiautodev 元素抓取客户端

- web_server 新增元素抓取/截图/设备列表等 API

- monitor.html 新增步骤编辑器、元素抓取模态框、独立关闭逻辑

- 新增 README.md 项目总览(快速上手/架构/配置/FAQ)

- 新增 doc/ARCHITECTURE.md 架构详解、doc/DEPLOY.md 部署指南、doc/API.md 接口文档

- 修复 doc/TASK_DEV.md:移除已删除的 comment 引用,补充 generic 包,更新注册示例

- .gitignore 忽略 .claude/ 工具产物
2026-08-08 10:40:06 +08:00

179 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""uiauto2 (uiautodev) 本地服务客户端封装。
uiautodev 启动后本地监听 http://localhost:20242,提供设备元素树查询。
前端步骤编辑器"抓取元素"按钮通过本模块拉取当前设备 UI 树供选择。
依赖:
- 用户需先 `pip install uiautodev` 并运行 `uiauto.dev` 启动本地服务
- 未运行时本模块函数返回友好错误,不抛异常
API 参考(uiautodev 0.14):
- GET /api/info — 服务信息(用于探测是否运行)
- GET /api/android/{serial}/dump_hierarchy — 元素树 JSON
"""
import requests
from core.logger import get_logger
_log = get_logger("core.uiauto")
# uiautodev 默认本地端口(固定 20242)
_UIAUTO_BASE = "http://localhost:20242"
# 请求超时(秒)。connect 超时短,避免前端等太久
_TIMEOUT = (1, 8)
def is_running():
"""探测 uiauto2 本地服务是否在运行。"""
try:
r = requests.get(f"{_UIAUTO_BASE}/api/info", timeout=_TIMEOUT)
return r.status_code == 200
except Exception:
return False
def list_devices():
"""获取 uiauto2 已连接的设备列表。
返回 (ok, data_or_error):
ok=True — data 是设备列表 [{serial, model, product, name, status}, ...]
ok=False — data 是错误消息字符串
"""
try:
r = requests.get(f"{_UIAUTO_BASE}/api/android/list", timeout=_TIMEOUT)
r.raise_for_status()
data = r.json()
return True, data
except requests.exceptions.ConnectionError:
return False, "uiauto2 未启动,请运行 `uiauto.dev`"
except Exception as e:
_log.warning(f"list_devices 异常: {e}")
return False, f"获取设备列表失败: {e}"
def get_screenshot(serial):
"""获取设备截图(通过 uiauto2 服务)。
返回 (ok, data_or_error):
ok=True — data 是 JPEG 二进制数据
ok=False — data 是错误消息字符串
"""
if not serial:
return False, "缺少 serial"
try:
r = requests.get(
f"{_UIAUTO_BASE}/api/android/{serial}/screenshot/0",
timeout=_TIMEOUT,
)
r.raise_for_status()
return True, r.content
except requests.exceptions.ConnectionError:
return False, "uiauto2 未启动"
except Exception as e:
_log.warning(f"get_screenshot 异常: {e}")
return False, f"截图失败: {e}"
def get_elements(serial):
"""获取指定设备的 UI 元素树。
返回 (ok, data_or_error):
ok=True — data 是元素列表 [{name, attrs:{...}, children:[...]}, ...]
ok=False — data 是错误消息字符串
元素树由 uiautodev 的 U2AndroidDriver.dump_hierarchy 返回(JSON)。
我们递归提取每个节点的关键属性(resource-id/text/description/class/...),
供前端列表展示和选择。
"""
if not serial:
return False, "缺少 serial"
try:
r = requests.get(
f"{_UIAUTO_BASE}/api/android/{serial}/hierarchy",
timeout=_TIMEOUT,
)
if r.status_code == 404:
return False, f"设备 {serial} 未连接到 uiauto2"
r.raise_for_status()
data = r.json()
# uiautodev hierarchy 返回 Node 树:{key, name, bounds, rect, properties, children}
# 提取成扁平的可选列表(保留层级缩进信息)
elements = []
_flatten(data, elements, depth=0)
if not elements:
return False, "当前界面未抓取到元素"
return True, elements
except requests.exceptions.ConnectionError:
return False, "uiauto2 未启动,请运行 `uiauto.dev`"
except requests.exceptions.Timeout:
return False, "uiauto2 请求超时"
except Exception as e:
_log.warning(f"get_elements 异常: {e}")
return False, f"抓取失败: {e}"
def _flatten(node, out, depth=0, path=""):
"""递归扁平化元素树,提取关键属性供前端选择。
uiautodev dump_hierarchy 返回 Node 格式:
{key, name, bounds(归一化浮点), rect({x,y,width,height}), properties({所有属性}), children}
其中 properties 包含原始 XML 属性(resource-id/text/content-desc/class/bounds 字符串等)。
"""
if not isinstance(node, dict):
return
props = node.get("properties") or {}
name = node.get("name") or props.get("class") or ""
# bounds:优先取 properties 中的原始字符串 "[x1,y1][x2,y2]"(前端渲染 overlay 用)
bounds_str = props.get("bounds", "")
if not bounds_str and node.get("rect"):
r = node["rect"]
bounds_str = f"[{r['x']},{r['y']}][{r['x']+r['width']},{r['y']+r['height']}]"
# 关键属性(前端选择器用)
item = {
"depth": depth,
"path": path,
"name": name,
"resource_id": props.get("resource-id", ""),
"text": props.get("text", ""),
"description": props.get("content-desc", ""),
"class": props.get("class", ""),
"package": props.get("package", ""),
"clickable": props.get("clickable", ""),
"bounds": bounds_str,
# 推荐选择器(优先级:resource-id > text > description > class)
"suggested": _suggest_selector(props),
}
out.append(item)
# 递归子节点
children = node.get("children") or []
for i, child in enumerate(children):
_flatten(child, out, depth + 1, f"{path}/{i}")
def _suggest_selector(attrs):
"""根据属性推荐最佳选择器(前端一键填入用)。
优先返回 xpath(最稳定,uiautomator2 原生支持),
其次 resourceId / text / description / class。
"""
cls = attrs.get("class", "")
rid = attrs.get("resource-id", "")
text = attrs.get("text", "")
desc = attrs.get("content-desc", "")
# 优先生成 xpath:resource-id 最稳定
if rid:
# 简短类名(去掉包前缀)
short_cls = cls.split(".")[-1] if cls else "*"
return {"type": "xpath",
"value": f'//*[@resource-id="{rid}"]'}
if text:
return {"type": "xpath",
"value": f'//*[@text="{text}"]'}
if desc:
return {"type": "xpath",
"value": f'//*[@content-desc="{desc}"]'}
if cls:
return {"type": "xpath",
"value": f'//{cls}'}
return {"type": "xpath", "value": "//*"}