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。
This commit is contained in:
@@ -258,3 +258,88 @@ def _extract(root, out):
|
||||
|
||||
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)}
|
||||
|
||||
Reference in New Issue
Block a user