Files
auto_control/doc/ARCHITECTURE.md
T

17 KiB
Raw Blame History

架构详解

本文面向想深入理解 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/ 不 import tasks/,任务通过注册机制接入
  • 配置最小化: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记录)
└──────────────┘

任务执行流程

  1. 前端创建 TaskJob(HTTP POST /api/jobs)
  2. TaskManager 保存到 SQLite,如启用 cron 则注册到 APScheduler
  3. 手动执行或 cron 触发时,_run_job 解析目标设备列表
  4. 每台设备起一个线程 _run_with_retry,含重试循环
  5. 线程内 task.create_worker() 创建 Worker,worker.start() 启动
  6. BaseWorker.run() 执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放
  7. Worker 通过 _update_status() 实时上报状态到全局 _WORKERS 字典
  8. 前端轮询 /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 connect
  • release():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 无响应永久 hang
  • d.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 密码;is_admin 管理员 + perms 业务权限位)
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)
  • 版本化 schema 迁移(SCHEMA_MIGRATIONS):结构变更必须追加迁移条目,create_all 只建新表不加列

权限模型

  • User.perms 存业务权限位 JSON 数组(tasks/devices/apks/logs),is_admin=true 拥有全部权限(has_perm 短路)
  • 后端统一用 @perm_required(PERM_X) / @admin_required 装饰器拦截(web_server.py),无权限返回 403; 查看类 GET 接口只要求登录;用户管理、维护接口(STF 重启 /api/stf/restart、adb 终端 /api/adb/cmd)强制 admin_required
  • adb 终端安全红线:拒绝 kill-server / disconnect(STF provider 共享 adb transport,断开会误判全设备离线)
  • 前端 loadMe() 拉取 /api/me,用 data-perm 属性隐藏无权限的 tab/按钮,行内按钮用 _can(perm) 判断
  • 安全兜底:前端隐藏只是 UX,权限强制在后端;新增路由时按"写操作必须带权限装饰器"的约定

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)

安装流程:

  1. 上传 APK → 保存到 data/apks/ → pyaxmlparser 解析包名/版本 → 入库
  2. 批量安装 → 后台线程 → 每台设备直连 adb install → 验证包名
  3. 跳过 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)。

3.9 屏幕 OCR(core/ocr.py)

条件判断的 ocr 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 d.click 一致)。

  • 跨平台(Windows/Linux/macOS),依赖 rapidocr_onnxruntime;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless
  • 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
  • 返回坐标约定:像素、原点左上

3.10 STF 设备管理(core/stf_device_mgmt.py)

工具页"STF 设备管理":维护 220 上 OpenSTF 设备池。

背景:220 的 adb 跑在 Docker 容器(STF_ADB_CONTAINER,默认 adb)里,设备池由 /mnt/data/openstf/connect_devices.sh(STF_SCRIPT_PATH)的 DEVICES 数组维护,cron 每 5 分钟补连。

函数 说明
status() 脚本配置 IP + 220 adb 实际连接状态
add_device(ip) 用 220 的 python3 精确改脚本 DEVICES 块 + docker exec adb adb connect
remove_device(ip) 脚本删条目 + adb disconnect

关键点:

  • 脚本编辑用 python3 行级增删(sed 处理多行数组易误伤)
  • adb connect 到不可达 IP 会挂 40s+,220 侧用 timeout 15 兜底,超时保留在脚本由 cron 重试
  • 断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立

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 下发时只传需要覆盖的字段,调度器做三层合并:

  1. 顶层字段:Job params 覆盖 DEFAULT_PARAMS
  2. 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 任务的步骤编辑器(任务弹窗已加宽到 1000px):

  • 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组)
  • 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组)
  • 每个步骤卡片可展开参数表单
  • 选择器字段旁有"抓取元素"按钮(独立模态框)
  • 容器步骤(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤
  • 条件判断(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(core/ocr.py,截屏匹配图片/画布文字,命中可自动点击)
  • 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组

5.3 页内子分栏

任务/工具 Tab 用通用 showSubTab(tabId, name) 实现页内子分栏:每个子分栏一个 .sub-panel, _activeSubs 记住各 Tab 上次选中的子分栏。工具 Tab 集中了全部管理工具(剪贴板注入/adb 终端/ Tailscale/应用管理/应用版本/已装应用/STF 设备管理),维护 Tab 仅保留 STF 服务。

5.4 元素抓取模态框

独立的第二层模态框(el-picker-overlay,z-index 1100),不影响任务编辑窗口:

  1. 选择设备 → 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 缓存