- 新增 generic_steps 通用步骤任务(可视化步骤编辑器编排流程,支持 open_app/click/swipe/input_text/wait/loop/group) - 新增 core/uiauto_helper.py 封装 uiautodev 元素抓取客户端 - web_server 新增元素抓取/截图/设备列表等 API - monitor.html 新增步骤编辑器、元素抓取模态框、独立关闭逻辑 - 新增 README.md 项目总览(快速上手/架构/配置/FAQ) - 新增 doc/ARCHITECTURE.md 架构详解、doc/DEPLOY.md 部署指南、doc/API.md 接口文档 - 修复 doc/TASK_DEV.md:移除已删除的 comment 引用,补充 generic 包,更新注册示例 - .gitignore 忽略 .claude/ 工具产物
14 KiB
架构详解
本文面向想深入理解 platform-tools 内部设计的开发者。如果你只想使用,看 README.md 即可。
1. 分层设计
平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉:
| 层 | 路径 | 职责 |
|---|---|---|
| 配置层 | config.py |
项目根配置:STF 地址、adb 路径、web 端口等基础设施。不放任务参数 |
| 核心层 | core/ |
框架运行时:日志、STF 客户端、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 |
| 任务层 | tasks/ |
每个 App 一个子包,自包含 task.py + actions/,互不依赖 |
| 前端层 | templates/admin/ |
单页应用(纯 HTML+CSS+JS,无框架) |
| 数据层 | data/ |
SQLite 持久化 |
| 日志层 | logs/ |
四类日志,10MB 滚动保留 5 份 |
| 工具层 | bin/adb/ |
adb 可执行文件 |
| 脚本层 | scripts/ |
实用脚本 |
分层原则
- 任务自包含:每个任务的参数、Worker、操作都放在
tasks/<app>/下,不污染全局 - 核心不依赖任务:
core/不 importtasks/,任务通过注册机制接入 - 配置最小化:
config.py只放基础设施配置,任务参数在各自task.py顶部
2. 数据流
┌──────────────┐ 创建/编辑任务 ┌─────────────┐ 分发 worker ┌──────────────┐
│ 单页应用前端 │ ───────────────► │ TaskManager │ ─────────────► │ Worker(设备) │
│ (monitor.html│ └─────────────┘ └──────────────┘
│ fetch+DOM) │ ▲ │
└──────────────┘ │ 状态/心跳 │ u2 操作
│ │ ▼
│ JSON API │ ┌─────────────────┐
▼ ┌──────────────┐ │ STF Device / adb │
┌──────────────┐ │ 看门狗监控 │ └─────────────────┘
│ web_server │ └──────────────┘
│ (Flask API) │
└──────────────┘
│
▼
┌──────────────┐
│ data/users.db│ SQLite 持久化(用户/分组/任务/自定义动作/APK记录)
└──────────────┘
任务执行流程
- 前端创建 TaskJob(HTTP POST
/api/jobs) TaskManager保存到 SQLite,如启用 cron 则注册到 APScheduler- 手动执行或 cron 触发时,
_run_job解析目标设备列表 - 每台设备起一个线程
_run_with_retry,含重试循环 - 线程内
task.create_worker()创建 Worker,worker.start()启动 BaseWorker.run()执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放- Worker 通过
_update_status()实时上报状态到全局_WORKERS字典 - 前端轮询
/api/status(5 秒缓存)获取设备 + Worker 状态
3. 核心模块详解
3.1 STFClient(core/stf_client.py)
OpenSTF REST API 封装,负责设备池管理。
关键设计:
- 所有请求带
timeout=(5, 15),避免 STF 网关 504 时卡死 remote_connect带重试(3 次),STF provider 慢启动时需要- 错误分类:
DeviceOfflineError(不重试)/STFNetworkError/DeviceConflictError(自动重试) - 占用冲突自动清理重试(偶发 "already in use" 脏状态)
核心方法:
| 方法 | 说明 |
|---|---|
list_all_devices() |
返回 STF 上所有设备(含状态) |
list_free_devices() |
返回可占用的空闲设备 |
list_my_devices() |
返回当前账户已占用的设备 |
occupy(serial) |
占用设备(冲突自动重试) |
release(serial) |
释放设备(含远程断开) |
remote_connect(serial) |
建立远程 ADB 隧道,返回 remoteConnectUrl |
remote_disconnect(serial) |
断开远程 ADB 隧道 |
3.2 STFDevice + BaseWorker(core/device_worker.py)
STFDevice — 单设备生命周期管理:
acquire():占用 → remoteConnect → adb connectrelease():adb disconnect → remoteDisconnect → STF release- 直连模式:serial 为
IP:5555时直接 adb connect,不走 STF 桥接
BaseWorker — 通用 Worker 基类(继承 threading.Thread):
run() 主循环(不要重写):
1. acquire 设备
2. u2.connect(30s 超时保护)
3. setup(d) ← 子类可选钩子
4. run_task(d) ← 子类必须实现
5. teardown(d) ← 子类可选钩子
6. finally: release 设备
超时保护:
u2.connect()用ThreadPoolExecutor + 30s 超时,防止 atx-agent 无响应永久 hangd.info用ThreadPoolExecutor + 10s 超时
心跳看门狗(_Watchdog):
- 后台线程,每 30 秒扫描一次
- Worker 超过 120 秒无心跳 → 标记
error - 防止设备被占用却不干活
全局状态注册表(_WORKERS):
serial -> status dict,线程安全(_WORKERS_LOCK)- 供
web_server读取实时状态,前端通过/api/status展示
3.3 TaskManager(core/task_manager.py)
统一管理:任务类型注册、设备分组、任务计划、定时调度、重试、持久化。
核心组成:
| 组件 | 说明 |
|---|---|
scheduler |
APScheduler BackgroundScheduler,cron 触发任务 |
groups |
设备分组(内存业务对象,持久化到 SQLite) |
jobs |
任务计划(内存业务对象,持久化到 SQLite) |
_running |
运行中的 worker(serial -> worker 信息) |
_stop_requested |
用户请求停止的 serial 集合(阻止后续重试) |
_fg_scanner |
前台 App 扫描器(不打扰设备) |
调度模式:
once:不注册 cron,手动执行cron:注册启动 cron,到点启动所有目标设备cron_stop:注册启动 cron + 停止 cron,到点停止本任务 worker
重试策略:
DeviceOfflineError:立即放弃,不重试(设备掉线短时间不会自愈)- 其他异常:按
retry.max_attempts重试,间隔retry.delay - 临时错误(端口耗尽):退避 max(delay, 120s)
- 用户停止:加入
_stop_requested,阻止任何后续重试
并发控制:同一 serial 同时只允许一个 worker,避免冲突。
状态缓存:get_status() 带 5 秒缓存,STF 请求慢时不阻塞前端。
3.4 前台 App 扫描器(_ForegroundScanner)
设计原则:不打扰设备,扫描不会让设备退出当前 App。
| 设备状态 | 处理方式 | 是否打扰 |
|---|---|---|
| worker 运行中 | 复用已有 ADB 连接查询 | 否 |
| 自己占用无 worker | STF remoteConnect 隧道查询 | 否 |
| 完全空闲 | 返回"空闲"(不 adb connect,避免 STF 误判离线) | 否 |
| 他人占用 | 标记"(他人占用)" | 否 |
为什么不扫描空闲设备的前台 App:STF provider 内部通过 IP:5555 维持 adb 连接。外部 adb connect/disconnect 会让 adb server 断开该 transport,连带 STF provider 的连接断开,STF 误判设备 offline 并触发重连。
3.5 ADB 操作(core/adb_helper.py)
全局锁串行化:_ADB_LOCK 确保所有 adb 调用串行执行,避免多线程竞争 adb server。
铁律:绝不 kill-server:
adb kill-server会断开所有设备的 adb transport- 导致 STF provider 对全部设备误判离线并触发重连
- 影响所有运行中的任务
| 函数 | 说明 |
|---|---|
adb_connect(url, retries=5) |
adb connect(带重试,绝不 kill-server) |
adb_connect_light(url) |
轻量 connect(单次尝试,扫描专用) |
adb_disconnect(url) |
adb disconnect |
screenshot(serial) |
截图(adb exec-out screencap -p,只读安全) |
get_foreground_app(url) |
获取前台 App 包名(dumpsys window) |
identify_device(serial) |
让设备响铃识别 |
3.6 数据模型(core/models.py)
SQLAlchemy 模型,存于 data/users.db:
| 模型 | 表名 | 说明 |
|---|---|---|
User |
user | 后台用户(Flask-Login 认证,SHA256 密码) |
DeviceGroup |
device_group | 设备分组(serials 存 JSON) |
TaskJob |
task_job | 任务计划(target/params/schedule/retry 存 JSON) |
CustomAction |
custom_action | 自定义动作(步骤打包,steps 存 JSON) |
ApkFile |
apk_file | APK 文件元信息 |
数据库初始化(init_db):
- 创建所有表
- 首次启动创建默认管理员
admin/admin123 - 自动迁移旧
groups.json/jobs.json到 SQLite(迁移后归档为.migrated)
3.7 APK 管理(core/apk_manager.py)
APK 上传/解析/批量安装。
设备连接策略(直连,绕过 STF):
- 直接
adb connect serial(serial 是 IP:5555) - 不经过 STF occupy/release,避免 STF release 触发 agent 清理卸载 app
- 安装后不主动 disconnect(STF provider 共享该 adb transport)
安装流程:
- 上传 APK → 保存到
data/apks/→ pyaxmlparser 解析包名/版本 → 入库 - 批量安装 → 后台线程 → 每台设备直连 adb install → 验证包名
- 跳过 worker 运行中的设备(避免打断任务)
3.8 元素抓取(core/uiauto_helper.py)
封装 uiautodev 本地服务(端口 20242)的客户端。
| 函数 | 说明 |
|---|---|
is_running() |
探测 uiauto2 服务是否运行 |
list_devices() |
获取 uiauto2 已连接的设备列表 |
get_screenshot(serial) |
获取设备截图(JPEG) |
get_elements(serial) |
获取设备 UI 元素树(扁平化列表) |
元素树解析:递归提取每个节点的 resource-id/text/content-desc/class/bounds 等属性,并推荐最佳选择器(优先 xpath)。
4. 任务系统设计
4.1 注册机制
tasks/__init__.py
├── from .base import BaseTask, register_task, list_task_types, get_task_class
├── from .douyin import task # 触发 @register_task
└── from .generic import task # 触发 @register_task
@register_task 装饰器将 Task 类注册到全局 _TASK_TYPES 字典,key 为 task_type 字符串。
4.2 参数深合并
Job 下发时只传需要覆盖的字段,调度器做三层合并:
- 顶层字段:Job params 覆盖 DEFAULT_PARAMS
- actions 字段:参数级深合并
- 前端没传的 action → 用默认
- 前端传了 →
enabled和params分别合并 params再深合并一层(保留前端没传的子参数)
示例:只想改点赞概率,Job params 只需:
{"actions": {"like": {"params": {"rate": 0.5}}}}
4.3 Action 系统
每个 App 有独立的 Action 注册表(create_action_registry()),互不污染。
core/actions/base.py — BaseAction 全局基类 + should_trigger + register_action
tasks/douyin/actions/base.py — ACTIONS = create_action_registry() + list/get 函数
tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
循环导入坑:actions/__init__.py 必须先 from .base import ACTIONS,再 from . import like。
5. 前端设计
5.1 单页应用
templates/admin/monitor.html 是纯 HTML+CSS+JS 单页应用,无框架依赖。
- Tab 切换:5 个 Tab(监控/任务/分组/日志/用户),纯 DOM 操作
- 数据获取:
fetch()调 JSON API,5 秒轮询/api/status - 状态渲染:设备表格、任务卡片、进度条、徽章,纯 DOM 操作
5.2 步骤编辑器
generic_steps 任务的步骤编辑器:
- 左侧操作库(拖拽源)
- 中间画布(步骤卡片列表,HTML5 Drag API 排序)
- 每个步骤卡片可展开参数表单
- 选择器字段旁有"抓取元素"按钮(独立模态框)
5.3 元素抓取模态框
独立的第二层模态框(el-picker-overlay,z-index 1100),不影响任务编辑窗口:
- 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
6. 关键设计决策
6.1 为什么用 SQLite 而不是 JSON 文件
- 支持用户/分组/任务的关系存储
- 并发安全(WAL 模式)
- 迁移旧 JSON 时归档为
.migrated,避免删空后重启又复原
6.2 为什么用 threading 而不是 asyncio
- uiautomator2 是同步阻塞库,不适合 asyncio
- 多设备并发用多线程即可,每台设备一个 Worker 线程
- Flask
threaded=True处理并发 HTTP 请求
6.3 为什么绝不 kill-server
adb kill-server 会断开所有设备的 adb transport,导致 STF provider 误判全部设备离线并触发重连。连接失败就返回 False,由调用方处理。
6.4 为什么状态查询带缓存
STF API 响应慢(设备多时 2-5 秒),每次 /api/status 都打 STF 会阻塞 Flask。带 5 秒缓存,Worker 状态实时读内存(无 IO)。
6.5 为什么 DeviceOfflineError 不重试
设备掉线后短时间内不会自愈,重试只会浪费配额并阻塞调度器。让设备进入冷却,由运维/STF 恢复后再启用。
7. 线程模型
主线程(Flask)
├── HTTP 请求处理(threaded=True,每请求一线程)
├── APScheduler 线程(cron 触发)
├── 看门狗线程(_Watchdog,30s 间隔)
├── uiautodev 子进程
└── Worker 线程(每台设备一个)
├── _run_with_retry 线程(重试循环)
└── BaseWorker 线程(设备生命周期 + run_task)
线程安全:
_WORKERS_LOCK:保护全局 worker 状态字典_ADB_LOCK:串行化所有 adb 调用TaskManager._lock:保护运行中任务字典_ForegroundScanner._cache_lock:保护前台 App 缓存