PLAN_pause_resume.md 16 KB

方案:任务执行的「暂停 / 继续」— 待评审 · 未实施

目标:运行中的任务能暂停(脚本停手、不推进),之后继续(从暂停处往下跑), 而不是只能「停止」(不可恢复)。 状态:调研 + 方案,代码一行未改。 待你拍板后再实施。


0. 结论先说

问题 回答
现在能暂停吗? 不能。 当前只有「停止」(一次性、不可恢复,写 stop.flag → 引擎抛 StopSignal 退出)
AutoX.js 能做到吗? 能做到,但没有原生 API。 设备端实测:引擎只有 forceStop(),没有 pause/resume;engines 也没有"暂停某个引擎"的入口。必须自己做协作式暂停
能用 Thread.suspend()/resume() 吗? 不能。 方法确实存在(见下表),但会挂在任意指令处、不释放锁,且本项目的 UI 与脚本跑在同一套 looper 线程上(MainThreadProxy),挂起 = 界面卡死
推荐路径 协作式暂停:复用现有的"唯一等待出口 wait.tick()",加一个 control.json 通道。暂停点天然覆盖全流程,业务代码零改动 —— 与 stop 完全同构,只是可逆

实测证据(Pixel 5a / AutoX org.autojs.autoxjs.v7,2026-10-13 反射探测)

ScriptEngine 方法(匹配 pause|resume|suspend|interrupt|stop):forceStop/0     ← 只有它
ScriptEngine 类链:LoopBasedJavaScriptEngine ← RhinoJavaScriptEngine ← JavaScriptEngine ← ScriptEngine$AbstractScriptEngine
engines 方法:execScript, execScriptFile, execAutoFile, myEngine, all, stopAll, stopAllAndToast  ← 无暂停入口
engines.myEngine().pause / resume / suspend / isPaused → 全部 undefined
threads.currentThread() 类:com.stardust.autojs.core.looper.MainThreadProxy
Thread 方法:interrupt, interrupted, isAlive, isInterrupted, resume, stop, suspend   ← 底层 Thread 方法在,但 suspend/resume 不可用(见 §2)

一句话:AutoX 给的是"杀",不是"停"。暂停只能由脚本自己在安全点实现 —— 这也正是本项目的架构已经准备好的事。


1. 现状:为什么「停止」能做得那么干净(暂停照抄它就行)

tiktok-auto 有一条明确的设计主张(src/wait.js:1):

脚本里不允许出现裸 sleep 当作"等待";tick = 轮询节拍,sleep = 盲等(禁止用于等待)。

于是全流程几十处等待全部经过唯一出口 wait.tick()(src/wait.js:33):

function tick(ms) {
    if (TikTokAuto.bridge) { TikTokAuto.bridge.checkStop(); }   // ← 停止检查就插在这一处
    sleep(ms === undefined ? DEFAULT_TICK_MS : ms);
}

bridge.checkStop()(src/bridge.js:263)每 500ms 节流读一次 stop.flag, 内容等于本次 runId 就抛 StopSignal → 由 main 统一兜住 → 上报终态。 因为只有一个出口,所以只在那一处插检查,就获得了全流程可中断,业务代码一行没改。

暂停只需要在这同一个出口再加一个"闸门":命中暂停就停在 tick 里循环等待,恢复后原样继续往下跑。 这是本方案的全部核心。


2. 三条技术路线对比(为什么只有第一条能用)

路线 怎么做 可行性 结论
A. 协作式暂停(推荐) 引擎在 wait.tick() 里检查"暂停闸门",命中就阻塞在 tick 内循环等待恢复 ✅ 与 stop 同构,改动小、可测 采用
B. 挂起线程 thread.suspend() / Process.sendSignal(SIGSTOP) ❌ 三个致命问题:① 挂在任意指令处(可能停在一次 click/swipe 中间,恢复后页面处于半操作状态);② suspend 不释放锁 → 死锁风险;③ 本项目的 UI 与脚本共享 MainThreadProxy,挂起即界面无响应;④ SIGSTOP 冻结整个进程,连状态上报文件也不写,界面会以为引擎死了 否决
C. 依赖运行时自带 pause 找 ScriptEngine.pause() ❌ 实测不存在(见 §0 证据) 不可用

顺带说明为什么不给引擎加 threads.start 的"暂停标志线程": 子线程只能改标志位,没法让正在等待的主流程停下来 —— 停下来的动作必须发生在主流程自己的代码路径上, 也就是 wait.tick()。所以还是要走 A。


3. 方案(推荐:路线 A)

3.1 新增一个"电平触发"的控制文件

现有 stop.flag 是边沿触发(写一次 = 请求停止,用内容 = runId 避免误杀); 暂停/继续是电平触发("当前是不是暂停态"),语义不同,所以单独一个文件更清楚:

文件 方向 内容 触发方式
/sdcard/tiktok_app/control.json (新增) 界面 → 引擎 {"runId":"run-xxx","paused":true/false} 电平(读当前状态)
stop.flag(不动) 界面 → 引擎 本次 runId 边沿(请求停止)

为什么要 json 而不是又一个 flag 文件:暂停需要一个明确的"继续"信号。 用文件存在/不存在表达时,"继续"就得删除文件(删除有竞态,且 files.remove 命中 MCP 危险调用黑名单 —— 这条坑在 stop.flag 上已经踩过,src/bridge.js:23 有记录)。 paused: false 是幂等的、不怕上一轮残留,也不会误杀本轮(runId 比对)。

3.2 引擎侧改动(4 处,都在既有结构上)

文件 位置 改什么
tiktok-auto/src/bridge.js parseArgs(:102 附近) 新增 --control 参数(默认 /sdcard/tiktok_app/control.json)
同 checkStop 旁(:263) 新增 checkPause():与 checkStop 共用同一个 500ms 节流窗口(一次 tick 只读一个文件,不增加 IO);命中暂停 → report({status:'paused'}) → 进入等待循环
tiktok-auto/src/wait.js tick(:33) 在 checkStop() 之后加一行 checkPause() —— 唯一的插入点
tiktok-auto/src/flow.js runSession(:263)上方 新增 relocate(config):恢复后重放"回到结果页"那几步(ensureFeed → openSearchPage → submitKeyword → waitForCards → selectResultTab,都是现成且幂等的函数)

暂停循环的关键约束(三条,缺一条就出问题):

// bridge.checkPause() 内(示意,未实施)
report({ status: 'paused', phase: '已暂停' });
var since = Date.now();
for (;;) {
    sleep(200);                    // 暂停期间极低开销
    checkStop();                   // ★ 必须继续检查停止:否则暂停中按「停止」毫无反应,用户以为卡死
    if (!stillPaused()) { break; } // 读到 paused:false(且 runId 一致)→ 继续
}
report({ status: 'running', phase: '' });
  1. 暂停循环里必须继续响应停止(否则"暂停中停不下来");
  2. 恢复时要核对 runId(防止读到上一轮残留的 paused:false);
  3. 暂停只发生在 tick 上,所以生效延迟 = 当前这次动作的剩余时间(通常 <1s;最坏是一次触摸/等待超时)。

3.3 界面侧改动(5 处)

文件 位置 改什么
tiktok-app/src/session.js 字段区 / elapsedMs(:110) 新增 paused / pausedMs / pauseStartedAt;elapsedMs() 扣除暂停时长(否则"耗时"里混进等待时间,D1/D3 的数字失真)
同 barText(:91) 暂停态文案:{任务} · {脚本} · 已暂停
tiktok-app/src/main.js 运行条渲染(:242 段内) 暂停态色调用现成的 warn(琥珀);App.tickTimer 的 setTone 同步
同 renderRunArea(:392) 详情页 CTA 那一行加次级按钮「暂停 / 继续」(主 CTA 仍是「停止运行」)
同 pollEngine(:562) 认 status === 'paused':置 Session.paused,不计为终态(Engine.isTerminalStatus 不动)
同 requestRun(校验链) 暂停态视为仍占用:点别的任务的「运行」照旧弹 F3(Dialog.switchTask 文案改为「{X} 已暂停,开始 {Y} 将停止它」)

按钮放哪(这一点需要你拍板):规格书 §2.4 明确写了 「运行条 = 状态与导航;页面底部 CTA = 启停控制。运行条上不放停止按钮」。 所以暂停按钮不建议塞进运行条,两种放法:

选项 交互 代价
① 放详情页 CTA 行(推荐) CTA 行变成「暂停」次级按钮 + 「停止运行」主按钮;运行条只表达状态(琥珀 + 「已暂停」) 需要切回该任务的详情页才能暂停(运行条点一下就到,路径很短)
② 放运行条右侧 运行条右侧加一个小按钮 破坏规格书 §2.4 的分工,且运行条整条可点=跳任务页,会有点击冲突

3.4 恢复时必须重新定位(这条最容易被漏掉,也是暂停功能的真正难点)

暂停期间脚本停手了,但手机和 TikTok 不会停:视频继续播、可能自动滑到下一条、 可能弹"青少年模式/网络异常"弹窗、可能被系统回收,也可能息屏。

所以恢复不能假设"还在暂停时那一页"。方案:

  1. 恢复时置 needsRelocate = true;
  2. 在 per-video 循环顶部(flow.js:302)检测该标志 → 调 relocate(config) 重放定位;
  3. 暂停打断的那一次视频处理,记为 skipped(不是失败),并在日志里如实写一行 已跳过:暂停期间页面漂移;
  4. 结果统计里 skipped 单列,attemptedVideos 不虚增。

这样做而不是"原地续":原地续需要保存"我在第几个卡片的第几秒",而暂停期间播放进度已变, 续上去必然对不齐 → 会变成一个"看起来在续、其实数据不对"的假功能。 重放定位 + 跳过被打断的那一个,语义诚实、实现简单、零归位成本。

备选:安全点暂停(配置项 pauseMode: "safePoint") 只在"视频与视频之间"的循环顶部接受暂停 —— 恢复时状态天然干净、无需归位, 代价是暂停最长要等当前视频处理完(一次 30–60s),用户会以为没生效。 默认建议 immediate(立刻暂停 + 重放定位),把 safePoint 留给"不能让抖音状态漂移"的保守场景。

3.5 计时与记录的语义(规格书一致性问题)

项 处理
运行条计时 暂停期间停表:elapsedMs() 扣除 pausedMs(暂停中显示"已暂停"而不是继续跳秒)
运行记录耗时 同上(Records.timeLine 的"耗时"因此是有效执行时长)
D3 执行日志 借用已有的日志通道(bridge.js:155 appendLog)写两行真实事件:已暂停 / 已继续(暂停 1 分 12 秒)
结果分类 不新增分类:暂停不是终态。若暂停中卡密到期/权限被关/被停止,按现有的「手动停止 / 报错中断」归类
暂停超时 新增 pauseMaxMs(默认 10 分钟,可配):超时自动按「手动停止」结束本次运行并写记录,理由见 §4.3 —— 不能让它无限占着"全局单跑"的位

4. 风险与边界(逐条给应对)

# 风险 应对
1 暂停 ≠ 手机停:TikTok 里的视频还在播,用户可能以为"全停了" 暂停时 toast + 运行条文案明确「已暂停(TikTok 仍在播放)」;文案必须写清,否则是误导
2 恢复后页面漂移(见 §3.4) 重放定位 + 跳过被打断的一次;日志如实记录
3 息屏/锁屏:暂停久了系统息屏,无障碍手势与触摸可能受限、后台可能被回收(项目里目前没有任何 keepScreenOn 调用,实测确认) ① 暂停时调用保持屏幕常亮(device.keepScreenOn());② pauseMaxMs 到点自动停止
4 引擎被系统杀掉 界面已有兜底(main.js:562 段:「引擎消失且无终态 → failed」),暂停态同样生效 ✅ 无需改
5 暂停中卡密到期 tickTimer 仍在跑 → 走现有 F4 路径(停引擎 + 弹窗 + 回登录)✅ 无需改
6 暂停中按「停止」 暂停循环内继续 checkStop()(§3.2 约束 1)→ 正常两步停止
7 「继续」信号丢失(界面崩了/被卸载) pauseMaxMs 兜底 + 引擎在暂停循环里也读 stop.flag;最坏情况是超时自动停止
8 全局单跑被"暂停"长期占用 暂停态视同占用:别的任务点「运行」→ F3 确认(文案说明它已暂停)
9 暂停点仍可能"半动作"(如刚点完一次 swipe 还没验证) 恢复后第一步就是 relocate() 校验页面状态,不满足就归位;不信任暂停前的页面假设

5. 明确不做的部分(避免方案膨胀)

  • 不做"暂停后继续"的跨进程持久化(杀进程后恢复上轮运行)—— 那需要把"我在第几个视频"落盘,风险远大于收益;
  • 不做 Thread.suspend / SIGSTOP 式冻结(§2 已否);
  • 不做用户可见的"暂停队列"(暂停中允许起新任务)—— 全局单跑不破;
  • 不动 stop.flag 的既有语义与两步停止流程(已验证过的路径不重做);
  • 不改规格书 §2.4 的运行条分工(除非你选 §3.3 的选项②)。

6. 测试计划(实施后按此验收)

6.1 离线(桩测,tools/smoke.js / 桩时钟)

  • 暂停 → 恢复:业务代码零改动也能继续跑完(用假 sleep + 注入时钟驱动 tick)
  • 暂停循环里收到 stop → 能中断、且上报终态是 stopped(不是 paused)
  • control.json 里 runId 不匹配 → 不暂停(防上一轮残留)
  • paused:false 幂等:连续写 10 次不会出问题
  • elapsedMs() 扣除暂停时长(构造 60s 暂停,断言耗时少了 60s)
  • 运行条文案/色调:running(青)→ paused(琥珀)→ finished(隐藏)
  • Records 落盘里出现 已暂停 / 已继续 两行日志,且耗时是有效时长
  • pauseMaxMs 到点 → 自动按「手动停止」写记录

6.2 真机矩阵(Pixel 5a,逐条截图核对)

  1. 起跑 → 暂停(确认 TikTok 不再有新动作)→ 30s 后继续 → 跑完(记录里两条日志 + 耗时已扣除)
  2. 暂停中息屏 2 分钟 → 点亮 → 继续(检查屏幕常亮是否生效、恢复是否成功)
  3. 暂停中切到别的 App → 继续(验证重放定位)
  4. 暂停中手动在 TikTok 里滑走/返回再继续(故意制造漂移 → 应跳过并继续,不报错)
  5. 暂停中点「停止」→ 两步停止正常、记录归「手动停止」
  6. 暂停中在别的任务点「运行」→ 弹 F3(文案含"已暂停")
  7. 暂停中卡密到期 → F4 + 回登录页
  8. 暂停期间杀掉引擎进程 → 界面走"引擎已退出"兜底,不留假运行态

7. 分期实施建议(每期都能独立验收)

期 内容 涉及文件 验收
第 1 期(最小可用) control.json 协议 + --control 参数 + checkPause + tick 插桩 + 详情页暂停按钮 + 停表 + 运行条琥珀态 + 日志两行 + 离线断言 bridge.js wait.js session.js main.js engine.js + smoke.js 离线全绿;真机矩阵 1、5、6 通过
第 2 期(抗漂移) relocate() 重放定位 + skipped 统计 + pauseMaxMs 超时自动停止 + 屏幕常亮 flow.js config.js + 离线断言 真机矩阵 2、3、4、7、8 通过

改动量预估:第 1 期约 5 个源文件 + 1 个测试文件,核心逻辑 <100 行(其中一半是注释); 第 2 期约 3 个源文件。不需要动规格书、不需要动 UI 骨架、不需要动既有 stop 链路。


8. 如果不做暂停:可用的替代(现在就能用)

  • 停止 + 断点续跑:tiktok_stats.js 那套 batchUrls + skipExisting 的思路, 任务级"跑一段、停、下次跳过已完成的"—— 语义上不如暂停,但对"跑一半要走开"的场景够用;
  • 把批量拆小:视频功能的"停止个数"区间调小(1 ~ 5 已支持),单次运行短、好停。

这两条都不需要新代码,先写在这里是为了让"要不要做暂停"这个决定有参照。