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