Files
auto_control/doc/TASK_DEV.md
T
butubb c29516cff5 feat(任务): 任务级「公共巡检」——独立于步骤画布的守护条件(含 webhook 通知)
需求:任务编辑器里能单独配"这个任务每隔 N 秒检查一次"——熄屏就点亮、某个元素
出现就通知、掉出 App 就停本设备;通知标题正文要能自己写。

配置与执行分离(这是本次的关键设计):
- **配置是任务级的**(`params.watchers`),在任务编辑器单独一块,不进步骤画布;
- **执行是穿插的**:worker 每执行完一步、以及长等待的每个分片,看一眼哪个巡检
  到点了。不起线程 → 不需要并发模型,也不会和主流程抢屏幕(两边同时点屏幕会
  互相打断)。代价是精度受步长影响(某步卡 30s,巡检最多晚 30s),已在文档写明。

- core/patrol.py(新):检查项/动作注册表(CHECKS/ACTIONS)+ evaluate/act。
  检查:屏幕熄灭/亮着、元素存在/不存在、前台是/不是某 App;
  动作:只通知、点亮、息屏、停止本设备。屏幕走 `dumpsys power`(0.3s,
  不用 d.info——那玩意在部分设备要 14s),前台走 d.app_current()(0.7s)。
- tasks/generic/task.py:`_maybe_patrol` / `_run_patrol`(冷却、命中记一条
  步骤明细、发通知);**文案在动作之前渲染**——点亮后 {screen} 就成了"亮屏",
  用户要看的是"发现熄屏,已点亮"。
- 任务编辑器新增「公共巡检」块(static/admin/tasks.js)+ 样式;保存进 params.watchers。
- 通知:新增事件 `task.patrol.hit`(巡检命中)与 `task.notify.custom`(步骤发通知);
  给了 title 就用它当标题(不再拼前缀),level 字段可点名级别。

顺带(巡检需要的原语,也可单独用):
- if_el 条件判断支持 `selector_type=screen`(亮/熄)与 `foreground`(前台包名);
  非元素条件不参与「选择器健康」统计(否则会攒出假的"选择器失效"告警)。
- 新增两个步骤:`notify`(发自定义通知)、`stop_self`(停本设备,记"被停止"
  而不是失败,不触发重试)。步骤类型 18 → 20,相关文档计数一并更新。

修 bug:`wait` 步骤在巡检耗时超过剩余时间后 `sleep(负数)` 抛
"sleep length must be non-negative"(真机联调抓到,已 clamp 到 0)。

自测:假设备单测 12 组(命中/冷却/间隔/元素/前台/停止/静默/异常不炸);
真机联调:熄屏→点亮(False→True)+ 通知文案正确、掉出抖音按间隔命中 5 次、
长等待里穿插生效且步骤回到 ok、清理后用户通知配置原样恢复。
文档:TASK_DEV §4.5(含两个可抄的例子)与步骤表/条件类型、NOTIFY §3、README。
2026-09-16 13:02:40 +08:00

500 lines
28 KiB
Markdown
Raw 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.
# 任务与步骤开发(TASK_DEV)
> 适用读者:写任务、编排步骤、加步骤类型的开发者。
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md) §5(调度链路)、[API.md](API.md) §5(任务接口)、[AI_CONSOLE.md](AI_CONSOLE.md)(AI 也会产步骤)。
> 现状:平台**只有 `generic_steps` 一种任务类型**,绝大多数 App 操作直接用步骤编辑器编排即可。
---
## 目录
- [1. 核心概念](#1-核心概念)
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
- [3. 20 种步骤全表](#3-20-种步骤全表)
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布))
- [5. 选择器与元素定位](#5-选择器与元素定位)
- [6. 自定义动作](#6-自定义动作)
- [7. 步骤编辑器(前端)](#7-步骤编辑器前端)
- [8. 新增任务类型](#8-新增任务类型)
- [9. Action 框架(App 专属操作)](#9-action-框架app-专属操作)
- [10. 测试与排查](#10-测试与排查)
- [11. 开发检查清单](#11-开发检查清单)
---
## 1. 核心概念
### 1.1 TaskType — 任务类型
一个 `TaskType` = `Task` 子类 + `Worker` 子类 + `DEFAULT_PARAMS`,用 `@register_task` 注册进全局 `_TASK_TYPES`(key 为 `task_type` 字符串)。
```python
# tasks/base.py
_TASK_TYPES = {}
def register_task(task_cls):
"""任务类型注册装饰器(无需传 name,用 task_cls.task_type)"""
_TASK_TYPES[task_cls.task_type] = task_cls
return task_cls
def get_task_class(task_type):
return _TASK_TYPES.get(task_type) # 未注册返回 None
```
**注册位置**:`tasks/__init__.py` 只做两件事——`from .base import ...` 聚合导出,以及 import 各任务包子包触发注册:
```python
# tasks/__init__.py
from .base import BaseTask, register_task, list_task_types, get_task_class
from .generic import task # 触发 @register_task(当前唯一任务类型)
```
前端「新建任务」的任务类型下拉来自 `GET /api/task_types`。新增类型要重启 Web 才生效。
> **未知任务类型的行为**:调度时打 error 返回;`POST /api/jobs/<id>/run` 直接返回明确错误;启动加载时会把库里残留的已删类型任务**列进日志告警**(只告警,不自动删用户数据)。
### 1.2 TaskJob — 任务计划
| 字段 | 说明 |
|------|------|
| `task_type` | 任务类型(不传默认 `generic_steps`) |
| `target` | `{"mode":"all"\|"group"\|"serial", "group_name":…, "serial":…}`,默认 `{"mode":"all"}` |
| `params` | 任务参数,与 `DEFAULT_PARAMS` **深合并**;含两个隐藏开关:`skip_offline`(默认 **true**,serial/group 模式先跳过离线设备)、`preempt`(默认 **false**,是否抢占正在跑别的任务的设备) |
| `schedule` | `{"mode":"once"\|"cron"\|"cron_stop", "cron":…, "stop_cron":…, "window":{…}}` |
| `retry` | `{"max_attempts":1, "delay":60}` |
| `enabled` | 是否参与调度 |
> **后端不做 params 校验**:只校验 `name` 非空 + `task_type` 已注册,其余原样盲存——**步骤编辑器是参数正确性的唯一关卡**,保存前务必用编辑器的 validate / "测试此步骤" 自校验。
### 1.3 Worker — 单设备执行
每台设备一个 `BaseWorker` 线程(`core/device_worker.py`)。基类已封装:设备获取(IP:5555 直连 / USB 走远程 adb server,`try/finally` 保证释放)、u2 连接(30s 超时)、状态上报、异常分类(设备离线不重试)、`self.stopped()` 停止信号、心跳看门狗(120s)。子类只实现 `run_task(d)`。
可用的基类能力:
| 方法 | 用途 |
|------|------|
| `self.set_action(text)` | 上报"当前动作"(同时刷新心跳) |
| `self.set_progress(done=, total=, unit=, action_counts=, elapsed=)` | 上报进度(前端统一渲染进度条 + 徽章) |
| `self.heartbeat()` | 长循环内手动刷新心跳 |
| `self.stopped()` / `self.is_time_up()` | 检查停止信号 / 任务时长上限 |
| `self.serial` / `self.params` | 当前设备 / 合并后的参数 |
| `self._start_timer()` / `self.elapsed()` | 计时 |
---
## 2. 通用步骤任务 generic_steps
`task_type="generic_steps"`(`tasks/generic/task.py`):把任意 App 操作编排成"步骤链"。前端**步骤编辑器**拖拽节点 → 存为 `params.steps`(JSON 数组)→ worker 按顺序执行。
```json
{
"max_duration": 0,
"steps": [
{"id": "step_1", "type": "open_app", "label": "打开抖音",
"params": {"package": "com.ss.android.ugc.aweme", "wait_home": true, "home_feature": "拍摄"}}
]
}
```
- **任务级参数**:`DEFAULT_PARAMS = {"max_duration": 0}`(0 = 不限时)
- **没有默认步骤**:`steps` 只能由编辑器产出;为空时 worker 立即报错「通用步骤任务没有可执行步骤:请在「任务」页编辑该任务并添加步骤」(设备「最近错误」可见),**不会静默空跑**
- **顶层 steps 只顺序执行一次**——需要重复的动作必须显式放进 `loop` 步骤
- **每步 schema**:`{id, type, label, params}`;`id` 由前端生成保证唯一(导出为自定义动作时会剥掉 id)
**进度上报**:`done` = 累计执行的**非容器**步骤数(`loop`/`group`/`if_el` 不计入,避免噪音)、`total=0`、`unit="操作"`,前端显示"已执行 N 次操作"。
**选择器健康跟踪**:某选择器连续未命中达阈值(10 次)会记 `last_warning`("选择器连续 N 次未命中"),提示 App 改版导致选择器失效。
---
## 3. 20 种步骤全表
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
| # | type | 名称 | 必填 params | 可选 params(默认) | 子步骤 | 语义要点 |
|---|------|------|------------|--------------------|--------|---------|
| 1 | `open_app` | 打开 App | `package` | `wait_home`(False)、`home_feature`("") | — | `app_start(package, wait=True)`;`wait_home=true` 时以 `descriptionContains=home_feature` 作为"到首页"判定,超时只提示不失败。**缺 `package` 只告警跳过** |
| 2 | `stop_app` | 结束 App | `package` | — | — | `app_stop`(`am force-stop`,下次打开是冷启动) |
| 3 | `screen_on` | 亮屏 | — | — | — | `d.unlock()`(息屏时唤醒并滑动解锁) |
| 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` |
| 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则**把系统息屏超时顶到最大**(`screen_off_timeout=2147483647`)+ `svc power stayon true` + 唤醒一次。⚠️ 只发 `svc power stayon true` 对**没插充电器**的设备(走 WiFi 的那些)**完全无效**——它管的是"充电时屏幕常亮",2026-09-14 就是因此被反馈"保持亮屏没用" |
| 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** |
| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50) | — | 时长取随机值;up = 中轴从 0.8h → 0.2h(down 反向,left/right 同理);**非法 direction 静默不滑** |
| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True) | — | 每轮滑动后 sleep 0.5s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 |
| 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警** |
| 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** |
| 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` |
| 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) |
| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 click 输入框) |
| 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);`paste=true` 时再触发一次粘贴 |
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断 |
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App** |
| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
---
## 4. 容器步骤与公共参数
### 4.1 `loop` 三种模式
| `loop_mode` | 行为 |
|-------------|------|
| `rounds`(默认) | 跑 `max_iterations` 轮 |
| `forever` | 一直循环,直到任务被外部停止或达到 `max_duration`(配合调度"定时启动+停止"或任务时长上限使用) |
| `time` | 跑满 `loop_duration` 秒(`<=0` 时**跳过**) |
> 未知 `loop_mode` 会**按 `rounds` 处理**(不报错)。
### 4.2 `if_el` 条件判断
- 先做判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套)
- **`selector_type` 还支持两类非元素条件**(只有条件判断有,别的带选择器的步骤没有):
- `screen` —— 屏幕状态,`selector_value` 填 `off` / `on`(也认 `熄屏`/`灭屏`):读 `dumpsys power` 的 `mWakefulness`;
- `foreground` —— 前台是不是某个包名,`selector_value` 填包名(如 `com.ss.android.ugc.aweme`)。
- 这两类**不参与「选择器健康」统计**(它们不是选择器,不该攒出「连续未命中」告警),也不吃 `timeout`(只看当前状态,不等待)
- `selector_type="ocr"` 时改用 **OCR**:截屏 → `find_on_screen(img, selector_value)` 子串匹配;命中且 `ocr_click=true` 会点击命中位置。
- OCR 模式下 **`timeout` 不生效**(单次截图即判定)
- OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定)
- 命中长文本时点击位置按关键词在文本中的比例估算(避免点到整块中心偏离)
### 4.3 公共参数
| 参数 | 说明 |
|------|------|
| `probability` | 0-100,默认 100。小于 100 时**按概率决定本次是否执行该步**(如 30 ≈ 隔几次才触发一次);未触发会打日志"概率 X% 未触发,跳过" |
### 4.4 嵌套与容错
- **嵌套深度上限 5 层**:`loop.children` / `group.children` / `if_el.then/else` 递归超过 5 层会**整段跳过并告警**
- 每步执行前检查 `stopped()` / `is_time_up()`
- **静默跳过**:未知 `type`、缺必填参数(如 `click` 没选择器、`open_app` 没包名)只打 WARNING,**任务照常"成功"结束**。排查"任务成功了但什么都没做"时先看 `logs/task.log`
- 单个步骤内部异常被捕获并记 error,**不中断后续步骤**
---
### 4.5 公共巡检(任务级,独立于步骤画布)
任务运行期间**要全程盯着的条件**(熄屏就点亮、掉到桌面就通知、刷到广告就停),
配在任务编辑器的「公共巡检」块里,**不进步骤画布**。权威实现在 `core/patrol.py`。
| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) |
|---|---|---|
| 配置位置 | 步骤画布,拖出来的 | 任务编辑器单独一块 |
| 执行方式 | 顺序执行 | **穿插**:每执行完一步、以及长等待的每个分片,看一眼"哪个巡检到点了" |
| 适合 | 一段固定流程 | 全程都要盯的守护条件 |
为什么穿插而不是另起线程:不需要并发模型,也**不会和主流程抢屏幕**(否则巡检点屏幕、
主流程同时也在点,动作互相打断)。代价是**精度受步长影响**——某一步卡 30 秒,
巡检最多晚 30 秒。
配置结构(`params.watchers` 数组,每项):
```json
{"name": "熄屏点亮", "enabled": true, "interval": 60,
"check": "screen_off",
"selector_type": "xpath", "selector_value": "",
"action": "screen_on",
"notify": true, "title": "{device} 熄屏了",
"message": "任务 {job} 在 {time} 发现 {screen},已点亮",
"cooldown": 300}
```
| 字段 | 说明 |
|------|------|
| `interval` | 检查间隔(秒,最小 5)。**首次检查在任务开始时立刻做一次**,之后按间隔 |
| `check` | `screen_off` 屏幕熄灭 / `screen_on` 屏幕亮着 / `element_exists` 元素存在 / `element_missing` 元素不存在 / `foreground_is` 前台是该App / `foreground_not` 前台不是该App |
| `selector_type`/`selector_value` | 元素类检查填选择器;`foreground_*` 填**包名**;屏幕类不用填 |
| `action` | `none` 只发通知 / `screen_on` 点亮屏幕 / `screen_off` 熄灭屏幕 / `stop_self` 停止本设备任务 |
| `notify` | 命中是否推通知(事件 `task.patrol.hit`,见 [NOTIFY.md](NOTIFY.md));`false` 就只做动作 |
| `title`/`message` | 通知标题正文,可用 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}`。**文案在动作之前渲染**,所以写的是"发现熄屏,已点亮"而不是"发现亮屏" |
| `cooldown` | 命中后多少秒内不再重复动作/通知(默认 300)。**条件持续成立时(一直熄屏)靠它防刷屏** |
两个能直接抄的例子:
```jsonc
// 1) 熄屏就点亮(不吵人):每分钟看一眼,熄了就点亮
{"name":"熄屏点亮","interval":60,"check":"screen_off","action":"screen_on","notify":false}
// 2) 掉出抖音就通知 + 停本设备:前台不是抖音 → 通知 → 停下来等你处理
{"name":"掉出抖音","interval":60,"check":"foreground_not","selector_value":"com.ss.android.ugc.aweme",
"action":"stop_self","notify":true,"title":"{device} 掉出抖音了",
"message":"当前前台 {app},已停止本设备任务"}
```
要点与坑:
- **巡检不是步骤**:不要试图在画布上找它;它也不计入"已执行 N 次操作"的进度。
- 一次检查要查设备:屏幕 `0.3s`、前台 `0.7s`,**元素检查要 dump UI 树(慢设备上可能几秒)**。
所以 `interval` 别设太小(元素检查建议 ≥30s)。
- 巡检命中会往「日志 → 步骤明细」记一行(`step_type=patrol`,只记命中不记"没事发生")。
- 巡检内部异常只记日志、**绝不影响任务主流程**(同 `notifier.notify` 的口径)。
---
## 5. 选择器与元素定位
### 5.1 `selector_type` 支持的值
| 值 | 匹配语义 | 谁支持 |
|----|---------|--------|
| `xpath` | `d.xpath(value)`(存在性判断必须用 `wait()`) | 所有带选择器的步骤 |
| `description` | `content-desc` **精确**匹配 | 同上 |
| `descriptionContains` | `content-desc` **模糊**匹配(适配文案版本差异) | 同上 |
| `text` | 文本**精确**匹配 | 同上 |
| `resourceId` | `resource-id` 精确匹配(要带包名前缀 `com.xxx:id/…`) | 同上 |
| `className` | 类名匹配(如 `android.widget.EditText`) | 同上 |
| `ocr` | 截屏 OCR 文字子串匹配 | **仅 `if_el`** |
> 选择器值是**直接透传**给 uiautomator2 的 kwarg,**没有服务端校验**——写错了只会"找不到元素"。
### 5.2 定位优先级(重要)
```
① 目标有可见文字 → 用 text / descriptionContains(最稳)
② 有稳定 resource-id → 用 resourceId 或 xpath
③ 文案会变但有结构 → 用 descriptionContains 或结构 XPath
④ 纯图形、树里找不到 → 才用 click_xy(坐标,最脆)
```
**坐标是最脆的**:分辨率/布局一变就失配,且抓取时的坐标在其他设备上未必有效。抓取元素请在**任务实际运行的那台设备的同一界面**上做。
### 5.3 XPath 序号语义(踩过的坑)
| 写法 | 含义 |
|------|------|
| `//*[@resource-id="x"][2]` | 「在**其父节点**中排第 2 的匹配」——**不是**"第 2 个匹配" |
| `(//*[@resource-id="x"])[2]` | 「第 2 个匹配」← **抓取器生成的形式** |
历史实现写了前者,导致同 id 多实例(如底部导航 4 个 tab)时 `[2..n]` 全部失配。现在:
- 执行器 `tasks/generic/task.py` 的 `_norm_legacy_xpath` 会**自动纠正**旧任务里的 `//*[@x][k]` 写法(仅前缀,类名/位置步进不受影响)
- 抓取器生成 `(…)[k]` 前**先做语义消歧**(见 §5.4),序号型只是最后的退路
⚠️ **序号型选择器仍然脆弱**:它依赖"抓取那一刻该属性有 ≥k 个实例"。界面不同(如 App 还在闪屏页)就会失配——优先选带**文字**的元素,抓取器会自动产出语义选择器。
### 5.4 抓取器给的建议选择器
`GET /api/uiauto/elements`、`GET /api/uiauto/snapshot` 返回的元素都带 `suggested`,生成优先级:
1. 有 `resource-id` → `//*[@resource-id="v"]`;无 id 但有 `text`/`content-desc` 同理
2. 该属性值**全树有多个**时,**先用第二个属性把目标单独圈出来**(语义选择器,标 `semantic:true` + `via`):
`//*[@resource-id="v" and @text="我"]`。抖音底部导航同 id 的几个 tab 靠这个解决——
灰度版 tab 数量会变(4 个 ↔ 3 个),序号必然错位,文字不会
3. 两个属性组合仍分不开(列表里同 id 同文字)→ 才退回序号 `(…)[k]`(标 `indexed:true` + `occ`/`total`,前端打**黄标 ⚠ 序号**)
4. 都没有属性、但有"最近的有属性祖先" → `{祖先}/*[@class="android.widget.ImageView"][n]`
5. 连祖先都没有 → `//hierarchy/*[i]/*[j]`(按**子节点位置**逐层,标 `broad:true`,脆弱、前端会提示)
6. 类名也是空 → 标 `invalid:true`(提示无法生成可靠选择器)
**两个必须记住的 XPath 坑**(都实测踩过):
- **dump 的 XML 标签名一律是 `<node>`**,`class` 在 `@class` 属性上。所以结构步进只能写
`*[@class="…"]` 或位置 `*[i]`;写成 `//FrameLayout[1]` 这种"类名当标签名"的路径**永远零命中**
(2026-09-13 修:248 个元素里曾有 26 条死选择器)
- **同级节点的 `index` 属性会重复**(状态栏/内容区/导航栏三个兄弟的 `index` 全是 `0`),
兜底结构路径要按**位置** `*[i+1]` 定位,不能按 `@index`
### 5.5 抓取与验证(编辑器里的两个按钮)
- **「▶ 点一下」**:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素中心),返回吸附结果并刷新截图——确认位置是否可达
- **「✓ 测选择器」**:用**将填入的选择器**真跑一次 click(`POST /api/steps/test`)→ 返回 `命中 / 未找到 / 已执行`——确认回填的选择器在真实界面能命中
- **「测试此步骤」**:在编辑器里对任意步骤做真机试执行(不占用设备池、不影响运行中的任务)
### 5.6 u2 辅助函数(`core/u2_helper.py`)
| 函数 | 用途 |
|------|------|
| `ensure_app_running(d, package, max_restart=3, home_check=None)` | 确保 App 在前台,必要时重启 |
| `wait_for_app_home(d, package, home_check, timeout=40)` | 等 App 首页就绪。**注意:主页 Activity 名可能是 `SplashActivity`,不能靠 Activity 名判断,要用界面特征元素** |
| `random_sleep(min_s, max_s)` | 随机间隔(模拟人类) |
| `safe_click(el, timeout=1)` / `safe_set_text(el, text, timeout=1)` | 存在才点/才输,不抛异常 |
| `find_and_click(d, timeout=1, **selectors)` | 找元素并点击 |
| `random_swipe_up(d, ...)` | 按比例上滑,时长可随机 |
---
## 6. 自定义动作
**自定义动作**(`custom_action` 表)把一串常用步骤打包成可复用动作,供任何 `generic_steps` 任务拖入:
- 编辑器里 **☑ 多选步骤 → 「打包选中步骤」** 即可生成
- 拖入画布时**展开为 `group` 节点**(执行语义等同顺序执行一次)
- 「任务 → 自定义动作」子分栏可查看/编辑/删除
> 想做"引用型"节点(改一处、处处生效)目前不支持,见 [backlog/TODO.md](backlog/TODO.md)。
---
## 7. 步骤编辑器(前端)
`static/admin/editor.js` 的 `_stepEditor` 单例,能力:
| 能力 | 说明 |
|------|------|
| 操作库拖拽 | 左侧分类库(interact / screen / flow),拖到画布;支持跨层级嵌套(循环套循环)、拖拽排序 |
| 参数表单 | 按步骤类型渲染对应字段(选择器行、时长、概率、循环模式等) |
| 条件分支 | `if_el` 的 then/else 两个分支区;OCR 模式下多出"命中后点击"复选框 |
| 多选与打包 | ☑ 多选 → 打包成自定义动作 |
| 元素抓取 | 「抓取元素」打开抓取弹窗(选设备 → 截图 + 元素树 → 点选回填),见 §5.5 |
| 测试 | 「测试此步骤」真机试执行 |
| 草稿自动保存 | 编辑中的步骤会存草稿,避免误关丢失 |
> 后端 `STEP_TYPES` 与前端 `STEP_LIB` 是**两份**定义,新增/修改步骤类型必须同时改,否则会出现"编辑器里有、后端不认识"(静默跳过)或反之。
---
## 8. 新增任务类型
> 平台当前只有 `generic_steps`。**先确认是否真的需要**:绝大多数 App 操作直接用步骤编辑器编排即可,新建任务类型的成本与维护负担都不低。
### 8.1 目录结构
```
tasks/<app>/
├── __init__.py # from . import task 触发注册
├── task.py # DEFAULT_PARAMS + Worker + Task + @register_task
└── actions/ # 该任务专属操作(可选)
├── __init__.py # 先 from .base import ACTIONS,再 from . import xxx
├── base.py # ACTIONS = create_action_registry()
└── like.py # @register_action(ACTIONS) LikeAction
```
参考实现:`tasks/generic/task.py`(步骤引擎)。
### 8.2 骨架
```python
# tasks/<app>/task.py
from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status
from core.logger import get_logger
log = get_logger("task.<app>") # 写入 logs/task.log
DEFAULT_PARAMS = {
"count": 20, # 任务自身参数放这里,不放 config.py
"max_duration": 0, # 0 = 不限时(时长上限由基类实现)
}
class MyWorker(BaseWorker):
"""单设备执行体:只实现 run_task(d),d 是已连好的 u2.Device。"""
def __init__(self, serial, params=None):
super().__init__(serial, params)
p = {**DEFAULT_PARAMS, **(params or {})}
self.count = int(p.get("count", 20))
def run_task(self, d):
self._start_timer()
self.set_progress(done=0, total=self.count, unit="次")
for i in range(self.count):
if self.stopped() or self.is_time_up(): # 必须检查,否则无法停止/超时
break
self.set_action(f"第 {i+1}/{self.count} 次") # 上报当前动作(同时刷新心跳)
# ... 业务操作(用 d 做点击/滑动/输入)...
self.set_progress(done=i + 1, total=self.count, unit="次",
action_counts={}, elapsed=self.elapsed())
@register_task
class MyTask(BaseTask):
task_type = "my_task"
name = "我的任务"
description = "…"
default_params = dict(DEFAULT_PARAMS)
@classmethod
def list_action_types(cls):
return [] # 有专属操作时返回其清单
@classmethod
def get_action_class(cls, action_type):
return None
def create_worker(self, serial, params, ctx=None):
merged = {**DEFAULT_PARAMS, **(params or {})}
return MyWorker(serial, params=merged, ctx=ctx)
```
```python
# tasks/<app>/__init__.py
from . import task # noqa: F401 触发 @register_task 注册
```
```python
# tasks/__init__.py —— 加一行
from .base import BaseTask, register_task, list_task_types, get_task_class
from .generic import task
from .myapp import task # ← 新增
```
重启 Web 后,「新建任务」的任务类型下拉会出现新类型。
### 8.3 签名约定
- `BaseWorker.__init__(self, serial, params=None, daemon=True, ctx=None)`
- `Task.create_worker(self, serial, params, ctx=None)` —— **不要带 `stf_client` / `stf` 形参**(STF 已摘除,历史签名已清理)
- `ctx` 是本次运行的上下文(`run_id` / `job_id` / `job_name` / `device_name`),由
`TaskManager._run_with_retry` 传入;**只用于结构化记录**(如步骤明细
`core/step_log.py`),不参与业务逻辑。不关心就原样透传给 `BaseWorker` 即可。
---
## 9. Action 框架(App 专属操作)
`core/actions/` 提供**跨任务的通用操作抽象**:`BaseAction` 接口、概率触发、注册机制;**每个 App 有自己的注册表**(`create_action_registry()`),互不污染。
```python
# tasks/<app>/actions/like.py(示例)
from core.actions import BaseAction, register_action, should_trigger
from . import ACTIONS # 本 App 的注册表
@register_action(ACTIONS)
class LikeAction(BaseAction):
action_type = "like"
name = "点赞"
default_params = {"rate": 0.3}
def execute(self, ctx):
if not should_trigger(self.params): # 按概率决定是否本次触发
return False
... # ctx 提供 device/worker/进度等
return True
```
**循环导入坑**:`actions/__init__.py` 必须**先** `from .base import ACTIONS`,**再** `from . import like`。
> 当前 `generic_steps` **不使用** Action 框架(它直接分发到 `_exec_<type>`);Action 框架留给"专属任务类型"使用。
---
## 10. 测试与排查
| 场景 | 做法 |
|------|------|
| 试执行单个步骤 | 编辑器「测试此步骤」(`POST /api/steps/test`)——真跑一次,返回 `命中 / 未找到 / 已执行` |
| 试执行整个任务 | 建一条只含该步骤的临时任务,目标选**一台**设备,`enabled=false` +「执行任务」 |
| 看任务日志 | `logs/task.log`(业务)、`logs/core.log`(调度/adb) |
| 看设备错误 | 监控页「最近错误」列;**失败原因会带真实错误**(不再只有"重试N次失败") |
| 步骤没生效 | 检查是否有 WARNING:未知 type / 缺必填参数**只告警不报错** |
| 选择器失效 | 看设备 `last_warning`("选择器连续 N 次未命中")→ 重新抓取 |
**没有步骤的任务**:执行时会明确报错,不会静默空跑。
---
## 11. 开发检查清单
新增/修改任务与步骤时,逐条打勾:
- [ ] 后端 `STEP_TYPES` 与前端 `STEP_LIB` 一致(type 名、字段、默认值、分类)
- [ ] 新步骤在 `_exec_<type>` 里实现了,且缺参数时有明确日志
- [ ] 长循环里调用了 `self.stopped()` / `self.is_time_up()` / `self.heartbeat()`
- [ ] 用文字/id 定位,没有滥用坐标
- [ ] 抓元素是在**目标任务运行的那台设备**上、停在**同一界面**时做的
- [ ] 新增任务类型已注册进 `tasks/__init__.py`
- [ ] 文档同步:[TASK_DEV.md](TASK_DEV.md)(步骤表)、[API.md](API.md)(若接口有变)、必要时 [AI_CONSOLE.md](AI_CONSOLE.md)(AI 沉淀动作的白名单)
- [ ] 临时测试数据已清理(任务/分组/自定义动作)