# 方案:任务执行的「暂停 / 继续」— 待评审 · **未实施** > 目标:运行中的任务能**暂停**(脚本停手、不推进),之后**继续**(从暂停处往下跑), > 而不是只能「停止」(不可恢复)。 > 状态:**调研 + 方案,代码一行未改。** 待你拍板后再实施。 --- ## 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`): ```js 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`,都是现成且幂等的函数) | **暂停循环的关键约束(三条,缺一条就出问题)**: ```js // 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` 已支持),单次运行短、好停。 > 这两条都不需要新代码,先写在这里是为了让"要不要做暂停"这个决定有参照。