用户实测要求:"去掉切完账号下拉刷新这一步,直接改成等待 15 秒"。 原来的做法是"回读不到就再点一次「我」页签"—— 在设备上看着像去点刷新,还可能打断 抖音换号后的整体重载。 · tasks/generic/task.py:切完后按 wait_after(默认 15s,步骤参数可调)静等,等完只回读、 **不再点任何东西**(读不到就多读几次,页面渲染有零点几秒抖动) · 参数落到位:STEP_TYPES / 骨架 build_switch_step / 编辑器面板 / 发布页就地编辑字段表 · 测试:用例里 wait_after=0.2(不然一跑就一分多钟) · 文档:TASK_DEV §3 的 switch_account 行 + research/U2_ELEMENT_SELECTORS §七(记下"只能等"这条实测教训)
234 lines
12 KiB
Markdown
234 lines
12 KiB
Markdown
# 元素选择器研究:为什么"点不到按钮"(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 都记了这两个坑,
|
||
避免以后又写回来。
|
||
|
||
|
||
---
|
||
|
||
## 七、抖音「我」页面 + 账号切换列表的选择器(2026-10-12,A01 实测)
|
||
|
||
**背景**:发布计划改成按**名字**发(`doc/TASK_DEV.md` §4.7),平台需要两处控件:
|
||
① 「我」页面上的**昵称**(读当前登的是谁);② 「我」页面右上角的**切换账号**入口。
|
||
抖音 40.6.0 / 720×1650 实测(A01 = `192.168.20.82:5555`)。
|
||
|
||
### 用哪个(已实测可用)
|
||
|
||
| 用途 | 选择器 | 说明 |
|
||
|---|---|---|
|
||
| **读昵称** | `resourceId = com.ss.android.ugc.aweme:id/tk+` | 读回来就是昵称原文(如 `周周苏州吃喝玩乐`)。等价 xpath:`//*[@resource-id="com.ss.android.ugc.aweme:id/tk+"]` |
|
||
| **进切换列表** | `descriptionContains = 切换账号` | 右上角那个按钮是个 ImageView,**只有 content-desc**(`切换账号`),没有文字 |
|
||
| **切到某个号** | `textContains = <名字>` 点它 | 列表每行的名字是 `TextView`(id `tv_nickname`),**本身 `clickable=false`** —— 但 u2 的 `click()` 是**按中心坐标点屏**,行容器(无 id 的 `ViewGroup`,`clickable=true`)会接住这次点击,实测能切成 |
|
||
| **底部「我」页签** | `descriptionContains = 我` | 页签的 content-desc 是 `我,按钮`(**全角逗号**),`包含` 就能命中;别用 `text=我`(会撞上「我的订单」等) |
|
||
|
||
### 结构(40.6.0)
|
||
|
||
```
|
||
「我」页面
|
||
└ LinearLayout uj+ 资料块
|
||
├ ukd → s=g → TextView tk+ = 昵称 ← 读名字就用它
|
||
│ └ j2f → 1gp → ImageView oxp(desc=切换账号) ← 点它进列表
|
||
└ ukc → l1= → j6c → g7j = 抖音号:xxx
|
||
|
||
切换账号列表(点 oxp 后弹出的面板)
|
||
└ View(desc=返回,铺满面板,可点) ← "点面板外关掉"那层
|
||
├ ViewGroup(无 id,clickable)894~1060 ← 第 1 行(当前号)
|
||
│ ├ ImageView iv_avatar / TextView tv_nickname / TextView 37_(粉丝数)/ CheckBox b7w
|
||
├ ViewGroup 1060~1228 ← 第 2 行
|
||
├ ViewGroup 1228~1396 ← 第 3 行
|
||
└ ViewGroup 1396~1556(添加或注册新账号)
|
||
```
|
||
|
||
### 为什么**不要**用 `(…b7w)[N]`
|
||
|
||
`b7w` 是每行那个单选框(CheckBox)的 id —— `(…b7w)[2]` 看着能选中第 2 行,
|
||
但这正是本文 §二说的**序号型**:`[N]` 依赖"列表里有几行、面板从哪一行开始",号一多/一少、
|
||
或者抖音把「添加或注册新账号」挪个位置就点到别人号上了(**把内容发到别人号上**,最严重的一类错)。
|
||
按名字点(`textContains=<名字>`)永远指着那一行本身。
|
||
|
||
### 实测记录
|
||
|
||
- 冷启动(`app_stop` → `app_start`)后切号:**17.2s** 成功;热态切号 12.9s
|
||
- ⚠ 切完之后**只能等**:实测『回读不到就再点一次「我」』会被当成去点刷新、还可能打断抖音重载,用户直接要求『别刷新,等 15 秒』 → 现在是 `wait_after`(默认 15s,步骤参数可调)等完再回读
|
||
- ⚠ **第一次实现踩的坑**:把"进列表"后的等待写成**固定 sleep(1.2s) 再找一次**,
|
||
冷启动时列表还没渲染出来 → 判成"本机没登这个号" → **漏发**(还不会报错,只是不发)。
|
||
现在两处都改成**轮询到 deadline**(`max(6s, timeout)`):等面板出目标行、等回读变成目标名。
|
||
教训同 §一:**"找不到"和"没加载出来"必须用等待区分开**。
|
||
|
||
### 抓取命令(下次 App 改版后重抓)
|
||
|
||
```bash
|
||
# 前台确保是抖音且在「我」页,然后
|
||
.venv/Scripts/python -c "import uiautomator2 as u2; d=u2.connect('<serial>'); open('me.xml','w',encoding='utf-8').write(d.dump_hierarchy())"
|
||
# 在 me.xml 里找 text 是昵称、或 content-desc 是「切换账号」的节点,看它的 resource-id
|
||
```
|