Files
auto_control/doc/research/U2_ELEMENT_SELECTORS.md
butubb 0bc713137d feat(抓取): 选择器优先语义化(同 id 多实例用 @text 限定,而非序号)+ 修掉两类"死选择器"
语义消歧(B 项):主属性在整棵树里重复时,先找第二个属性把目标单独圈出来 ——
  //*[@resource-id="x" and @text="我"]   (次属性 text > content-desc > class,
                                        单个不够就两两组合)
标 semantic:true + via;只有组合也分不开(列表里同 id 同文字)才退回
  (//*[@resource-id="x"])[k]            (标 indexed,前端黄标提醒脆弱)
抖音底部导航正是这个场景:tab 个数随灰度版本变(4 个 ↔ 3 个),序号必然错位。

顺带修掉两个结构性缺陷(给上面做验证时逐条 lxml 求值发现的,均非本次引入):
1. 结构步进把 class 当标签名 —— dump 的 XML 标签**一律是 <node>**,class 在
   @class 上,所以 //FrameLayout[1]/… 这类路径**永远零命中**;改 *[@class="…"][n]
2. 兜底结构路径用 @index 定位兄弟 —— 实测同级 index 会重复(状态栏/内容区/
   导航栏三个兄弟全是 index="0");改按子节点位置 //hierarchy/*[1]/*[2]

前端:抓取列表把 text/content-desc 排到最前并加粗上色(最稳的定位依据);
序号型从蓝标改**黄标 ⚠ 序号 k/n**,语义型给**绿标 ✓ 语义**;属性页新增
「选择器稳定性」一行说明这个选择器靠什么定位、会不会因界面变化失效。

真机实测(192.168.20.100,248 个元素):
  精确命中目标 221 → 247 | 死选择器 26 → 0 | 语义型 0 → 26(序号型 141 → 115)
  「我」的语义选择器经 /api/steps/test 真机点击 → 命中 ✓

文档:TASK_DEV §5.3/5.4(含两个 XPath 坑)、API §8(suggested 字段表 +
snapshot 行)、research/U2_ELEMENT_SELECTORS §五/§六、backlog ②标记完成。
2026-09-13 22:05:47 +08:00

175 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 元素选择器研究:为什么"点不到按钮"(2026-09-13)
> 背景:任务步骤(`click` 等)频繁出现"元素明明在屏幕上却点不到"。用户怀疑是
> 任务执行用的 u2 与元素抓取用的 uiautodev 两条通道不一致,要求研究。
> 分支:`research/raw-u2-elements`。
---
## 一、先否掉一个假设:两条通道**并不**冲突
| | 用的东西 | 连接方式 |
|---|---|---|
| 任务执行(点击/输入…)| 原生 `u2.connect(serial)` | 直连设备的 uiautomator2 server |
| 元素抓取(编辑器按钮)| `uiautodev` 服务(localhost:20242)→ `/api/android/{serial}/hierarchy` | 它自己的连接 |
实测(同一设备、同一屏、同一时刻,两边各 dump 一次):
| 对比项 | u2 | uiautodev |
|---|---|---|
| 节点总数 | 330 | 330 |
| `id/0qf`(抖音底部 tab) | 3 | 3 |
| `id/content_layout` | 5 | 5 |
**逐项一致** —— 两个通道给出的元素树是同一份(都是设备的 UiAutomation dump)。
所以"抓取看到的和执行看到的不是一回事"这个假设**不成立**。
> 顺带纠正一处过时注释:`core/uiauto_helper.py` 说"uiautodev 归一化浮点坐标无法换算像素"。
> 实际上 uiautodev 的节点里 **`rect` 就是像素**(`{x,y,width,height}`),`bounds` 才是
> 归一化的 0~1 浮点。要用像素直接用 `rect` 即可。
---
## 二、真正的原因:**序号型选择器**(`(…)[k]`)会随界面变形而错位
复现(192.168.20.100,抖音 v40.4.0)—— 进「我」页面,底部导航 dump 出来是:
```
[1] text='首页' bounds=[38,1484][106,1530]
[2] text='消息' bounds=[470,1484][538,1530]
[3] text='我' bounds=[631,1484][665,1530]
```
只有 **3 个** `0qf`。而任务里的步骤写的是:
```
(//*[@resource-id="com.ss.android.ugc.aweme:id/0qf"])[4] ← 第 4 个,根本不存在
```
**必然点空** —— 日志里表现为 `click 未找到元素`,而界面上那个「我」明明就在那儿。
### 为什么有的设备是 4 个?
抖音底部的「朋友」tab 是**灰度功能**:有的账号/设备有(首页/朋友/消息/我 = 4 个),
有的没有(首页/消息/我 = 3 个)。同一个序号 `[4]`:
- 4 个 tab 的设备 → 点到「我」(碰巧对)
- 3 个 tab 的设备 → 点空(或点到别的东西)
**同一个选择器在不同机器上语义不同**,这是"时好时坏"的根源。
---
## 三、解法:用**语义选择器**,不要依赖序号
实测有效(同一台 3-tab 设备):
```python
# ✗ 序号型:依赖"底部有几个 tab"
(//*[@resource-id="com.ss.android.ugc.aweme:id/0qf"])[4]
# ✓ 语义型:同一个 id,用文字限定 —— 不管有几个 tab 都对
//*[@resource-id="com.ss.android.ugc.aweme:id/0qf" and @text="我"]
```
验证:命中(bounds 落在「我」上)→ 点击 → 进入我页面 → `jy-`(切换按钮)出现 ✓
同类可用限定条件(按优先级):
1. `@text="…"` —— 文字最稳(tab 名、按钮名)
2. `@content-desc="…"` —— 无文字但有描述(如 `首页,按钮`)
3. `@resource-id="…"` —— **唯一**时直接用
4. 组合:`//*[@resource-id="x" and @text="y"]` —— **同一 id 多实例时的最佳解**
5. 结构路径(`…/FrameLayout[2]`)—— 最后手段,最脆
6. `(…)[k]` —— **只在上面都不行时**
---
## 四、抓取弹窗"老是错位"的根因 —— **截图和元素树不是同一时刻**
(2026-09-13 追加,用户反馈"抓取的窗口老是会错位")
先把几个常见猜测逐一排除(都实测过):
| 猜测 | 实测结果 |
|------|---------|
| 两条通道元素树不一致 | ✗ 排除:节点数/各 id 个数逐项相同 |
| 截图被 CSS 缩放了、框没跟着缩 | ✗ 排除:`scaleX=clientWidth/naturalWidth` 算得对,实测框位置与理论值一致 |
| 窗口 resize 后没重算 | ✗ 排除:预览列固定 320px,resize 不影响 |
| 列表索引与框的 `data-idx` 对不上 | ✗ 排除:用 `indexOf` 保住原始序号,过滤后也不乱 |
**真因:截图与元素树分两次取,中间隔了 1~2 秒。**
| 取数方式 | 截图 | 元素树 | 两者时间差 |
|---|---|---|---|
| 现在(`/api/uiauto/screenshot` + `/api/uiauto/elements` 两次请求,走 uiautodev)| 0.4s | 1.8s | **约 1.8 秒** |
| 原生 u2 背靠背(同一个连接)| 0.3s | 1.4s | **约 1.4 秒** |
元素树的 dump 本身就要 1.3~1.8 秒(设备端 uiautomator 的开销,改不动)。
界面**只要在动**(抖音信息流、视频、加载动画…),两秒足够让元素位置全变 →
**框永远落在旧位置上** —— 这就是"老是错位",不是偶发。
### 解法(✅ 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. **双截图校验**(关键:让失败可见而不是悄悄错位):
抓取时前后各截一张,两张**不一致就明确提示**"界面在抓取过程中变化了,
请让设备停在目标界面再抓",而不是给一个已经错位的框。
3. 文档里明确写:**抓元素要让设备停在静止界面**(设置页、已加载完的页面),
别在视频播放/信息流滑动中抓。
---
## 五、建议的改动
1. ✅ **抓取器优先产出语义选择器**(2026-09-13 已实现,`core/uiauto_helper._extract`):
同一属性值出现多个实例时,**先找第二个属性把目标单独圈出来** ——
`//*[@resource-id="x" and @text="…"]`(次属性按 `text` > `content-desc` > `class`
排,单个不够就两个组合);只有怎么都分不开(列表里同 id 同文字)才退回
`(…)[k]`。返回值标 `semantic:true` + `via`(限定用的属性)或 `indexed:true`。
抓取列表里 **text/content-desc 排在最前并加粗上色**,让用户能看着选。
2. ✅ **序号型加醒目提示**(同日):前端把 `indexed` 的徽标从蓝色 `#k/n` 换成
黄色 **⚠ 序号 k/n**,悬浮说明"依赖同类元素个数,界面一变就指到别的元素上";
语义型给绿色 **✓ 语义**;右侧「属性」页新增一行**选择器稳定性**。
3. ⏳ **已存任务**:把 `(…)[k]` 型选择器扫一遍,能改成文字限定的自动改(脚本),
其余在编辑器里逐个复核。(用户自行处理)
4. ⏳ **运行期**:`click` 未命中时,日志里顺带 dump 一下"同 id 现在有几个实例",
让"序号错位"当场可诊断(现在是干巴巴一句"未找到元素")。
5. ✅ **(针对第四节的"抓取错位")** 抓取接口合并成一个快照接口 + 双截图校验,
具体见第四节「解法」。
---
## 六、顺带修掉的两个"结构性"坑(2026-09-13)
给 §五.1 做验证时(对真机 dump 逐条用 lxml 求值,比对"命中数==1 且 bounds 一致"),
发现旧实现 248 个元素里有 **26 条选择器永远匹配不到任何东西** —— 都是"结构路径"类:
| 坑 | 现象 | 原因 |
|---|---|---|
| `//FrameLayout[1]/LinearLayout[2]` | 零命中 | **dump 的 XML 标签名一律是 `<node>`**,class 在 `@class` 属性上 —— 把类名当标签名写的路径永远匹配不到 |
| `//hierarchy/node[@index="1"]` | 零命中或一次命中多个 | 同级节点的 **`index` 属性会重复**(实测状态栏/内容区/导航栏三个兄弟的 `index` 全是 `0`) |
**修法**:
- 子路径步进改成 `*[@class="android.widget.ImageView"][n]`(谓词 `[n]` 在步进选出的
节点集上按文档序定位,语义与原来的"同 class 兄弟序号"一致)
- 兜底结构路径改成按**子节点位置**:`//hierarchy/*[1]/*[3]`
**效果**(同一份真机 XML,248 个元素):
| | 旧 | 新 |
|---|---|---|
| 先按 lxml 求值,精确命中目标 | 221 | **247** |
| 零命中(死选择器) | 26 | **0** |
| 语义型 / 序号型 | 0 / 141 | **26 / 115** |
`core/uiauto_helper.py` 里 `_child_path()` 的注释与 `doc/TASK_DEV.md` §5.4 都记了这两个坑,
避免以后又写回来。