Files
auto_control/core/uiauto_helper.py
T
butubb 37a2b1c59b feat(抓取): 元素检查器改成本地版"大图 + 一次取齐"——复刻云检查器的体验
用户要的是 uiauto.dev 那个检查器的体验(左边大图点元素、右边层级树、选完回填),
并希望本地复刻(云页面跨域,拿不到它的选中结果,没法自动回传)。

- core/uiauto_helper.py: 新增 `snapshot(serial)`
  * **一个 u2 连接背靠背** dump_hierarchy() + screenshot():截图与元素树同源同刻,
    不再像原来那样分两个接口取(中间隔着 dump 本身的 1.3~1.8 秒)
  * `_xml_to_node()`:把 u2 的 XML 节点转成 uiautodev 那套结构,直接复用既有的
    选择器建议逻辑(不写第二遍)
  * **双截图校验**:dump 前后各截一张,差异明显就标 `unstable`,让前端明确提示
    "界面在变化中,请停在静止界面再抓",而不是悄悄给一个可能错位的框
- web/tasks_api.py: 新增 `GET /api/uiauto/snapshot`(登录 + 设备权限)
- static/admin/editor.js + templates/admin/monitor.html:
  * 抓取弹窗从 900px 加宽到 1280px,预览列从"固定 320px"改为铺满左侧 →
    **图片按原始分辨率 1:1 显示**(实测 720x1650 缩放 1.000),元素框严丝合缝;
    原来缩到 294px 时框全挤在一起,看着就像错位
  * 工具栏显示 `720x1650 · 247 个元素 · 2370ms`;界面在变时顶部弹黄色提示条
  * 鼠标在图上移动时高亮"最深命中"的元素框,便于确认真要点哪个

实测:抓取弹窗 1:1 显示、222 个框逐一贴合元素、无 JS 报错;
`unstable` 在静止界面为 false。
2026-09-13 21:17:12 +08:00

346 lines
14 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 collections import Counter
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..., suggested:{type,value,indexed?,occ?,total?,broad?}}, ...]
ok=False — data 是错误消息字符串
元素树由 uiautodev 的 dump_hierarchy 返回(JSON)。我们递归提取每个节点的
关键属性(resource-id/text/description/class/bounds...),供前端列表展示和选择。
选择器精度策略(精确到具体按钮的关键):
1. 有 resource-id/text/content-desc 的元素:
先预统计该属性在整棵树中的出现次数——
- 唯一出现:直接用属性选择器 //*[@resource-id="x"]
- 重复出现(如抖音底部导航 4 个 tab 同 id):用**整体加括号**的
`(//*[@resource-id="x"])[k]` 精确到第 k 个匹配。
注意 XPath 语义:`//*[@id="x"][k]` 是"在其父节点中排第 k",不是
第 k 个匹配——历史实现踩过这个坑(多实例时 [2..n] 全部失配)。
2. 无任何属性的元素:
用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位
(如 //*[@resource-id="x"]/FrameLayout/ImageView[2]);
整棵树都没有属性时退化为从根开始的结构路径(标记 broad,前端提示脆弱)。
"""
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 = []
_extract(data, elements)
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 _xpath_q(v):
"""XPath 字符串字面量:优先双引号,值含双引号时改用单引号包裹(XPath 1.0 无转义)。"""
if '"' in v:
return "'" + v + "'"
return '"' + v + '"'
def _extract(root, out):
"""把 uiautodev 元素树扁平化为可选列表,并为每个节点生成精确选择器建议。"""
# 全树属性出现次数(预统计,供重复元素加 [k] 序号消歧)
id_cnt, text_cnt, desc_cnt = Counter(), Counter(), Counter()
# 文档顺序已出现次数(决定当前元素是第几个)
seen_id, seen_text, seen_desc = Counter(), Counter(), Counter()
def count_attrs(node):
if not isinstance(node, dict):
return
props = node.get("properties") or {}
rid, text, desc = (props.get(k, "") for k in ("resource-id", "text", "content-desc"))
if rid:
id_cnt[rid] += 1
if text:
text_cnt[text] += 1
if desc:
desc_cnt[desc] += 1
for c in node.get("children") or []:
count_attrs(c)
def attr_selector(attr, value, cnt, seen):
"""属性选择器:唯一直接出;重复时用 `(…)[k]` 精确到**第 k 个匹配**。
重要(XPath 位置谓词语义):
//*[@resource-id="x"][2] → 「在**其父节点**中排第 2 的属性匹配」,**不是**第 2 个匹配
(//*[@resource-id="x"])[2] → 「第 2 个匹配」← 我们要的
历史实现写成前者,导致同 id 多实例(如抖音底部导航 4 个 tab 同 id)时
[2..n] 全部匹配不到 → 运行时"未找到元素"(2026-09-10 实测修复)。
"""
seen[value] += 1
base = f'//*[@{attr}={_xpath_q(value)}]'
if cnt[value] > 1:
val = f"({base})[{seen[value]}]"
return val, {"type": "xpath", "value": val,
"indexed": True, "occ": seen[value], "total": cnt[value]}
return base, {"type": "xpath", "value": base}
def flatten(node, depth=0, path="", ctx=None, tag_path="", tag_index=1):
"""递归扁平化。
ctx: 最近一个有属性祖先的选择器(含序号),无属性元素用它限定范围。
tag_path: 从根到本节点的完整结构路径(class + 同class兄弟序号),兜底用。
tag_index: 本节点在父下同 class 兄弟中的序号(1-based)。
"""
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"):
# rect 兜底:只有整数像素坐标才用;uiautodev 归一化浮点坐标无法换算像素
r = node["rect"]
try:
vals = [r["x"], r["y"], r["x"] + r["width"], r["y"] + r["height"]]
if all(isinstance(v, (int, float)) and float(v).is_integer() for v in vals):
bounds_str = f"[{int(vals[0])},{int(vals[1])}][{int(vals[2])},{int(vals[3])}]"
except (TypeError, KeyError):
pass
rid = props.get("resource-id", "")
text = props.get("text", "")
desc = props.get("content-desc", "")
cls = props.get("class", "")
tag = cls.split(".")[-1] if cls else "*"
# 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义;
# class 为空的层(tag=*)不参与结构路径,避免 //*[N] 前缀污染导致选择器定位到任意节点
if tag != "*":
seg = f"{tag}[{tag_index}]"
own_tag_path = f"{tag_path}/{seg}" if tag_path else seg
else:
seg = ""
own_tag_path = tag_path
# ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ----
if rid:
child_ctx, suggested = attr_selector("resource-id", rid, id_cnt, seen_id)
elif text:
child_ctx, suggested = attr_selector("text", text, text_cnt, seen_text)
elif desc:
child_ctx, suggested = attr_selector("content-desc", desc, desc_cnt, seen_desc)
elif ctx:
val = f"{ctx}/{seg}"
suggested = {"type": "xpath", "value": val}
child_ctx = val
else:
if tag == "*":
# class 也为空的元素:结构路径 //*[N] 无意义(匹配到文档任意节点),
# 标记 invalid,前端提示不可选,避免回填垃圾选择器导致"点击不到"
suggested = {"type": "xpath", "value": "",
"invalid": True,
"reason": "该元素无可用属性(id/文本/class),无法生成可靠选择器"}
else:
# 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示)
suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True}
child_ctx = None
out.append({
"depth": depth,
"path": path,
"name": name,
"resource_id": rid,
"text": text,
"description": desc,
"class": cls,
"package": props.get("package", ""),
"clickable": props.get("clickable", ""),
"bounds": bounds_str,
"suggested": suggested,
})
# 递归子节点,同时计算每个子节点在父下同 class 兄弟中的序号
children = node.get("children") or []
for i, child in enumerate(children):
cprops = child.get("properties") or {}
ccls = cprops.get("class", "")
ctag = ccls.split(".")[-1] if ccls else "*"
same = 1 + sum(
1 for prev in children[:i]
if ((prev.get("properties") or {}).get("class", "")).split(".")[-1] == ctag
)
flatten(child, depth + 1, f"{path}/{i}", child_ctx, own_tag_path, same)
count_attrs(root)
flatten(root)
# ================== 原生 u2 快照(截图 + 元素树一次取齐) ==================
def _xml_to_node(elem):
"""把 uiautomator2 的 XML 节点转成 uiautodev 那套 {name, properties, children}。
两边的属性名本来就一样(resource-id / text / content-desc / class / bounds 字符串),
所以只要套一层壳,就能原样复用上面的 _extract(选择器建议逻辑不用写第二遍)。
"""
props = dict(elem.attrib)
return {"name": props.get("class", ""), "properties": props,
"children": [_xml_to_node(c) for c in list(elem)]}
def _shots_differ(a, b, threshold=8):
"""两张截图是不是"明显不一样"(用来判断抓取期间界面有没有在动)。
逐像素求差的包围盒 → 用"变化区域占比"判断,避免个别像素抖动误报。
"""
try:
from PIL import ImageChops
if a.size != b.size:
return True
diff = ImageChops.difference(a.convert("RGB"), b.convert("RGB"))
bbox = diff.getbbox()
if not bbox:
return False
area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
total = a.size[0] * a.size[1]
# 变化面积超过阈值(默认 8%)才算"界面在动"
return (area / total) * 100.0 > threshold
except Exception:
return False
def snapshot(serial, quality=85):
"""一次取齐:设备截图 + 元素树(**同一个 u2 连接、背靠背取**)。
为什么不用现在的"两个接口":截图和元素树分两次取时,中间隔着 dump 本身的
1.3~1.8 秒;界面只要在动画(信息流/视频/加载),框就会落在旧位置上。
顺带做**双截图校验**:dump 前后各截一张,两张差得多就标 `unstable`,
让前端明确提示"界面在变化中,请停在静止界面再抓"——而不是悄悄给一个错位的框。
返回 (ok, 数据 | 错误信息)。数据形如:
{"image": "data:image/jpeg;base64,…", "width": 720, "height": 1650,
"elements": [...], "unstable": false, "cost_ms": 1800}
"""
import base64
import io
import time as _t
import xml.etree.ElementTree as ET
import uiautomator2 as u2
t0 = _t.time()
try:
d = u2.connect(serial)
img1 = d.screenshot() # 先截:用户看到的就是这一刻
xml = d.dump_hierarchy() # 慢的一步(1.3~1.8s)
img2 = d.screenshot() # 再截:和第一张比对
except Exception as e:
return False, f"抓取失败: {e}"
unstable = _shots_differ(img1, img2)
if not unstable:
img2.close()
try:
buf = io.BytesIO()
img1.convert("RGB").save(buf, format="JPEG", quality=quality)
image = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
w, h = img1.size
except Exception as e:
return False, f"截图编码失败: {e}"
elements = []
try:
root = ET.fromstring(xml)
_extract(_xml_to_node(root), elements)
except Exception as e:
return False, f"元素树解析失败: {e}"
return True, {"image": image, "width": w, "height": h,
"elements": elements, "unstable": unstable,
"cost_ms": int((_t.time() - t0) * 1000)}