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)}
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
| GET | `/api/uiauto/status` | L | uiautodev 服务是否在跑 |
|
||||
| GET | `/api/uiauto/devices` | D | 抓元素可选设备 |
|
||||
| GET | `/api/uiauto/elements` | D | 设备 UI 元素树 |
|
||||
| GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) |
|
||||
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
|
||||
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
|
||||
|
||||
|
||||
@@ -109,12 +109,14 @@
|
||||
界面**只要在动**(抖音信息流、视频、加载动画…),两秒足够让元素位置全变 →
|
||||
**框永远落在旧位置上** —— 这就是"老是错位",不是偶发。
|
||||
|
||||
### 解法
|
||||
### 解法(✅ 2026-09-13 已实现,见 `core/uiauto_helper.snapshot`)
|
||||
|
||||
1. **一次请求取齐**(推荐,也是用户说的"用原始 u2"):
|
||||
新增 `GET /api/uiauto/snapshot?serial=X`,服务端用**一个 u2 连接**背靠背
|
||||
`dump_hierarchy()` + `screenshot()`,返回 `{image, elements}`;
|
||||
前端只调这一个接口 → 天然同源,间隔从 ~2.2s 降到 ~1.4s。
|
||||
**并且抓取弹窗改成大图 1:1 预览**(原来预览列只有 320px、图片被缩到 294px,
|
||||
框全挤在一起,看着就像"错位";现在铺满左侧、按原始分辨率显示)。
|
||||
2. **双截图校验**(关键:让失败可见而不是悄悄错位):
|
||||
抓取时前后各截一张,两张**不一致就明确提示**"界面在抓取过程中变化了,
|
||||
请让设备停在目标界面再抓",而不是给一个已经错位的框。
|
||||
|
||||
+71
-53
@@ -831,7 +831,7 @@ var _stepEditor={
|
||||
'<div class="el-picker-main">'+
|
||||
'<div class="el-shot-wrap">'+
|
||||
'<div class="el-shot-toolbar">'+
|
||||
'<button class="btn" onclick="_stepEditor._refreshShot()" title="重新拉取截图和元素树(界面变化后刷新用)">刷新截图+元素</button>'+
|
||||
'<button class="btn" onclick="_stepEditor._refreshShot()" title="重新抓取截图与元素树(一次取齐)">重新抓取</button>'+
|
||||
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
|
||||
'</div>'+
|
||||
'<div class="el-shot-container" id="el-shot-container">'+
|
||||
@@ -849,65 +849,58 @@ var _stepEditor={
|
||||
'<span class="text-muted" style="margin-left:10px;font-size:11.5px">'
|
||||
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
|
||||
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
|
||||
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
|
||||
self._refreshShot();
|
||||
// 一次取齐截图 + 元素树(见 _loadSnapshot 的说明)
|
||||
self._loadSnapshot();
|
||||
},
|
||||
// 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
|
||||
_loadElData:function(){
|
||||
var serial=this._elSerial;
|
||||
if(!serial)return;
|
||||
var listEl=document.getElementById('el-list');
|
||||
if(listEl)listEl.innerHTML='<div class="se-empty">加载元素树...</div>';
|
||||
var self=this;
|
||||
fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now())
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(data){
|
||||
var le=document.getElementById('el-list');
|
||||
if(!le)return;
|
||||
if(!data.ok){
|
||||
le.innerHTML='<div class="se-empty">'+esc(data.error||'抓取失败')+'</div>';
|
||||
return;
|
||||
}
|
||||
self._elData=data.elements||[];
|
||||
self._renderEl(self._elData);
|
||||
// 刷新后保持搜索过滤状态
|
||||
var search=document.getElementById('el-search');
|
||||
if(search&&search.value)self._filterEl(search.value);
|
||||
self._renderOverlays();
|
||||
})
|
||||
.catch(function(e){
|
||||
var le=document.getElementById('el-list');
|
||||
if(le)le.innerHTML='<div class="se-empty">请求失败: '+esc(e)+'</div>';
|
||||
});
|
||||
},
|
||||
// 刷新截图
|
||||
_refreshShot:function(){
|
||||
// 一次取齐:截图 + 元素树(后端用原生 u2 背靠背取)
|
||||
// 为什么不再分两个接口:截图与元素树分两次拿,中间隔着 dump 本身的 1.3~1.8 秒,
|
||||
// 界面只要在动(信息流/视频/加载),框就会落在旧位置上("抓取错位")。
|
||||
_loadSnapshot:function(){
|
||||
var serial=this._elSerial;
|
||||
if(!serial)return;
|
||||
var container=document.getElementById('el-shot-container');
|
||||
if(!container)return;
|
||||
container.innerHTML='<div class="el-shot-loading">正在获取截图...</div>';
|
||||
var info=document.getElementById('el-shot-info');
|
||||
if(info)info.textContent='加载中...';
|
||||
var old=document.querySelector('.el-shot-warn');
|
||||
if(old)old.remove();
|
||||
if(container)container.innerHTML='<div class="el-shot-loading">正在抓取(截图 + 元素树一次取齐)…</div>';
|
||||
if(info)info.textContent='抓取中…';
|
||||
var self=this;
|
||||
var img=new Image();
|
||||
img.onload=function(){
|
||||
container.innerHTML='';
|
||||
container.appendChild(img);
|
||||
var infoEl=document.getElementById('el-shot-info');
|
||||
if(infoEl)infoEl.textContent=img.naturalWidth+'x'+img.naturalHeight;
|
||||
// 截图加载完后重新渲染元素边界框
|
||||
self._renderOverlays();
|
||||
};
|
||||
img.onerror=function(){
|
||||
container.innerHTML='<div class="el-shot-loading">截图加载失败</div>';
|
||||
var infoEl=document.getElementById('el-shot-info');
|
||||
if(infoEl)infoEl.textContent='失败';
|
||||
};
|
||||
img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now();
|
||||
// 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位
|
||||
this._loadElData();
|
||||
fetch('/api/uiauto/snapshot?serial='+encodeURIComponent(serial)+'&_='+Date.now())
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(!d.ok){
|
||||
if(container)container.innerHTML='<div class="el-shot-loading">'+esc(d.error||'抓取失败')+'</div>';
|
||||
if(info)info.textContent='失败';
|
||||
return;
|
||||
}
|
||||
self._elData=d.elements||[];
|
||||
if(d.unstable){
|
||||
var w=document.createElement('div');
|
||||
w.className='el-shot-warn';
|
||||
w.innerHTML='⚠ 抓取期间界面有变化,框可能对不上 —— 建议把设备停在<b>静止界面</b>后点「重新抓取」';
|
||||
container.parentNode.insertBefore(w, container);
|
||||
}
|
||||
var img=new Image();
|
||||
img.onload=function(){
|
||||
container.innerHTML='';
|
||||
container.appendChild(img);
|
||||
if(info){
|
||||
info.textContent=d.width+'x'+d.height+' · '+self._elData.length+' 个元素 · '
|
||||
+(d.cost_ms||0)+'ms'+(d.unstable?' · ⚠ 界面在变化':'');
|
||||
}
|
||||
self._renderOverlays();
|
||||
};
|
||||
img.src=d.image;
|
||||
self._renderEl(self._elData);
|
||||
var search=document.getElementById('el-search');
|
||||
if(search&&search.value)self._filterEl(search.value);
|
||||
})
|
||||
.catch(function(e){
|
||||
if(container)container.innerHTML='<div class="el-shot-loading">请求失败: '+esc(e)+'</div>';
|
||||
});
|
||||
},
|
||||
// 兼容旧调用名(工具栏「重新抓取」按钮)
|
||||
_refreshShot:function(){ this._loadSnapshot(); },
|
||||
// 在截图上渲染元素边界框(可点击)
|
||||
_renderOverlays:function(){
|
||||
var container=document.getElementById('el-shot-container');
|
||||
@@ -947,6 +940,31 @@ var _stepEditor={
|
||||
});
|
||||
// 点击截图:按 bounds 命中测试,选中最小的(最深的)元素,
|
||||
// 而不是被 DOM 层叠中的大容器/大兄弟挡住
|
||||
container.onmousemove=function(e){
|
||||
var im=container.querySelector('img');
|
||||
if(!im||!self._elData)return;
|
||||
var r=im.getBoundingClientRect();
|
||||
var px=(e.clientX-r.left)/scaleX, py=(e.clientY-r.top)/scaleY;
|
||||
var best=-1,bestArea=Infinity;
|
||||
self._elData.forEach(function(el,i){
|
||||
var mb=el.bounds&&el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||||
if(!mb)return;
|
||||
var a=+mb[1],b=+mb[2],c=+mb[3],dd=+mb[4];
|
||||
if(px>=a&&px<=c&&py>=b&&py<=dd){var ar=(c-a)*(dd-b); if(ar<bestArea){bestArea=ar;best=i;}}
|
||||
});
|
||||
var hov=container.querySelector('.el-shot-hover');
|
||||
if(best<0){ if(hov)hov.remove(); return; }
|
||||
var b2=self._elData[best].bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||||
if(!hov){hov=document.createElement('div');hov.className='el-shot-hover';container.appendChild(hov);}
|
||||
hov.style.left=(imgOffsetX+(+b2[1])*scaleX)+'px';
|
||||
hov.style.top=(imgOffsetY+(+b2[2])*scaleY)+'px';
|
||||
hov.style.width=((+b2[3])-(+b2[1]))*scaleX+'px';
|
||||
hov.style.height=((+b2[4])-(+b2[2]))*scaleY+'px';
|
||||
};
|
||||
container.onmouseleave=function(){
|
||||
var hov=container.querySelector('.el-shot-hover');
|
||||
if(hov)hov.remove();
|
||||
};
|
||||
container.onclick=function(e){
|
||||
var im=container.querySelector('img');
|
||||
if(!im)return;
|
||||
|
||||
@@ -289,7 +289,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
.el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px}
|
||||
.el-device-card .edc-status{font-size:11px;padding:2px 10px;border-radius:99px;background:var(--success-soft);color:#15803d;flex-shrink:0;font-weight:600}
|
||||
.el-picker-main{display:flex;flex:1;overflow:hidden}
|
||||
.el-shot-wrap{flex:0 0 320px;border-right:1px solid var(--border);background:var(--ink);display:flex;flex-direction:column}
|
||||
.el-shot-wrap{flex:1 1 auto;min-width:0;border-right:1px solid var(--border);background:var(--ink);display:flex;flex-direction:column}
|
||||
.el-shot-toolbar{padding:6px 10px;background:var(--ink-2);display:flex;gap:6px;align-items:center;border-bottom:1px solid var(--ink-line)}
|
||||
.el-shot-toolbar .btn{padding:3px 10px;font-size:12px;background:#39414d;color:#e5e7eb;border:none;box-shadow:none}
|
||||
.el-shot-toolbar .btn:hover{background:#4b5563}
|
||||
@@ -297,10 +297,12 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
.el-shot-container{flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:8px;position:relative}
|
||||
.el-shot-container img{max-width:100%;height:auto;border-radius:4px;display:block}
|
||||
.el-shot-container .el-shot-loading{color:#8a93a0;font-size:13px;padding:40px}
|
||||
.el-shot-overlay{position:absolute;border:1px solid rgba(103,232,249,.35);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s}
|
||||
.el-shot-overlay{position:absolute;border:1px solid rgba(103,232,249,.45);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s}
|
||||
.el-shot-hover{position:absolute;border:2px solid rgba(248,113,113,.9);background:rgba(248,113,113,.15);pointer-events:none;border-radius:2px;z-index:5}
|
||||
.el-shot-warn{background:#fff3cd;border-bottom:1px solid #ffda6a;color:#7a5b00;font-size:12px;padding:6px 10px}
|
||||
.el-shot-overlay:hover{border-color:#67e8f9;background:rgba(103,232,249,.18)}
|
||||
.el-shot-overlay.active{border:2px solid #f87171;background:rgba(248,113,113,.22)}
|
||||
.el-list-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.el-list-wrap{flex:0 0 400px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
|
||||
.el-picker-search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid var(--border);font-size:13px;outline:none;font-family:var(--body)}
|
||||
.el-picker-list{flex:1;overflow-y:auto}
|
||||
.el-picker-item{padding:8px 12px;border-bottom:1px solid var(--border);cursor:pointer;font-size:12px;display:flex;gap:8px;align-items:flex-start}
|
||||
@@ -1042,7 +1044,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
|
||||
<!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== -->
|
||||
<div id="el-picker-overlay" class="modal-overlay" style="z-index:1100" onclick="if(event.target===this)closeElPicker()">
|
||||
<div class="modal" id="el-picker-box" style="max-width:900px">
|
||||
<div class="modal" id="el-picker-box" style="max-width:1280px;width:96vw">
|
||||
<div class="modal-header">
|
||||
<h3 id="el-picker-title">抓取元素</h3>
|
||||
<button class="close" onclick="closeElPicker()">×</button>
|
||||
|
||||
@@ -358,6 +358,30 @@ def api_uiauto_devices():
|
||||
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
|
||||
return jsonify({"ok": True, "devices": data})
|
||||
|
||||
@bp.route("/api/uiauto/snapshot")
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_uiauto_snapshot():
|
||||
"""一次取齐:设备截图 + 元素树(**原生 u2,同一个连接背靠背取**)。
|
||||
|
||||
抓取元素时用它代替「/api/uiauto/screenshot + /api/uiauto/elements」两连击:
|
||||
那两条是两次独立取数,中间隔着 dump 本身的 1.3~1.8 秒 —— 界面只要在动
|
||||
(信息流/视频/加载动画),框就会落在旧位置上("抓取错位")。
|
||||
同一个连接背靠背取把这个窗口压到最小,并额外用**双截图校验**标出
|
||||
"抓取期间界面在变化"(`unstable`),前端据此明确提示而不是悄悄给错位的框。
|
||||
|
||||
?serial=xxx
|
||||
→ {ok, image(data-url), width, height, elements:[...], unstable, cost_ms}
|
||||
"""
|
||||
serial = request.args.get("serial", "")
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
ok, data = uiauto_helper.snapshot(serial)
|
||||
if not ok:
|
||||
# 设备没连上/抓取失败:502(依赖不可用),与 /api/uiauto/elements 一致
|
||||
return jsonify({"ok": False, "error": data}), 502
|
||||
return jsonify({"ok": True, **data})
|
||||
|
||||
|
||||
@bp.route("/api/uiauto/screenshot")
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_uiauto_screenshot():
|
||||
|
||||
Reference in New Issue
Block a user