用户要的是 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。
346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""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)}
|