用户要求:"进行每一步的时候先校验上一步有没有完成,比如相册界面肯定有特征,如果没有到相册界面
就出现执行,你看看怎么处理" → 方案 A:新增只做校验、不带副作用的步骤。
· tasks/generic/task.py:新步骤 expect_el
- 参数:特征元素选择器 / timeout(10) / what("在等什么") / on_missing
- on_missing:skip_rest(默认,**跳过本轮剩余步骤** —— 这一条不发、换下一个号) / notify / stop
- 为此加了 worker 级的 `_skip_rest`:_exec_steps 每步前检查,foreach_account 每轮复位
· 骨架:关键点击前各插一个 —— 相册页(//*[@text='所有照片']) → 素材已选中(desc 选择/预览)
→ 剪辑页(desc 文字/话题) → 发布页(含'添加标题'/'添加作品描述') → 发布按钮
· 通知:新增 task.page.mismatch(页面不对,本轮剩余步骤已跳过)
· 昵称选择器改**两版通吃**(实测 A06=40.7.0):xpath 联合
`//*[@resource-id="…:id/tkv"] | //*[@resource-id="…:id/tk+"]`
—— 40.7 是 tkv、40.6 是 tk+,联合在 40.7 上实测能读到名字;.env(dev)已改,切号入口两版同为 desc=切换账号
· 前端:编辑器面板 + 参数表单 + 发布页就地编辑字段表都认 expect_el
· 文档:TASK_DEV §3(27→28 种)+ §4.7("每步先校验上一步")、NOTIFY、research §七·补(40.7.0 对照表
+ 联合 xpath + 各页特征 + "别按坐标点页签"的坑)、.env.example
· 测试:新增 scripts/tests/test_expect_el.py(特征在/不在、三种 on_missing、选择器为空),全套 7 个用例通过
14 KiB
元素选择器研究:为什么"点不到按钮"(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 设备):
# ✗ 序号型:依赖"底部有几个 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-(切换按钮)出现 ✓
同类可用限定条件(按优先级):
@text="…"—— 文字最稳(tab 名、按钮名)@content-desc="…"—— 无文字但有描述(如首页,按钮)@resource-id="…"—— 唯一时直接用- 组合:
//*[@resource-id="x" and @text="y"]—— 同一 id 多实例时的最佳解 - 结构路径(
…/FrameLayout[2])—— 最后手段,最脆 (…)[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)
- 一次请求取齐(推荐,也是用户说的"用原始 u2"):
新增
GET /api/uiauto/snapshot?serial=X,服务端用一个 u2 连接背靠背dump_hierarchy()+screenshot(),返回{image, elements}; 前端只调这一个接口 → 天然同源,间隔从 ~2.2s 降到 ~1.4s。 并且抓取弹窗改成大图 1:1 预览(原来预览列只有 320px、图片被缩到 294px, 框全挤在一起,看着就像"错位";现在铺满左侧、按原始分辨率显示)。 - 双截图校验(关键:让失败可见而不是悄悄错位): 抓取时前后各截一张,两张不一致就明确提示"界面在抓取过程中变化了, 请让设备停在目标界面再抓",而不是给一个已经错位的框。
- 文档里明确写:抓元素要让设备停在静止界面(设置页、已加载完的页面), 别在视频播放/信息流滑动中抓。
五、建议的改动
- ✅ 抓取器优先产出语义选择器(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 排在最前并加粗上色,让用户能看着选。 - ✅ 序号型加醒目提示(同日):前端把
indexed的徽标从蓝色#k/n换成 黄色 ⚠ 序号 k/n,悬浮说明"依赖同类元素个数,界面一变就指到别的元素上"; 语义型给绿色 ✓ 语义;右侧「属性」页新增一行选择器稳定性。 - ⏳ 已存任务:把
(…)[k]型选择器扫一遍,能改成文字限定的自动改(脚本), 其余在编辑器里逐个复核。(用户自行处理) - ⏳ 运行期:
click未命中时,日志里顺带 dump 一下"同 id 现在有几个实例", 让"序号错位"当场可诊断(现在是干巴巴一句"未找到元素")。 - ✅ (针对第四节的"抓取错位") 抓取接口合并成一个快照接口 + 双截图校验, 具体见第四节「解法」。
六、顺带修掉的两个"结构性"坑(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 改版后重抓)
# 前台确保是抖音且在「我」页,然后
.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
七·补:40.7.0 的「我」页与发布流程各页(2026-10-12,A06 = 192.168.20.75 实测)
生产是 40.6 与 40.7 混装(9 台 40.7 / 3 台 40.6),两版的混淆 id 不一样。实测对照:
| 元素 | 40.6.0(A01) | 40.7.0(A06) |
|---|---|---|
| 「我」页昵称 | id/tk+ |
id/tkv |
| 切换账号入口 | desc=切换账号(id/oxp) |
desc=切换账号(id/ow_)同一个 desc ✓ |
| 抖音号 | id/504 |
id/5zk |
| 底部「我」页签 | desc=我,按钮 |
同样能用 desc 含我 命中 ✓ |
| 拍摄面板「相册」 | id/cnv |
id/cnv 同 id ✓ |
| 相册页 | —— | 标题 id/tv_title = 所有照片;页签 全部/视频/图片/动图/人像 |
| 选中素材后 | —— | 顶部出现 desc=选择 / 预览 |
| 剪辑页 | —— | desc=文字/话题/贴纸/特效/标记/滤镜(都挂在 id/iv_icon 上) |
| 发布页 | —— | 标题框 id/ipv(hint 添加标题)、描述框 id/ia-(hint 添加作品描述...) |
昵称的"两版通吃"写法(已实测):xpath 联合 ——
//*[@resource-id="com.ss.android.ugc.aweme:id/tkv"] | //*[@resource-id="com.ss.android.ugc.aweme:id/tk+"]
在 40.7.0 上 exists=True 且 get_text() 读到昵称 ✓。比按版本配两套省事,写进 .env 的
DOUYIN_NAME_SELECTOR_VALUE 即可(read_account_name 与「校验账号」共用它)。
为什么 desc=选择 这类"页面特征"能当校验:选素材/进剪辑页这些没有独立 id 的页面,
靠"这一页特有的文字/描述"判断"我在哪一页",比按坐标/序号稳 —— 这正是「校验页面」(expect_el)
要的东西(见 doc/TASK_DEV.md §3 第 28 行、§4.7)。
⚠ 走查时踩的坑:别用坐标点底部页签 —— 我按 (0.876,0.899) 点 A06 的「我」, 结果进了「抖音音乐榜」(
MusicDetailActivity)。同一台机器上desc 含我是好的, 按 desc 点才是对的(40.6 上坐标碰巧能用,不代表 40.7 也行)。