- 元素选择器生成:无 resource-id/text 的元素改用最近有唯一属性祖先限定范围, 并带同 class 兄弟序号消歧(如 //*[@resource-id=".."]/RelativeLayout[3]), 不再给裸 class(如 //android.widget.ImageView 会误匹配其他元素) - 仍无法限定的宽泛选择器标记 broad,前端元素列表显示"⚠宽泛"提示 - 抖音 like 动作:by_element 找不到点赞按钮改为跳过,不再双击屏幕中央(避免误触)
190 lines
7.1 KiB
Python
190 lines
7.1 KiB
Python
"""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)
|