"""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=""): """递归扁平化元素树,提取关键属性供前端选择。 uiautodev dump_hierarchy 返回 Node 格式: {key, name, bounds(归一化浮点), rect({x,y,width,height}), properties({所有属性}), children} 其中 properties 包含原始 XML 属性(resource-id/text/content-desc/class/bounds 字符串等)。 """ 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']}]" # 关键属性(前端选择器用) item = { "depth": depth, "path": path, "name": name, "resource_id": props.get("resource-id", ""), "text": props.get("text", ""), "description": props.get("content-desc", ""), "class": props.get("class", ""), "package": props.get("package", ""), "clickable": props.get("clickable", ""), "bounds": bounds_str, # 推荐选择器(优先级:resource-id > text > description > class) "suggested": _suggest_selector(props), } out.append(item) # 递归子节点 children = node.get("children") or [] for i, child in enumerate(children): _flatten(child, out, depth + 1, f"{path}/{i}") def _suggest_selector(attrs): """根据属性推荐最佳选择器(前端一键填入用)。 优先返回 xpath(最稳定,uiautomator2 原生支持), 其次 resourceId / text / description / class。 """ cls = attrs.get("class", "") rid = attrs.get("resource-id", "") text = attrs.get("text", "") desc = attrs.get("content-desc", "") # 优先生成 xpath:resource-id 最稳定 if rid: # 简短类名(去掉包前缀) short_cls = cls.split(".")[-1] if cls else "*" return {"type": "xpath", "value": f'//*[@resource-id="{rid}"]'} if text: return {"type": "xpath", "value": f'//*[@text="{text}"]'} if desc: return {"type": "xpath", "value": f'//*[@content-desc="{desc}"]'} if cls: return {"type": "xpath", "value": f'//{cls}'} return {"type": "xpath", "value": "//*"}