需求:任务编辑器里能单独配"这个任务每隔 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。
144 lines
4.5 KiB
Python
144 lines
4.5 KiB
Python
"""uiautomator2 通用辅助函数。
|
||
|
||
所有任务都可复用:应用启动保障、等待首页、安全点击、随机操作等。
|
||
具体业务逻辑(如抖音首页特征元素判断)不放这里,放各自 task 文件。
|
||
"""
|
||
import time
|
||
import random
|
||
import re
|
||
|
||
from core.logger import get_logger
|
||
|
||
_log = get_logger("core.u2")
|
||
|
||
|
||
def ensure_app_running(d, package, max_restart=3, home_check=None):
|
||
"""确保 app 在前台运行,被人为退出/崩溃则自动重启。
|
||
|
||
d: u2.Device
|
||
package: 包名
|
||
max_restart: 最大重启次数
|
||
home_check: 可选回调 d->bool,判断是否进入主页。
|
||
默认只看包名(包名对就返回 True,和原抖音逻辑一致)。
|
||
返回 True 表示 app 在前台;连续重启失败返回 False。
|
||
"""
|
||
home_check = home_check or (lambda d: True)
|
||
for attempt in range(1, max_restart + 1):
|
||
try:
|
||
cur = d.app_current()
|
||
except Exception:
|
||
cur = {}
|
||
if cur.get("package") == package and home_check(d):
|
||
return True
|
||
_log.info(f"{package} 不在前台(当前:{cur}),第 {attempt}/{max_restart} 次重启...")
|
||
try:
|
||
d.app_start(package, wait=True)
|
||
time.sleep(3)
|
||
if home_check(d):
|
||
return True
|
||
except Exception as e:
|
||
_log.warning(f"重启异常: {e}")
|
||
time.sleep(3)
|
||
return False
|
||
|
||
|
||
def wait_for_app_home(d, package, home_check, timeout=40):
|
||
"""等待 app 进入主页。
|
||
|
||
home_check: d->bool 回调,返回 True 表示已进入主页。
|
||
抖音主页 Activity 名仍叫 SplashActivity,不能靠 Activity 名判断,
|
||
要用界面特征元素(由调用方通过 home_check 传入)。
|
||
"""
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
try:
|
||
cur = d.app_current()
|
||
except Exception:
|
||
cur = {}
|
||
if cur.get("package") == package and home_check(d):
|
||
return True
|
||
time.sleep(2)
|
||
return False
|
||
|
||
|
||
def random_sleep(min_s, max_s):
|
||
"""随机睡眠(模拟人类操作间隔)。"""
|
||
time.sleep(random.uniform(min_s, max_s))
|
||
|
||
|
||
def screen_is_on(d):
|
||
"""屏幕是否亮着。返回 True/False,取不到返回 None(三态,别当 False 用)。
|
||
|
||
为什么不用 `d.info`:它在部分设备上一次要**十几秒**(见 doc/backlog/TODO.md);
|
||
`dumpsys power` 经 atx-agent 跑只要 ~0.3s,且 `mWakefulness` 字段全机型都有
|
||
(MIUI 的 `dumpsys display` 反而没有 mScreenState)。
|
||
"""
|
||
try:
|
||
out = d.shell("dumpsys power")
|
||
out = getattr(out, "output", None) or str(out)
|
||
m = re.search(r"mWakefulness=(\w+)", out)
|
||
if m:
|
||
return m.group(1).lower() != "asleep"
|
||
except Exception as e:
|
||
_log.warning(f"读取屏幕状态失败: {e}")
|
||
return None
|
||
|
||
|
||
def current_package(d):
|
||
"""当前前台 App 包名;取不到返回空串。"""
|
||
try:
|
||
cur = d.app_current() or {}
|
||
return cur.get("package") or ""
|
||
except Exception as e:
|
||
_log.warning(f"读取前台 App 失败: {e}")
|
||
return ""
|
||
|
||
|
||
def safe_click(el, timeout=1):
|
||
"""安全点击:元素存在才点,不抛异常。返回是否点击成功。"""
|
||
try:
|
||
if el.exists(timeout=timeout):
|
||
el.click()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def safe_set_text(el, text, timeout=1):
|
||
"""安全输入文本:元素存在才输入,不抛异常。返回是否成功。"""
|
||
try:
|
||
if el.exists(timeout=timeout):
|
||
el.set_text(text)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def find_and_click(d, timeout=1, **selectors):
|
||
"""按定位器找元素并点击,找不到不抛异常。返回是否成功。
|
||
|
||
用法:find_and_click(d, text="首页") 或 find_and_click(d, description="搜索")
|
||
"""
|
||
try:
|
||
el = d(**selectors)
|
||
if el.exists(timeout=timeout):
|
||
el.click()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def random_swipe_up(d, width_ratio=0.5, height_start=0.8, height_end=0.2, duration=None):
|
||
"""随机上滑(刷视频常用)。坐标按屏幕比例算,适配不同分辨率。
|
||
|
||
duration: 滑动时长(秒),None 则随机 0.25~0.50s。
|
||
"""
|
||
info = d.info
|
||
w, h = info["displayWidth"], info["displayHeight"]
|
||
dur = duration or random.uniform(0.25, 0.50)
|
||
d.swipe(int(w * width_ratio), int(h * height_start),
|
||
int(w * width_ratio), int(h * height_end), dur)
|