From 37a2b1c59b60cf238b045c661a999c30690cf960 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 13 Sep 2026 21:17:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8A=93=E5=8F=96):=20=E5=85=83=E7=B4=A0?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=99=A8=E6=94=B9=E6=88=90=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=89=88"=E5=A4=A7=E5=9B=BE=20+=20=E4=B8=80=E6=AC=A1=E5=8F=96?= =?UTF-8?q?=E9=BD=90"=E2=80=94=E2=80=94=E5=A4=8D=E5=88=BB=E4=BA=91?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=99=A8=E7=9A=84=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要的是 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。 --- core/uiauto_helper.py | 85 ++++++++++++++++++ doc/API.md | 1 + doc/research/U2_ELEMENT_SELECTORS.md | 4 +- static/admin/editor.js | 124 +++++++++++++++------------ templates/admin/monitor.html | 10 ++- web/tasks_api.py | 24 ++++++ 6 files changed, 190 insertions(+), 58 deletions(-) diff --git a/core/uiauto_helper.py b/core/uiauto_helper.py index db6f448..b2efd8a 100644 --- a/core/uiauto_helper.py +++ b/core/uiauto_helper.py @@ -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)} diff --git a/doc/API.md b/doc/API.md index 8c86f17..7a9a26a 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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 | 真机试执行单个步骤 | diff --git a/doc/research/U2_ELEMENT_SELECTORS.md b/doc/research/U2_ELEMENT_SELECTORS.md index df2f5bf..9c703fb 100644 --- a/doc/research/U2_ELEMENT_SELECTORS.md +++ b/doc/research/U2_ELEMENT_SELECTORS.md @@ -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. **双截图校验**(关键:让失败可见而不是悄悄错位): 抓取时前后各截一张,两张**不一致就明确提示**"界面在抓取过程中变化了, 请让设备停在目标界面再抓",而不是给一个已经错位的框。 diff --git a/static/admin/editor.js b/static/admin/editor.js index 7896f39..4929e1b 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -831,7 +831,7 @@ var _stepEditor={ '
'+ '
'+ '
'+ - ''+ + ''+ '加载中...'+ '
'+ '
'+ @@ -849,65 +849,58 @@ var _stepEditor={ '' +'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;' +'「✓ 测选择器」会用将填入的选择器真跑一次点击'; - // 加载截图和元素树(_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='
加载元素树...
'; - 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='
'+esc(data.error||'抓取失败')+'
'; - 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='
请求失败: '+esc(e)+'
'; - }); - }, - // 刷新截图 - _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='
正在获取截图...
'; 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='
正在抓取(截图 + 元素树一次取齐)…
'; + 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='
截图加载失败
'; - 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='
'+esc(d.error||'抓取失败')+'
'; + if(info)info.textContent='失败'; + return; + } + self._elData=d.elements||[]; + if(d.unstable){ + var w=document.createElement('div'); + w.className='el-shot-warn'; + w.innerHTML='⚠ 抓取期间界面有变化,框可能对不上 —— 建议把设备停在静止界面后点「重新抓取」'; + 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='
请求失败: '+esc(e)+'
'; + }); }, + // 兼容旧调用名(工具栏「重新抓取」按钮) + _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