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={ '