"""uiauto2 (uiautodev) 本地服务客户端封装。 uiautodev 启动后本地监听 http://localhost:20242,提供设备元素树查询。 前端步骤编辑器"抓取元素"按钮通过本模块拉取当前设备 UI 树供选择。 依赖: - 用户需先 `pip install uiautodev` 并运行 `uiauto.dev` 启动本地服务 - 未运行时本模块函数返回友好错误,不抛异常 API 参考(uiautodev 0.14): - GET /api/info — 服务信息(用于探测是否运行) - GET /api/android/{serial}/dump_hierarchy — 元素树 JSON """ import requests from core.logger import get_logger _log = get_logger("core.uiauto") # uiautodev 默认本地端口(固定 20242) _UIAUTO_BASE = "http://localhost:20242" # 请求超时(秒)。connect 超时短,避免前端等太久 _TIMEOUT = (1, 8) def is_running(): """探测 uiauto2 本地服务是否在运行。""" try: r = requests.get(f"{_UIAUTO_BASE}/api/info", timeout=_TIMEOUT) return r.status_code == 200 except Exception: return False def list_devices(): """获取 uiauto2 已连接的设备列表。 返回 (ok, data_or_error): ok=True — data 是设备列表 [{serial, model, product, name, status}, ...] ok=False — data 是错误消息字符串 """ try: r = requests.get(f"{_UIAUTO_BASE}/api/android/list", timeout=_TIMEOUT) r.raise_for_status() data = r.json() return True, data except requests.exceptions.ConnectionError: return False, "uiauto2 未启动,请运行 `uiauto.dev`" except Exception as e: _log.warning(f"list_devices 异常: {e}") return False, f"获取设备列表失败: {e}" def get_screenshot(serial): """获取设备截图(通过 uiauto2 服务)。 返回 (ok, data_or_error): ok=True — data 是 JPEG 二进制数据 ok=False — data 是错误消息字符串 """ if not serial: return False, "缺少 serial" try: r = requests.get( f"{_UIAUTO_BASE}/api/android/{serial}/screenshot/0", timeout=_TIMEOUT, ) r.raise_for_status() return True, r.content except requests.exceptions.ConnectionError: return False, "uiauto2 未启动" except Exception as e: _log.warning(f"get_screenshot 异常: {e}") return False, f"截图失败: {e}" def get_elements(serial): """获取指定设备的 UI 元素树。 返回 (ok, data_or_error): ok=True — data 是元素列表 [{name, attrs:{...}, children:[...]}, ...] ok=False — data 是错误消息字符串 元素树由 uiautodev 的 U2AndroidDriver.dump_hierarchy 返回(JSON)。 我们递归提取每个节点的关键属性(resource-id/text/description/class/...), 供前端列表展示和选择。 """ if not serial: return False, "缺少 serial" try: r = requests.get( f"{_UIAUTO_BASE}/api/android/{serial}/hierarchy", timeout=_TIMEOUT, ) if r.status_code == 404: return False, f"设备 {serial} 未连接到 uiauto2" r.raise_for_status() data = r.json() # uiautodev hierarchy 返回 Node 树:{key, name, bounds, rect, properties, children} # 提取成扁平的可选列表(保留层级缩进信息) elements = [] _flatten(data, elements, depth=0) if not elements: return False, "当前界面未抓取到元素" return True, elements except requests.exceptions.ConnectionError: return False, "uiauto2 未启动,请运行 `uiauto.dev`" except requests.exceptions.Timeout: return False, "uiauto2 请求超时" except Exception as e: _log.warning(f"get_elements 异常: {e}") return False, f"抓取失败: {e}" def _flatten(node, out, depth=0, path="", ctx=None, tag_index=0): """递归扁平化元素树,提取关键属性供前端选择。 uiautodev dump_hierarchy 返回 Node 格式: {key, name, bounds(归一化浮点), rect({x,y,width,height}), properties({所有属性}), children} 其中 properties 包含原始 XML 属性(resource-id/text/content-desc/class/bounds 字符串等)。 ctx: 最近一个有唯一属性(resource-id/text/content-desc)祖先的 xpath。 无唯一属性的元素用它限定范围生成精确选择器,避免裸 class(如 //ImageView)过宽误点。 tag_index: 本节点在父节点同 class 兄弟中的序号(1-based),用于消歧。 """ if not isinstance(node, dict): return props = node.get("properties") or {} name = node.get("name") or props.get("class") or "" # bounds:优先取 properties 中的原始字符串 "[x1,y1][x2,y2]"(前端渲染 overlay 用) bounds_str = props.get("bounds", "") if not bounds_str and node.get("rect"): r = node["rect"] bounds_str = f"[{r['x']},{r['y']}][{r['x']+r['width']},{r['y']+r['height']}]" rid = props.get("resource-id", "") text = props.get("text", "") desc = props.get("content-desc", "") cls = props.get("class", "") tag = cls.split(".")[-1] if cls else "*" # 推荐选择器:唯一属性优先;否则用最近锚点祖先限定范围(带同class序号消歧) if rid: suggested = {"type": "xpath", "value": f'//*[@resource-id="{rid}"]'} child_ctx = suggested["value"] elif text: suggested = {"type": "xpath", "value": f'//*[@text="{text}"]'} child_ctx = suggested["value"] elif desc: suggested = {"type": "xpath", "value": f'//*[@content-desc="{desc}"]'} child_ctx = suggested["value"] else: if ctx: seg = f"/{tag}" + (f"[{tag_index}]" if tag_index > 1 else "") suggested = {"type": "xpath", "value": f"{ctx}{seg}"} child_ctx = f"{ctx}{seg}" else: # 无唯一属性且无锚点祖先:裸 class 过宽,标记 broad 让前端提示 suggested = {"type": "xpath", "value": f"//{tag}", "broad": True} child_ctx = None item = { "depth": depth, "path": path, "name": name, "resource_id": rid, "text": text, "description": desc, "class": cls, "package": props.get("package", ""), "clickable": props.get("clickable", ""), "bounds": bounds_str, # 推荐选择器(resource-id > text > description > 锚点祖先限定class) "suggested": suggested, } out.append(item) # 递归子节点,同时计算每个子节点在父下同 class 兄弟中的序号 children = node.get("children") or [] for i, child in enumerate(children): cprops = child.get("properties") or {} ccls = cprops.get("class", "") ctag = ccls.split(".")[-1] if ccls else "*" same = 1 for prev in children[:i]: pcls = (prev.get("properties") or {}).get("class", "") ptag = pcls.split(".")[-1] if pcls else "*" if ptag == ctag: same += 1 _flatten(child, out, depth + 1, f"{path}/{i}", child_ctx, same)