# tiktok-app — 飞舟脚本 · 界面层 按 Ardot 设计稿(file `734917825641912`)实现的 TikTok 自动化运营 App 界面, **AutoX.js UI 模式**,纯代码建 View,含完整的设备数据获取、配置持久化与运行态。 与同目录下的 `tiktok-auto/` 是两件事、互相解耦: | | `tiktok-auto/` | `tiktok-app/`(本工程) | |---|---|---| | 职责 | 驱动 TikTok 干活(搜索→观看→评论) | 显示界面、收集配置、落盘运行配置 | | 运行方式 | PC 侧经 MCP 下发,跑完即退 | 在 AutoX 里打开,常驻界面 | | 交付物 | `dist/tiktok-auto.js` | `dist/tiktok-app.js` | 两者通过 `run-config.json` 解耦:界面只负责**收集 + 落盘**, 真正点 TikTok 的动作由 `tiktok-auto` 读取该文件后执行。谁的实现换了都不影响对方。 --- ## 1. 快速开始 前置:手机装 AutoX(`org.autojs.autoxjs.v7`)并开启无障碍服务。 > **它现在是整个项目的启动入口**:`project.json` 的 `"main"` → 根目录 `main.js`, > 而 `main.js` 只做一件事 —— `require('./tiktok-app/dist/tiktok-app.js')`。 > 所以在 AutoX 里点「运行」、或打包 APK 后启动,进来的就是这个界面。 > ★ `"ui";` 写在**入口文件 `main.js` 的第一行**;被 require 的模块里那句是无效的 > (见项目根 `README.md` 的说明)。 ```bash # 构建 + 离线自检 + 取值审计 node tiktok-app/tools/build.js node tiktok-app/tools/smoke.js node tiktok-app/tools/audit-values.js # 推到手机 adb push tiktok-app/dist/tiktok-app.js /sdcard/Download/tiktok-app.js # 直接拉起运行(也可在 AutoX 里手动打开该文件) adb shell "am start -n org.autojs.autoxjs.v7/org.autojs.autojs.external.open.RunIntentActivity \ -a android.intent.action.VIEW -d file:///sdcard/Download/tiktok-app.js -t text/javascript" ``` > ⚠️ 这是**常驻界面脚本**,不要用 MCP 下发(`command:run` 有 120s 上限, > 界面不会 `__END__`,必然超时)。它应该由人在 AutoX 里打开。 > 上面的 `adb push` 是**开发期单文件调试**用的。走项目入口时不需要单独推 —— > AutoX 同步项目后,`tiktok-app/dist/tiktok-app.js` 会随项目一起同步到设备上。 --- ## 2. 目录结构 ``` tiktok-app/ ├── src/ │ ├── theme.js # 设计令牌(颜色/尺寸)+ 绘制工具(渐变/描边/光晕/dp/argb) │ ├── store.js # 数据模型 + 持久化 + 校验 + 迁移(字段声明表) │ ├── records.js # 运行记录:不定长列表,走同一份 storages 的另一个 key │ ├── device.js # 设备数据获取(权限/TikTok 版本/屏幕/文件选择) │ ├── service.js # 卡密校验、检查更新(远端优先 + 本地降级) │ ├── session.js # 运行会话(跑没跑 / 跑哪个 / 跑了多久)——纯内存态 │ ├── widgets.js # 通用组件(卡片/设置行/单选框/勾选框/下拉/胶囊/页签/运行状态条…) │ ├── dialog.js # 自绘弹窗体系(输入 / 选择 / 确认 / 权限 / 拦截) │ ├── floatball.js # 全局悬浮球(H1–H7):跨 App 的控制入口与状态可视处 │ ├── sources.js # ID提取的取材清单(不定长列表,独立 storage key) │ ├── page-id.js # ID提取页(三段式:取材 / 过滤 / 输出) │ ├── page-dm.js # UID私信页 + 养号页 │ ├── page-video.js # 视频功能页 │ ├── page-tasks.js # 任务列表(B1/B2,含四张任务卡与任务状态) │ ├── page-records.js # 运行记录(D1/D2 列表与空态 + D3 运行详情) │ ├── page-login.js # 卡密登录页(未激活时的整屏) │ ├── page-mine.js # 我的页(卡密/应用/版本) │ └── main.js # 入口:骨架、路由(tab + 二级页)、运行态、启动流程 ├── tools/ │ ├── build.js # src/ → dist/ 单文件(含三道检查) │ ├── android-stub.js # 最小 Android/AutoX 环境模拟 │ ├── smoke.js # 产物离线自检(387 项断言) │ └── audit-values.js # 界面取值审计(六个场景) └── dist/tiktok-app.js # ⚠️ 构建产物,不要直接编辑 ``` **为什么要有 `tools/build.js`**:人维护时希望「一个文件只做一件事」(`src/` 拆 18 个模块), 但 AutoX 拿到的必须是**一整段脚本**,带不了一堆 `require`。 中间加一步 concat,两边都拿到自己最舒服的形态。 --- ## 3. 界面结构(2026-10-13 按实施规格书 + 新稿重构) > **本次改版的本质是"入口层级变了"**:底栏从 5 个页签收敛成 **3 个 tab** > (任务 / 记录 / 我的),原来的 ID提取 / UID私信 / 养号 / 视频功能 > **降级成「任务」tab 里的四张任务卡**,各自的页面成为**二级页**(带返回头)。 > 依据是《实施规格书_飞舟脚本_2026-10-13》§1/§2 —— 规格书写明 > 「有冲突时以本文档为准」,所以画布只作视觉参考。 **两层路由**(`App.TAB_PAGES` / `App.DETAIL_PAGES`,都落盘): ``` 登录页(A1/A2/A3,无底栏) └─ 主界面 一级 tab:任务(B1/B2) · 记录(D1/D2) · 我的(E1) ← 底栏 3 个图标 二级页 :任务详情 C1/C6/C8/C9 · 运行详情 D3 ← 返回头 + 可滚动 ``` | 编号 | 屏 | 设计稿节点 | 实现 | 状态 | |---|---|---|---|---| | A1/A2/A3 | 卡密登录(含校验中 / 失败) | `16:257` / `16:1491` / `16:288` | `Pages.login` 三态 | ✅ 真机 | | A4 | 权限引导弹窗 | `30:738` | `Dialog.showPermissions` | ✅ 真机 | | **B1/B2** | **任务列表(含运行态)** | `30:102` / `30:203` | `Pages.tasks` + `W.taskCard` | ✅ 真机 | | C1 | ID提取(取材清单) | `3:1` | `Pages.id` + `W.backHeader` | ✅ 真机 | | C2/C3 | ~~ID提取 · 脚本二三 / 脚本四~~ | `16:483` / `16:589` | **v8 已合并进 C1**(不再有脚本卡) | — | | C4/C5 | ID提取 · 过滤与输出两段 | `16:706` / `16:965` | 同上(v8 起独立成段) | ✅ 真机 | | C1r/F5 | 运行中 / 脚本报错 | `16:138` / `30:1119` | 全局运行条 + `Dialog.scriptError` | ✅ 真机(运行条)/ PC 自检(F5) | | C6/C7 | UID私信(含脚本一展开) | `3:534` / `7:11` | `Pages.dm` | ✅ 真机 | | C8 | 养号 | `3:469` | `Pages.nurture` | ✅ 真机 | | C9 | 视频功能 | `3:629` | `Pages.video` | ✅ 真机 | | **D1/D2** | **运行记录(列表 / 空态)** | `30:871` / `30:958` | `Pages.records` / `recordsEmpty` | ✅ 真机 | | **D3** | **运行详情(摘要 + 执行日志)** | `30:1241` | `Pages.recordDetail` | ✅ 真机(真实日志) | | E1 | 我的(可滚动) | `7:289` | `Pages.mine` + 底部渐隐 | ✅ 真机 | | E2 | 解绑确认 | `16:850` | `Dialog.confirmUnbind` | ✅ 真机 | | F1 | 未选脚本拦截 | `16:23` | `Dialog.runBlocked` | ✅ 真机 | | F2 | 权限拦截 | `16:319` | `Dialog.permissionBlocked` | ✅ 真机 | | **F3** | **切换任务确认(全局单跑)** | `30:1005` | `Dialog.switchTask` | ✅ PC 自检 | | F4 | 运行中卡密到期 | `16:1360` | `Dialog.licenseExpired` | ✅ PC 自检 | | **H1/H2** | **悬浮球(折叠 / 展开)** | `34:*` | `src/floatball.js` | ✅ 真机(几何逐像素核对) | | **H3/H4** | **扇形四按钮 + 标签** | `34:*` | `FB.openFan` | ✅ 真机(四个按钮逐个点过) | | **H5/H6** | **停止 / 退出二次确认** | `34:*` | `Dialog.stopScript` / `Dialog.exitApp` | ✅ 真机(弹窗文字已 dump 核对) | | H7 | 隐藏悬浮球 → 从「我的」恢复 | — | `FB.hide` / `FB.setHidden` | ✅ 真机(隐藏 + 恢复都验过) | ### 本次新增的四块东西 | 新增 | 说明 | |---|---| | **任务列表(B1/B2)** | 四张任务卡:图标框 40 · 任务名 + 状态徽标(空闲/运行中/未配置)· 当前脚本 · **上次运行(读真实记录)** · 「运行」按钮 68×44。**点卡片主体进详情、点按钮直接起跑**,两个命中区 | | **全局运行条(§2.4)** | 顶部 x=20 y=70 w=335 h=44,**仅运行中出现**;`● {任务} · {脚本} · 阶段 + 等宽计时`;点整条跳回**运行中的那个任务**;失败时整条玫瑰红「执行出错已停止 / 已终止」 | | **运行记录(D1/D2/D3)** | 每次运行结束写一条(任务/脚本/结果分类/时间/耗时/结果文案/结果文件/**真实日志**)。D1 有条数说明与结果标签,D2 是空态,D3 = 摘要卡 + 日志卡(INFO 灰 / WARN 琥珀 / ERROR 玫瑰,带「复制」) | | **日志回传通道** | 引擎侧 `logger.setSink` → `files.append(run-log.jsonl)`,界面 `Engine.readLog()` 逐行读回(坏行只丢自己)。**以前 D3 那种"执行日志"根本没有数据源** | | **全局悬浮球(H1–H7,`src/floatball.js`)** | 浮在所有应用之上,跨 App **唯一的控制入口与运行状态可视处**。见下节 | ### 全局悬浮球(规格书 §10) 存在的理由很具体:**引擎必须待在 TikTok 前台**(见 §6),所以 App 内那条"全局运行条"在真正干活时 是看不见的。球浮在最上层,于是"现在跑没跑 / 点一下就能停"这两件事在任何界面都成立。 | 事项 | 取值 / 做法 | |---|---| | 尺寸 | 球 Ø48(视觉)/ 热区 Ø64(触控)· 扇形半径 96 · 子按钮 Ø48 · 标签高 24 | | 夹角 | `[60, 20, -20, -60]`(以球心为原点向右展开;球在右侧时整体**镜像**、标签移到按钮左侧) | | 四个按钮 | 运行·停止 / 回到 App / 隐藏悬浮球 / 退出应用 | | 三态环 | 空闲**无环** · 运行中青绿 `#13DDC4`@22% + 呼吸 · 报错玫瑰 `#FF5470`@20%。球内**恒定**是品牌图标(避免"球里是播放键 → 以为点了就运行") | | 手势 | 单击展开/收起 · 位移 > 8px 算拖动 · 松手吸附最近边缘并记忆(存 `ball.side` + `ball.yRatio`) | | 与引擎的通道 | `FB.publishRect` → `/sdcard/tiktok_app/ball-rect.json`;引擎侧 `ui.js` 的 `ballRect()`(500ms 缓存)+ `shiftOffBall()` 有 4 个内缩角可躲,躲不开就返回 `blocked-by-floatball` 而**不乱点** | #### ★ 窗口层:**两个** overlay 窗口,尺寸与位置恒定(2026-10-15 重做) | 窗口 | 尺寸 | 触摸 | 说明 | |---|---|---|---| | 球窗口 | 88×88dp | **永远可触摸** | 环 + 球 + 透明热区;只在用户拖动时移动 | | 扇形窗口 | 308×264dp | 折叠时 **NOT_TOUCHABLE**,展开时可触摸 | 4 个按钮 + 4 个标签 | **为什么不能是"一个窗口、展开时改尺寸"**(这条是本模块最贵的教训): 改窗口尺寸/位置由系统(WindowManager,另一个进程)**立刻生效**,而视图重排要等 App 下一帧重绘; 中间那几帧,合成器显示的是**上一帧的旧画面被摆到新的窗口矩形里** —— 用户看到的就是"点击后先跳一下"。 用录屏(`screenrecord` + `ffmpeg -vsync 0` 抽帧 + 逐帧量球心)确认过:展开那一刻球会从下往上"爬"几级才停。 两次补救都**在真机上失败了**,别再试: 1. 同一个 JS 调用里"先藏球、再改尺寸" —— 藏只是标记,改尺寸已经发出去了,照样跳; 2. "藏起来 → 等这一帧画完(`onPreDraw`)→ 再改尺寸" —— 合成器有 2~3 帧缓冲队列, 它照样能把"藏之前"那一帧摆到新位置上去。 ⇒ 唯一干净的办法是**根本不改窗口几何**:两个窗口各自建好就永不动窝,展开/收起只改 **显隐 + 一个 flag**。附带好处:折叠态的"死区"从 176×176px 缩到球窗口那么大, 引擎要绕开的范围也小了(折叠时只发布球窗口)。 #### ⚠️ 其他实测出来的约束(踩完才知道) 1. 走**裸 WindowManager**(`context.getSystemService("window")` + `addView` / `updateViewLayout` / `removeView`),**不用** AutoX 的 `floaty.window`: · `floaty.rawWindow(代码建的 View)` 直接不可用(缺 `toXMLString()`); · `floaty` 会在内容外再套一圈 **12dp 内边距**(去不掉),几何全靠反推; · **`floaty` 同时只能有一个窗口真正绘制**(第二个建得出来但画不出来)—— 而这正是本方案的地基; · `floaty` 默认还带一个调整手柄小点。裸窗口没有这些问题。 两个裸窗口能同时绘制已实测(`_probe/fb_two_windows.js`:红块与绿块都出现)。 那圈 12dp 内边距在本实现里用视图自己的 `setPadding` 复现,所以几何算法一个字没改。 2. **覆盖窗口必须带 `FLAG_LAYOUT_IN_SCREEN`** —— 否则窗口会被状态栏内缩, 整套坐标整体下移一个状态栏高度(实测球比锚点低了 48px,正好是状态栏高; AutoX 的 floaty 带这个 flag,所以以前没暴露)。 3. **窗口的透明区同样会吃掉触摸**(实测:点它透明区里的输入框、键盘不弹;点窗口外就弹)。 所以折叠时扇形窗口必须设成 `FLAG_NOT_TOUCHABLE`,让触摸穿透到下面的 App —— 否则那 616×528px 就是一片死区。 4. **同类型的两个窗口按加入顺序叠放**:球窗口必须**后加**(在最上层)。 反过来的话,扇形展开时球的矩形整个落在扇形窗口里,点球会被扇形窗口吃掉 → **再点一次收不起来**(真机实测踩到过)。 5. 覆盖窗口必须 `FLAG_NOT_FOCUSABLE`(§10.9④),否则会抢 TikTok 的键盘焦点。 6. `WindowManager.LayoutParams` 的 x/y/w/h 都是**像素**,dp 换算只在 `dp()` 里做。 7. **UI 线程禁止 `sleep()`** —— 本模块所有时序一律 `setTimeout` / `setInterval`。 8. 图片只能 `images.read(path).bitmap` + `setImageBitmap(bmp)`; XML 的 `src="…"` 走的是 drawable 查找,**不吃文件路径**(会 InflateException)。 9. **不做过渡动画**(用户 2026-10-14 要求去掉):规格书 §10.1 写了"展开动画 180ms", 这里是有意识的偏离 —— 那 180ms 淡入让"展开"在点击后近 0.3 秒才看清。 将来若要恢复动画:在 `setFanVisible(true)` 之后做透明度渐变,且**透明度的写者必须只有这一处**, 否则会出现"两个动画互相顶掉"的鬼影(不报错、只有肉眼看得见)。 10. **锚点夹取要按"扇形也能完整显示"来算**:两个窗口的位置都由锚点推导, 分别夹会让它们错位。代价是球不能被拖到离上下边缘 120dp 以内(扇形半高)—— 否则扇形窗口会出屏、下面两个按钮点不到。横向不用管:球本来就吸附在左右边缘上。 **排障开关**(默认关闭,不产生任何 IO):`adb shell touch /sdcard/tiktok_app/ball-debug` 打开, 删掉即关。打开后落三个文件: | 文件 | 内容 | |---|---| | `ball-touch.log` | 球的每次触摸(action/坐标/展开态/锚点)、每次扇形按钮点击(带标签)、`confirmThenAct` 的三步 | | `ball-layout.txt` | 当前**真实布局**:窗口矩形、根视图、每个子视图的位置尺寸与文字 | | `ball-error.txt` | 展开 / 定位 / 按钮动作抛出的异常(UI 回调里的异常不写下来就彻底消失了) | `ball-layout.txt` 是有来历的:球的坐标算错过两次(dp/px 混用一次、根视图中心漏算内边距一次), 两次都只是"看起来有点歪",靠肉眼量截图量不准。有了它,下一次"位置不对"一眼就能看出是 窗口、根视图还是某个子视图算错了。现在**每次就地刷新按钮后也会再 dump 一次** (否则快照永远停在"展开那一刻",事后回看会以为按钮一直是「运行」)。 ### 设计令牌(规格书 §6 的新取值) 底色 `#030014`、底栏 `#0A0A17@96%`、弹窗底 `#17142B`、主色 `#2EA7FF → #9381FF`、 成功/运行中 `#13DDC4`、警告 `#F5C24B`、危险 `#FF5470`、未激活页签 `#8E8BA8`(旧 `#7A7794` 对比度 4.4:1 不达 AA)。 尺寸:底栏 83 = 页签行 49 + 安全区 33、返回头 44(热区 44×44)、主 CTA 48、 卡片圆角 16/14、**区间输入框单框 56 且数字居中**、文件路径右对齐、底部渐隐 80。 ### 与规格书的差异(§9 待确认项逐条落地) | 规格书 §9 | 本项目的处理 | |---|---| | ① C6 与 C7 的「功能设置」字段集不同 | 取**并集**:AI 性别 + 进行关注/关注数量 + 进行私信/私信数量 + 休息时间 + 去重方式。两条都成立,不丢功能 | | ③ 触控目标建议 44 | 新组件(任务卡运行按钮 68×44、返回热区 44×44)按 44 做;**既有设置行/胶囊保持 26 的紧凑视觉**(规格书自己也建议"视觉紧凑 + 透明 padding 撑热区") | | ④ E1 / 二级页内容余量小 → 可滚动 | 已可滚动,并加了**底部渐隐**(透明→底色,内容空时不可见) | | ⑤ 未激活页签对比度 | 已改 `#8E8BA8` ✅ | | ⑥ B1「上次运行/结果量」的数据来源 | 用本项目的**运行记录**(`src/records.js`);没有记录显示「还未运行过」 | | ⑦ 多脚本并行不支持 | 保持**全局单跑**:运行中点别的任务的「运行」不禁用按钮,而是弹 F3「停止并开始」 | | ⑧ D3 日志可能很长需虚拟滚动 | **未做虚拟滚动**:改为只带最近 200 行(记录要落盘,不能让日志把它撑爆)。已在 D3 用等宽字体 + 紧凑行距缓解 | | — 右侧滚动指示条(`x=367 w=3`) | **未实现**:它需要滚动监听,收益小、风险高(AutoX 里构造 `OnScrollChangeListener` 的兼容性没验过);底部渐隐已经把"还能滚"表达出来了 | | — §7 技术栈表 | 运行形态 = **AutoX.js `ui` 模块**(纯代码建 View);卡密/权限/存储/结果文件/更新检查全部沿用本项目既有实现(本地卡密、`storages`、`files`) | ### ID提取页:三段式(v8) > **v8(2026-10-17)把四张脚本卡换掉了。** 那四张卡是「起点(人从哪来)」×「深度(要不要再展开 > 一层)」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,合起来才完整。于是 > 「同一时间仅可运行一个脚本」这条约束本身就在跟用户较劲 —— 想同时抓"关键词视频的作者" > 和"文本里这批 uid 的粉丝"时,它直接不允许。定位过程与 38 个字段的逐条处置见 > `REQ_id_extract_v2.md`。 > > **v8 之后执行引擎已接入**(`task: "id"`):点「开始运行」会真的起因材与过滤去 TikTok 里抓 ID, > 产物落在 `/sdcard/Download/tiktok_app/uid_日期_时分.txt`。引擎侧的实现与实测锚点见 > `tiktok-auto/README.md` 第 3.5 节。 页面就是三段: | 段 | 内容 | |---|---| | ① **取材** | 可累加的清单(最多 5 条):每条 = 起点 + 该起点需要的输入 + 深度 | | ② **只要这样的账号** | 所有取材共用:账号特征(私密/无头像/语言)+ 四组数值门槛 | | ③ **输出** | 提取上限 + 本地/跨轮/云端去重 + 结果文件名 | **五个起点**(选了什么才出现什么,不会再有"勾上却无所指"的开关): | 起点 | 要填什么 | 取到谁 | |---|---|---| | 指定账号 | 账号(`#` 分隔)+ 取哪些列表(粉丝 / 关注 / 主页展示,**至少留一个**) | 这些列表里的人 | | 搜索结果 | 用户 / 视频作者 + 关键词 | 搜到的人 / 视频作者 | | 某个视频的评论区 | 视频从哪来(当前屏幕 / 关键词搜索) | 评论者 | | 文本文件 | 路径 + 选择文件 | 每行一个 UID | | 当前屏幕 | 无 | 此刻这个列表里的人 | **深度**:不展开 / 再取粉丝 / 再取关注,**最多 2 层**。 100 个粉丝 × 每人 100 个粉丝 = 1 万次用户页打开,真机不现实、账号也会异常 —— 所以第 3 层 干脆不给选项。「当前屏幕」没有深度:它已经是最底层。 实现要点(都写在 `page-id.js` / `sources.js` 的注释里): - **取材存在 `Sources` 模块自己的 storage key 里**(`feizhou_app` 的 `sources`), **不进 `Store.FIELDS`**:它是不定长列表,塞进那张标量表等于让 `validate()` 去校验数组, 还会把「路径 → 值控件」那套审计规则带歪。照 `Records` 的先例。 - **`W.chooser`**:不绑 Store 路径的通用单选胶囊 —— 取材的"起点 / 深度"只能走回调。 - **`W.inlineField` 新增 `read` / `write`**:用来绑"不是 Store 字段"的值(账号 / 关键词 / 路径)。 这类输入框注册到 `W.pendingExtra` 而**不是** `pendingFields` —— 后者同时被取值审计当作"这个字段有控件"的证据,混进去会报"绑定了表外路径"。 - **`Sources.migrateLegacy()`** 把旧的"四张卡选一张"翻译成清单,三条原则: ① **已有清单就不动**(不覆盖用户自己加的,重装/降级都安全); ② **能展开成多条就展开**(脚本四的三个开关 → 三条独立取材,旧结构塞不下的能力); ③ 超出上限要截断并**说出来**。 - **【踩坑】模块级函数重名会静默互相覆盖**:`build.js` 是单文件拼接,所有模块共享 一个顶层作用域。v8 加 `sources.js` 时踩到了 —— 里面的 `keysOf` 与 `store.js` 同名, 把 `getEnum` 打坏了(卡密状态读不出 → 所有运行都被送回登录页,**没有任何报错**)。 新增模块前先查一遍: ```bash for f in src/*.js; do grep -o '^function [a-zA-Z_]*' "$f"; done | sort | uniq -d ``` ### 切屏与运行态 - **`App.screen`** 只有两个值:`login` / `app`。卡密未激活时启动直接进登录页—— 不让用户先进一个什么都点不动的主界面。 - **`Session`** 是运行态的**唯一**持有者(跑没跑 / 跑哪个 / 起于何时), **纯内存、不落盘**。写进配置会有两个后果:上次没点停止就杀进程,下次启动会显示成 「运行中」;计时起点被迫落盘,重启后算出来的时长是错的。 - 计时用 `setInterval`(回调跑在主线程 Looper 上,可以安全改 View), **不重建界面**——每秒重建整页会让滚动位置归零。 - 计时回调顺带做**卡密到期巡检**:到期那一刻停掉运行并弹窗(设计稿 18), 不必等用户下一次操作才发现。 ### 两处刻意的偏离(都写在代码注释里) 1. **状态栏不照抄稿子的 62dp**,改用系统真实状态栏高度——稿子里那 62dp 含模拟状态栏, 真机上系统会自己画一条,照抄会双份。 2. **区间里的两个数做成两个独立胶囊**:稿子上「关注数量 100 ~ 200」是一个整体文本, 这里渲染成 `[100] ~ [200]` 两个可点击控件,点哪个改哪个。 这是对稿子的**增强**而非偏离:稿子那样只能整体展示,改一个值要重填一对。 ### 复合项 vs 区间:判据是「两个数是不是各自成立」 这一条区分很容易被含糊过去,所以单独写出来: | 形态 | 判据 | 做法 | 例子 | |---|---|---|---| | **复合项** | 两个数**各自独立成立**,拆开也说得通 | **拆成两行**,各自一个输入控件 | `进行关注` / `进行私信`(一个开关管两件事) | | **区间** | 两个数是**同一个量的下界与上界**,拆开就变成两个不相干的东西 | **一行一个区间**:`标签 [最小值] ~ [最大值]`,两个数**各自一个胶囊、各自弹窗输入** | `关注数量 100 ~ 200`;`休息时间(秒)100 ~ 500`;`停止个数 1 ~ 5` | 区间用 `W.rangeRow(pathMin, pathMax, label, separator)` 渲染, 两个胶囊互不干扰(点最小值不会顺带改最大值),`~` 只是把「这是一对数」表达出来。 > 注意区间**不等于**「两个值挤在一个控件里」:`[100] ~ [500]` 是两个独立控件, > 而 `100 ~ 500` 一个胶囊才是被塞在一起(本项目已经不存在这种写法)。 上下限联动(`syncCrossRules`)在区间里**只刷新这两个胶囊的文字,不重建整页**—— 走整页重建会把滚动位置清零,用户刚滚到设置区就被弹回顶部。 `Store.CROSS_RULES` 里每一对都对应界面上一个 `W.rangeRow`; 自检里有一条断言专门核对这个对应关系(漏一条就会出现「最小值 500 / 最大值 100」)。 --- ## 4. 数据获取(都是真实数据,没有写死的假值) 全部收敛在 `src/device.js` 与 `src/service.js`,页面只负责展示。 | 界面上的元素 | 来源 | 实现位置 | |---|---|---| | 副标题「· 已连接 / 未连接」 | `auto.service !== null` | `Device.a11yEnabled()` | | 权限弹窗三行状态 | 无障碍服务 / 悬浮窗权限 / 通知权限(可选项) | `Device.permissionRows()` | | 「已安装 v47.1.4」 | `PackageManager.getPackageInfo` 多包名探测 | `Device.tiktok()` | | 卡密状态 / 到期时间 | `Service.licenseStatus()` + 过期自动改写状态 | `service.js` | | 脚本版本 | 构建时注入(`--version` / 默认日期) | `Service.SCRIPT_VERSION` | | 「检查更新」 | 远端优先,无端点时本地自比并如实标注来源 | `Service.checkUpdate()` | | 「选择文件」 | 扫指定目录的 txt/json/csv,弹列表选择 | `Device.listTextFiles()` | | 状态栏 / 导航栏高度 | 系统 `dimen` 资源,取值失败有兜底 | `Device.statusBarHeight()` | **关键约定:所有探测都必须能在能力缺失时返回兜底值,绝不抛出去。** `device.js` 里每个探测都包了 `probe()`——AutoX 没有 linter 也没有崩溃隔离, 一次未捕获异常就让整个界面起不来。 ### 权限:谁阻断、谁可选 设计稿 02 与 12 两个弹窗的顺序与措辞必须完全一致,所以数据只在 `Device.permissionRows()` 里声明一次: ``` 无障碍服务 已开启 / 未开启 ← 阻断项 悬浮窗权限 已开启 / 未开启 ← 阻断项 通知权限 已开启 / 未开启(可选) ← 不阻断,排最后 ``` - **「可选」写进标签本身**,而不是靠颜色让用户猜。早先它既没标可选、又排在悬浮窗前面, 用户会以为必须开。 - **`blockingPermissionsGranted()` 与 `allPermissionsGranted()` 是两个函数**: 前者只管无障碍 + 悬浮窗(决定「能不能跑」),后者含通知(决定「进 App 时要不要提示」)。 用后者去拦人,会让用户被一个不影响执行的权限挡在门外。 ### 卡密的诚实边界 `service.js` 默认是**本地模式**:只校验格式(16 位字母数字), **不具防伪能力**,界面上会如实提示「本地模式,未联网核验」。 要真防伪,把 `Service.CONFIG.licenseEndpoint` 指向自己的接口: ``` 请求 POST {endpoint} {"code":"ABCD1234EFGH5678"} 响应 {"ok":true,"expireAt":"2026-11-09"} {"ok":false,"reason":"卡密不存在"} ``` --- ## 5. 数据保存 **结论先说:界面上每一个可输入 / 可选择的控件,改动都会立刻落盘,启动时自动读回。** 不是「大致都存了」——这条有自动化覆盖(第 9 节场景 F):渲染所有页面、把每个控件 真操作一遍、断言「配置跟着变」,再模拟重启(丢掉内存 state、重新从 `storages` 读) 断言「界面显示的还是新值」。字段表 79 个字段里 78 个有对应控件, 唯一没有控件的是 `activeTab`(底部页签栏的当前页,由点页签写入)。 | 控件 | 存什么 | 时机 | |---|---|---| | 输入框(文本 / 数字 / 区间两端 / 文件路径) | 该字段的值 | 输入即落盘;退格期间不写、失焦一次提交 | | 勾选框(脚本卡、过滤账户、云端去重、AI 识别…) | 布尔 | 点一下即落盘 | | 开关(进行关注 / 进行私信 / 执行操作四选) | 布尔,各自独立 | 点一下即落盘 | | 下拉框(AI 性别、去重方式、概率档位…) | 枚举 key | 选中即落盘 | | 脚本卡的选中 / 展开态 | 布尔 | 点一下即落盘 | | 底部页签 | `activeTab` | 切页签即落盘 → **重开就停在上次的页** | | 登录页卡密框(**还没点激活**的半截输入) | `license.draftCode` | 输入即落盘 → 被系统回收进程也不用重打 16 位 | | 卡密激活状态 / 到期时间 / 卡密本体 | `license.*` | 激活成功时写入 | 刻意**不落盘**的只有三类,都是「重启后必须从头开始」的东西: - **登录页的三态**(idle / 校验中 / 失败)与失败文案 —— 重启回到 idle 才对 - **运行态**(跑没跑 / 跑多久,`src/session.js`)—— 落盘会导致「没点停止就杀进程, 下次启动显示运行中」+ 计时起点错误 - **弹窗的打开状态、检查更新的结果** —— 瞬态 其余机制: - **存储**:`storages.create("feizhou_app")` 的 `config` 键,值是 JSON 字符串 - **唯一写入口**:`Store.set(path, value)`——改字段即落盘,且**落盘前按字段表归一化** (夹越界 / 回退非法枚举 / 纠正类型),所以盘上永远不会出现越界值 - **启动读取**:`App.main()` → `Store.init()` → `Store.load()` → `validate()`, 一次读全量;页面只从内存里的 `Store.current()` 取值 - **字段表(默认值的唯一来源)**:`Store.FIELDS` 把每个字段声明一次 (路径 / 类型 / 范围 / 默认值),`defaults()` 与 `validate()` 都由它推导 - **界面取值必须走类型安全接口**,它们保证拿到的值一定是合法类型, 非法时回退默认并回写配置: | 接口 | 保证 | |---|---| | `Store.getEnum(path, group)` | 返回合法枚举 key——**选择框不会空** | | `Store.getNumber(path)` | 返回有限数——**不会显示 NaN** | | `Store.getText(path)` | 返回字符串(非字符串回退默认值) | | `Store.getBool(path)` | 返回布尔 | - **校验**:`Store.validate()` 逐字段归一化——越界数字夹到区间、非法枚举回退默认、 非字符串回退默认(不做 `String()` 转换)、`min > max` 自动抬齐, 并丢弃字段表之外的键;「修正了什么」记进 `problems` 打印出来 - **`optional: true`**:声明「这个字段的空串是合理状态」(例如云端去重的表名)。 取值审计靠它把「有意留空」和「漏了默认值」区分开 - **版本迁移**:`Store.MIGRATIONS` 按版本逐级升级。新增默认值只能影响**没有**该字段 的配置;老存档里存着 `keyword: ""` 时,空串是合法字符串、类型归一化补不上—— 这种「空串其实等于未设置」的语义差异只有迁移能处理。 **改语义**时同理:v4→v5 把五处「一个值」变成「一个区间」,也只能靠迁移 - **枚举映射**:界面显示中文、存储存稳定英文 key(`{"id":"ID 模式"}`),改文案不作废老数据 配置结构(节选,`SCHEMA_VERSION = 5`): ```js { version: 5, activeTab: "id", license: { code, draftCode, status, expireAt }, tabs: { id: { scripts: { s1: { checked: false, expanded: true, dedupe: "id", enterPage: "fans", useTextId, textIdPath }, s2, s3, s4 }, features: { filterPrivate, filterNoAvatar, regionCodes: "JP#TW#US", langRegion, nicknameCheck, cloudDedupe, cloudTable, aiAvatar, aiGender, aiAgeMax, followMin, followMax, fansMin, fansMax, likesMin, likesMax, videosMin, videosMax, extractLimit } }, dm: { scripts: { s1: { checked, expanded, doDm, filterDate, firstAction, uidTextPath }, s2 }, aiGender, doFollow, followMin, followMax, doDm, dmMin, dmMax, restMin, restMax, dedupeMode, dmTextPath, commentTextPath }, nurture: { scripts: { s1: { checked, expanded } }, browseMin, browseMax, likeRate, commentRate, followRate, actionMin, actionMax }, video: { scripts: { s1 }, keyword, region, stopMin, stopMax, intervalMin, intervalMax, actions: { follow, dm, like, comment }, dmTextPath, commentTextPath } } } ``` ### v4 → v5:五处「一个值」改成「一个区间」 按设计稿,关注数量 / 私信数量 / 休息时间 / 浏览时长 / 单次操作 / 停止个数 / 间隔时间 都成了 `最小值 ~ 最大值` 的区间。迁移原则是**不改变原有行为**,分三类: | 旧字段 | 类型 | 迁移方式 | 例 | |---|---|---|---| | `followLimit` / `dmLimit` / `actionLimit` | 上限 | `min = 0`(不设下界)、`max = 旧值` | 关注上限 300 → `0 ~ 300` | | `stopCount` | 精确个数 | `min = max = 旧值`(与稿子「20 ~ 20」同表达) | 停止个数 7 → `7 ~ 7` | | `browseDuration`(档位枚举) | 档位 | 取该档的上下界 | 档位 `5 ~ 10` → `5 ~ 10` | 旧字段非数字时不迁移,直接用设计稿给的默认区间(例如 关注数量 `100 ~ 200`)。 新装用户拿到的也都是设计稿默认值。 **运行配置快照出口**:点「开始运行」会写 `/sdcard/tiktok_app/run-config.json` (含 `tasks` + 完整界面配置 + 本次生效的引擎配置 + 不会生效的项)。 它是**给人看、给事后追溯用**的;引擎真正读的是下面第 6 节里的 `engine-config.json`。 ### 运行目标 = 当前页签选中的那一个脚本 `App.currentTask()` **只认当前页签**,不把 5 个页签里勾上的都收上来。 设计稿写得很明确——「单选:同一时间仅可运行一个脚本」。 跨页收集会出现「ID提取页什么都没选,按钮却能把养号脚本跑起来」, 用户根本不知道自己到底在跑什么。 它还会带上该脚本的 **`engine` 标记**(来自 `Pages.SCRIPTS_OF` 的脚本定义)。 没有这个标记的脚本,点「开始运行」会被 `Dialog.engineMissing` 拦下 —— 见第 6 节最后。 --- ## 6. 执行引擎桥接(点「开始运行」之后发生了什么) ### 一次运行的时间线 ``` 点「开始运行」 ├─ 定位引擎产物(从入口脚本同级推 → 扫项目目录 → 回退 Download) ├─ 界面配置 → 引擎配置(engine-config.json) ├─ 更新 run-config.json(含本次 runId) ├─ engines.execScriptFile(产物, ["--config",…,"--run-id",…]) ← 起另一个 ScriptEngine ├─ Session.start(...) + 状态条 + 每秒轮询 │ │ 引擎侧:读 --config → 跑流程 → 每步写 run-state.json │ 界面侧:每秒读 run-state.json → 状态条显示 phase │ └─ 终态(finished / stopped / failed)→ 复位 + 切回前台 + toast ``` ### 两侧怎么说话:四个文件 界面与引擎是**两个独立的 ScriptEngine**:各有各的全局作用域,对象传不过去 (实测:父脚本读不到子引擎里的任何东西)。所以通道只能是文件。 | 文件(都在 `/sdcard/tiktok_app/`) | 方向 | 内容 | |---|---|---| | `engine-config.json` | 界面 → 引擎 | 映射后的引擎配置(由 `--config` 交给 `config.resolve`) | | `run-state.json` | 引擎 → 界面 | `status` / `phase` / `done` / `total` / `message` / `summary` | | `stop.flag` | 界面 → 引擎 | 停止请求,内容是**本次运行的 runId** | | `run-config.json` | 界面 → 人 | 完整快照,供追溯 | 全是纯文本 JSON,出问题 `cat` 一下就能复盘。 ### 停止:两步,且以引擎的回报为准 点「停止运行」不是"一按就当停了": 1. 界面写 `stop.flag`(内容 = runId),状态条立刻变 **「正在收尾」**(琥珀色); 2. 引擎在**唯一的等待原语** `wait.tick()` 里轮询到它 → 抛 `StopSignal` → 退出当前等待 → 收尾 → 写 `status: "stopped"`; 3. 界面读到终态 → 复位 + 提示。实测**从点击到复位约 1 秒**。 为什么不做成点一下就复位:引擎收到请求后还要退出当前等待、把 TikTok 带回信息流, 它确实还在动。这时界面说「已停止」,用户看到 TikTok 仍在操作,只能怀疑"到底停没停"。 两个细节: - **停止检查放在 `wait.tick()` 里**,那是全流程唯一的等待出口(等待应用启动、等待播放、 等待结果卡片……几十处都经过它)。于是"能被打断"这件事**只实现了一次**, 业务代码一行都没改。`bridge` 未启用时是空操作,独立运行零开销。 - **`stop.flag` 里存 runId 而不是空文件**:于是不必删除它(删除既有竞态, `files.remove` 又命中 MCP 危险调用黑名单),上一轮的残留标志也不会误杀本轮。 - 界面还有**超时兜底**(8 秒):引擎不确认也不能一直卡在"正在收尾"上。 ### 引擎侧的能力边界,如实告知 引擎目前实现的是「搜索 → 逐个看 → 评论」。`Engine.buildConfig()` 会把 "用户表达了意图、但引擎这次做不到"的事逐条记进 `notes`: - **「执行操作」的关注 / 私信 / 点赞** → 各记一条「引擎尚未实现」, 启动时 toast 汇总一句(`点赞动作/关注动作/私信动作 本次不会生效`), 完整内容写进 `run-config.json` 的 `engine.notes`; - **没勾「评论」** → 映射为 `comments.enabled = false`,引擎**只浏览不发评论**。 这是给引擎新加的能力:以前 `comments.pool` 必须非空,等价于"必然发评论"。 用户的意图优先于引擎的默认行为。 `notes` 的汇总只取每条**中文冒号前的短名** —— 三条完整说明拼起来四十多个字, 那种长度的 toast 用户来不及读。 ### 界面没有对应引擎的脚本:明确拦下 `Pages.SCRIPTS_OF` 里的脚本可以声明 `engine`。没声明的(ID提取 / UID私信 / 养号) 点「开始运行」会弹 `Dialog.engineMissing`,说清是哪个脚本、目前哪一页能用。 **绝不静默跑一个不相关的东西**——用户以为在跑 ID 提取,实际在跑视频评论,是最糟的一类错误。 这条也是自检里的一条断言:被拦下时**不能起任何引擎**。 ### 引擎没写终态就没了:兜底判定 引擎被系统杀掉、被人手工停掉时不会写终态,界面就会永远显示"运行中"。 所以有一个兜底:`Session.running` 且 `Engine.childAlive()` 为假 → 判为「引擎已退出」。 `childAlive()` 靠 `engines.all()`。但它在不同运行时里的可见性不完全一致, 所以加了一道保险 `Engine.sawChild`:**只有确实见过子引擎,才有资格后来说"它不见了"**。 从没见过就当作"查不出来",宁可多显示一会儿运行中,也不误判成已结束把运行态清掉。 ### 运行中请勿切走(TikTok 必须留在前台) 引擎是靠"看到 TikTok 在前台"来定位页面的。用户切回界面看一眼进度是很自然的动作, 但它会让 TikTok 退到后台,引擎立刻"瞎掉"(页面状态全是 unknown),甚至误判成"启动应用失败"。 这不是引擎的 bug,也不该靠文档里写"请勿切走"(没人会记得)。所以: - **运行状态条整条可点击 = 一键切回 TikTok**(`App.backToTarget()`); - 界面回到前台且引擎在跑时,`ui.emitter.on("resume")` 提醒一次 (**本次运行只提醒一次**,来回切窗口不会反复弹)。 > 这条是**真机联调时被自己的操作绊出来的**:我为了在界面上点「停止」而把界面切回前台, > 结果那一次运行报「搜索流程失败」。查日志才明白是 TikTok 被挤到后台了。 ### 用一个假引擎验证界面交互 `tools/fake-engine.js`:读同一套 `--config/--state/--stop-flag/--run-id` 协议, 只写状态、循环等停止信号,**完全不碰 TikTok**。 为什么需要它:用真引擎验证"状态条/停止"这些 UI 交互时,它会把 TikTok 拉到前台, 我就没法同时操作界面上的按钮了。换成假引擎,界面始终留在前台,UI 交互能一次测干净。 用法:把设备上的引擎产物临时替换掉(记得备份),验完换回来。 --- ## 7. 输入与弹窗 ### 输入:全部**直接在栏位里改**(不弹窗) 关键词、国家简码、云表名、文件路径、以及所有数字(含区间的两端)都是页面上的 **常驻输入框**(`W.inlineField`)。点一下就能改,没有「弹出输入框 → 填 → 确定」这一步。 | 组件 | 用途 | |---|---| | `W.inlineField({ path, ... })` | 绑定配置路径的常驻输入框:读初值 / 输入即落盘 / 失焦正规化 | | `W.textField(path, hint, w)` | 文本(关键词、主播区域、国家简码、云表名、文件路径) | | `W.numberField(path, hint, w, onLinked)` | 数字(AI 年龄、提取符合、区间两端) | | `W.rangeRow(...)` | 一行两项:`标签 [最小] ~ [最大]`,两个数各自独立可改 | **四条实现要点,每条都对应一个真机上验证过的坑:** 1. **`TextWatcher` / `OnFocusChangeListener` 在这台 AutoX 上可用。** 早先绕开常驻 `EditText` 是担心「Java 接口构造不出来」—— 实测 `new android.text.TextWatcher({...})` 与 `new View.OnFocusChangeListener({...})` 都能构造,真机逐字输入每键都触发回调。**监听写在本组件内部**, 全项目只有这一个输入入口,不存在「某一处漏绑监听」的余地 (漏绑的表现是「能打字但永远不保存」,用户会报成「没有默认值」)。 2. **程序化回写必须防自触发。** 真机上 `setText` 会**同步**触发 `TextWatcher` (before → on → after 全部跑完才返回),所以 `paint()` 用一个 `writing` 标志挡住监听; 并且**用 `try/finally` 复位这个标志** —— 一旦异常把标志留在 `true`, 这个输入框就永远不会再写配置了(静默哑掉,极难查)。 3. **数字框在「从尾部退格」期间不写配置。** 把 `2000` 清成 `2` 会经过 `200`、`20` 这些**恰好合法**的中间值,用户清空后走开,配置就停在 `2` 上。 退格期间不写、失焦时一次性提交;空串则回退原值。 文本字段不受这条限制 —— 半截文本本身就是「用户正在输入的内容」。 4. **不失焦就离开,编辑也不能丢。** Android 里点一个**不可聚焦**的按钮不会让 `EditText` 失焦,所以「按开始运行」「切页签」时栏位内容可能还没落盘。 `W.commitPendingInputs()` 在这两个入口各调一次;提交之后 `W.beginPage()` 清空登记表(留成全局累积的话,切走那一页的旧文字会把别处刚改的值顶掉)。 **「配置里永远没有越界值」由写入端保证**:`Store.set()` 落盘前先按字段表归一化 (夹紧越界、回退非法枚举、纠正类型)。少了这一步,界面直接输入的 `99999` 会原样进配置, 要等下一次重启加载才被纠正 —— 用户看到的是「我填的数它照收了,可跑起来又不是那么回事」。 这条改动顺带把一处旧漏配打回来了:`activeTab` 的候选值里少了 `"mine"`, 导致「切到我的页后重启会落回 ID提取」。 ### 弹窗:只做「非输入」的事 `src/dialog.js` 是自绘弹窗,**输入类弹窗(`askText` / `askNumber`)已全部删除** —— 输入不再需要弹窗,留下的都是「选一项 / 确认一次 / 被告知一次」。 | 接口 | 用途 | 对应设计稿 | |---|---|---| | `Dialog.choose` | 单选列表(所有下拉框、选择文件) | — | | `Dialog.confirm` / `alert` | 确认 / 提示 | 区域条款说明、更新日志 | | `Dialog.showPermissions` | 权限检测(软引导) | `7:260` | | `Dialog.permissionBlocked` | 权限拦截(「无法开始运行」) | `16:436` | | `Dialog.runBlocked` | 未选脚本拦截 | `16:1065` | | `Dialog.confirmUnbind` | 解绑确认(红色确认键) | `16:951` | | `Dialog.licenseExpired` | 卡密到期 | `16:1480` | **为什么下拉框仍然用弹窗**:从一组候选里选一个,本质上需要一个选择器; 而系统 `Spinner` 的弹出列表是浅色主题,在深色界面里就是一块刺眼的白板。 (也不能用 `dialogs.select`:它在 `"ui";` 模式下**拿不到返回值** —— 阻塞即死锁。) 另外三条约定: - **弹窗同时只允许存在一个**:`present()` 会先关掉上一个。 两层弹窗叠着关不掉,是自绘 UI 最容易出的岔子。 - **「确定」不预先关窗**:是否关窗由 `onConfirm` 自己决定。 早先写成「先 `close()` 再 `onConfirm()`」,直接毁掉了输入类弹窗的关键行为。 - 拦截类与询问类**命名刻意分开**:`runBlocked` / `permissionBlocked` 的标题统一是 「无法开始运行」,调用点读起来就是「被拦了,因为……」,不用去猜 title 该写什么。 ### 上下限联动 `Store.CROSS_RULES` 声明了成对的上下限(休息时间、间隔时间、养号的两个参数、 ID提取的四个数量门槛)。每敲一个数字都会跑一遍 `syncCrossRules()`:若上界小于下界就抬齐。 ★ **只刷新「兄弟」那一格,绝不刷用户正在输入的那一格** —— 回写会抢掉刚敲进去的字符、还会把光标弹到末尾。 联动也**不重建整页**:重建会把滚动位置清零,用户刚滚到设置区就被弹回顶部。 --- ## 8. 真机验证记录 设备:Pixel 5a(1080×1920,Android 14),AutoX `org.autojs.autoxjs.v7`。 19 屏相关的界面逐屏截图核对通过(含登录页三态、两个拦截弹窗、解绑弹窗、运行态计时), 三个含区间的页(UID私信 / 养号 / 视频功能)也逐行核对过新形态, 并在真机上实际改过值(点最小值胶囊 → 输入 → 确定 → 界面文字就地刷新)。 过程中修掉的问题按「为什么检查体系没抓到」分两类: **A. 只有真机才暴露** | 现象 | 根因 | 修法 | |---|---|---| | 卡片里所有文字看不见,只剩勾选框 | 用 `U.spacer()`(宽度 `MATCH_PARENT`)做**横向**间距,把同行的标题挤成 0 宽 | 新增 `U.hgap()` 固定宽度版,横向一律用它 | | 「TikTok 客户端」标签与「更新」按钮消失 | 左侧标签用 `weight=1` 撑开,而右侧控件本身是 `MATCH_PARENT` 时,标签被压成 0 宽 | 改成「内容宽度 + `flexible()` 弹性占位」 | | 切到「我的」页直接崩溃,报 `Cannot find function setAndNotify` | 「检查更新」在 `threads.start` 起的后台线程里改 View | 默认路径改同步;只有真发 HTTP 时才走线程 | | **整排「非活动」页签消失** | 改配色时漏掉了 `Theme.hex.tabIdle`,`argb(undefined)` 算出 `0`(全透明)。`uiautomator` 里它们的 `bounds` 一切正常,**只有肉眼**看得出 | ① 补回 `tabIdle`;② `argb()` 遇到非法色值改为返回**洋红**并打日志,不再静默返回透明;③ 自检新增两条断言:所有色值合法、且界面用到的键都存在 | | 关掉「进行关注」顺带把私信也关了 | 设计稿两版的功能设置卡内容不一致,代码取并集时把两个独立动作挤进了一个开关 | 见 B 类第 2 条 | **B. 只有「有历史数据」或「跑到具体流程」才暴露** | 现象 | 根因 | 修法 | |---|---|---| | **关键词 / 主播区域 / 文件路径只显示灰色占位符** | ① v1 的默认值本身就是空串,老存档里 `""` 是合法字符串,类型归一化补不上;② `W.textField` 用常驻 EditText 却没绑 `TextWatcher` | 加版本迁移(v1→v2);输入框改弹窗取值 | | 数字框可能显示 `NaN ~ NaN` | `Number(Store.get(path, 0))` 遇非数字串得 `NaN` | 改用 `Store.getNumber()` | | **两个数值的输入框只能输一个** | 连弹两次阻塞式 `dialogs.rawInput`,第一次就拿不到返回值 | 自绘弹窗体系 | | **「关注/私信」这类复合项没法单独控制** | 两个独立动作/两个独立上限挤在一个开关、一个胶囊里 | 拆成一项一控件 + 版本迁移 | | **五个数量仍是「单一上限/单一值」,与稿子的区间不一致** | 关注上限 200 / 私信上限 30 / 单次操作上限 30 次 / 浏览时长档位 / 停止个数 5,都是「一个值」;稿子已全部改成 `最小值 ~ 最大值` | 改成区间行 + **v4→v5 迁移**(上限→`0 ~ 旧值`;精确值→`旧值 ~ 旧值`;档位→取该档上下界),保证升级后行为不变 | | **我的页显示「已登录」却挂着「去登录」** | `Service.licenseDisplay()` 没返回 `status`,页面写 `display.status === "active"` 永远为 false | 让 `licenseDisplay()` 返回 `status`;自检加断言(已登录给「解绑」、未登录给「去登录」) | | 非法输入时弹窗被关掉,只能重新点开重填 | `cancelConfirmRow` 先 `close()` 再 `onConfirm()` | 「确定」不预先关窗,由 `onConfirm` 决定 | | **输入框改成「直接在栏位里改」后,会有四种「改了不保存 / 存了怪值」** | ① 常驻输入框要靠 `TextWatcher` 才能同步回配置;② 程序化回写的 `setText` 又会**同步触发**同一个监听;③ 数字框退格清空会把 `2000` 一路写成 `2`;④ 点「不可聚焦」的按钮不会让 `EditText` 失焦,切页签时编辑随视图消失 | 四条一起做:防自触发(`try/finally` 复位 `writing`)+ 退格期间不写 + 失焦提交并正规化 + 离开前 `commitPendingInputs()` | | **「切到我的页后重启会落回 ID提取」** | `activeTab` 的候选值漏了 `"mine"`(字段表 `values` 与 `Store.enum.tab` 两处都漏)。写入端归一化上线后才把这种漏配打回来 | 两处都补 `mine`;自检加断言 `Store.set('activeTab','mine')` 后能读回 `mine` | | **清空数字框再走开,配置停在半截数字上** | 退格经过的 `200`、`20` 恰好都是合法值,逐个落盘 | 数字框退格期间不写;失焦时若为空则回退原值 | | **「激活成功后清空卡密草稿」清了等于没清** | `App.enterApp()` → `renderContent()` 会先跑 `commitPendingInputs()`,而登录页那个输入框此刻**还在待提交表里**,于是把刚清掉的草稿又写了回去(顺序反了) | 干脆**不清**草稿:解绑后回登录页可一键重新激活,而它和 `license.code` 本来就在同一个 JSON 里,多留一份不增加暴露面 | | **`edit.getText().length()` 在 PC 桩上每次必抛** | 真机上 `getText()` 返回 `android.text.Editable`(有 `length()` 方法),桩返回的是 JS 字符串(`length` 是属性不是函数)→ `paint()` 永远走 catch 分支,「回写之后光标在哪」这类问题在 PC 侧根本测不到 | 改成引擎无关的 `String(edit.getText()).length` | > **这一轮的教训**:上面 A 类里「页签消失」那条,**PC 桩测与真机首启都是绿的**—— > 因为它是"画出来了但看不见"。它提醒我:自检只能验证"结构对不对", > 颜色的**取值**必须单独断言(所有色值合法 + 用到的键都存在), > 否则一个拼错的键就等价于一次静默的视觉删除。 页签图标也踩过一次:`\u2709`(信封)在 Android 上即使追加 `\uFE0E` 文本变体选择符 仍被渲染成彩色 emoji,已全换成 `U+25xx` 几何块字符。 弹窗顶部的警示图标同理,用字符 `!` 而不是 `⚠`。 **验证「直接输入」时踩到的操作坑**(不是代码问题,但会得出错误结论): 点一下输入框,**软键盘就弹起来了**,界面被顶起、页签栏从 `y=1801` 移到 `y=1225`。 此后按原来的固定坐标点「切页签」,实际点中的是**键盘上的退格键和数字键** —— 配置里就会凭空出现一个没人输入过的数字(我就这样「造」出了一个 `9`, 一度以为是代码写错了)。 → 每次点完输入框,都要**重新 `uiautomator dump` 取当前真实坐标**, 并先确认 `dumpsys input_method | grep mInputShown` 的状态,不要复用上一轮的坐标。 ### 桥接(第 6 节)的真机联调记录 按**真实的项目目录结构**部署到 `/sdcard/Scripts/tiktok-demo/` (`main.js` + `tiktok-app/dist/` + `tiktok-auto/dist/`),从 `main.js` 启动: | 验的东西 | 结果 | |---|---| | 从入口启动即进界面 | `[TTA] 项目启动入口 main.js → 加载界面` → `[app] 界面已就绪` | | 界面唤起引擎 | `[app] 已唤起引擎 …/tiktok-auto/dist/tiktok-auto.js runId=…` | | 参数送达 | 引擎打印 `已连接到界面宿主,runId=…` | | 配置映射 | 引擎打印生效配置 `{"keyword":"fashion","maxVideos":1,"watchMs":[2000,2000],"commentEnabled":false}` | | 状态上报 | `run-state.json` 的 `phase` 随流程变化(`启动应用` → `进入信息流` → `搜索 fashion`) | | **真引擎端到端** | 启动 TikTok → 搜索 fashion → 切 Top → 打开视频 → 观看 2095ms → 返回;终态 `finished`,界面接住并提示 | | **纯浏览模式** | `comment: {ok:true, skipped:true, reason:"本次未启用评论动作"}`——没勾评论就真的没发 | | **跑完自动切回前台** | 界面自己回到前台,顶部显示「上次运行:完成:处理 1 个视频 / 发出 0 条评论」 | | **两步停止** | 状态条「正在收尾」(琥珀色)→ 约 1 秒后「已停止(假引擎)」;引擎日志 `收到停止请求(runId 匹配)` | | 未接入的脚本被拦下 | 在 UID私信页点「开始运行」→ 弹「…的执行引擎尚未接入」,引擎调用数为 0(ID提取已接入,改用它验这条闸) | | **ID提取端到端** | 界面点运行 → 引擎读到 `task=id` + 完整取材清单 → 抓 3 个 uid → 产物文件 3 行 3 唯一 → 记录页「完成:提取 3 个 ID → 文件路径」 | **联调时自己绊自己两次**,都记下来免得重演: 1. 为了在界面上点「停止运行」,我用 `am start -f 0x20000000` 把界面切回前台 —— 结果 TikTok 被挤到后台,引擎立刻"瞎掉",那一次运行报「搜索流程失败」。 这不是 bug,而是"引擎必须待在 TikTok 前台"的直接证据。后来改用**假引擎** (`tools/fake-engine.js`,不碰 TikTok)才把 UI 交互测干净,并顺手加了 「点状态条切回 TikTok」与「回到前台时提醒一次」。 2. 用假引擎验完忘了换回真引擎,第二次"真引擎验证"其实跑的还是假的(日志里 没有 `[TTA]` 前缀就能看出来)。所以换引擎前后都要核对 `md5sum`。 ### ID提取四张脚本卡的真机验证 | 验的东西 | 结果 | |---|---| | 四张卡各自展开 | 脚本一 4 项 / 脚本二 2 项 / 脚本三 4 项 / 脚本四 5 项,与稿子逐项一致 | | 脚本一的「主播 ID」 | 直接输入 `anchor001#anchor002` → 切页签来回 → **杀进程重启后仍在** | | 开关与右侧控件联动 | 关掉「主播去重」→ 右侧「ID 模式」下拉**消失**;再打开→回来 | | 脚本三的「进入页面」 | 显示「**关注页**」(脚本一是「粉丝页」,同一控件不同默认值) | | 脚本四的「关键词主播」 | 显示默认值 `fashion`,不是空框 | | **滚动指引的位置** | 「↓ 下方还有「功能设置」,向下滚动查看」在**脚本四之后、功能设置标题之前**(2026-10-13 按要求从页尾移上来) | > 这几条里最容易写错的是**最后两条**:同一类控件在不同卡片上默认值不同 > (进入页面:脚本一粉丝页 / 脚本三关注页),以及**开关关掉后要移除右侧控件**。 > 两者都已进审计(`脚本三的「进入页面」默认「关注页」` / `关掉「主播去重」后不再显示「去重方式」`)。 ### v6 改版(3 tab + 任务卡 + 运行记录)的真机验证 | 验的东西 | 结果 | |---|---| | **v5 老存档迁移** | 老配置 `activeTab="id"` → 启动直接停在 **ID提取 详情页**(返回头 + 三 tab 底栏),配置一条没丢 | | **任务列表 B1** | 四张卡(图标框/任务名/状态徽标/当前脚本/上次运行/运行按钮)✅ 截图 `28-v6-tasks` | | **未配置 / 空闲徽标** | 选了脚本的任务显示「空闲」,ID提取一个都没选时显示「未配置」✅ | | **任务卡「上次…」是真实数据** | 跑过一轮后视频功能卡显示「上次 05:26 · 完成:处理 1 个视频 / 发出 0 条评论」✅ | | **点卡片主体进详情** | 卡片 → 二级页(返回头「‹ 视频功能」+ 参数 + 底部 CTA)✅ | | **点运行按钮直接起跑** | 任务卡「运行」→ 引擎起来(run-state 变 running)→ 自动停在那个任务的详情页 ✅ | | **全局运行条** | 顶部 `● 视频功能 · 正在处理第 1/1 个视频 00:00:21`(切回界面截图 `38-v6-running-foreground`)✅ | | **记录列表 D1** | 「共 N 条 · 点任一条查看执行日志」+ 条目(任务·脚本 / 已完成 / 今天 05:26 · 耗时 27 秒 / 结果行)✅ | | **记录空态 D2** | 无记录时「还没有运行记录 / 去「任务」标签选一个脚本跑起来」✅ 截图 `29-v6-records-empty` | | **运行详情 D3 + 真实日志** | 摘要卡 + 日志卡,日志行就是引擎 `run-log.jsonl` 的内容(`[bridge] 已连接到界面宿主,runId=…` 等 28 行)✅ 截图 `35-v6-record-detail` | | **我的页 E1** | 卡密/应用/关于三段,版本显示真实构建号 `2026.10.13`、TikTok 版本读系统(`v47.1.4`)✅ | | **二次运行落第二条记录** | 两次运行 → D1 显示 2 条,各自的耗时不同 ✅ | ### ⚠️ 一个真 bug:点任务卡进不去参数设置(2026-10-13 用户反馈后修掉) **现象**:任务列表上点卡片的「运行」按钮有反应,但点卡片其它任何地方(包括脚本名所在的文本组) 都进不去参数设置页。 **根因**:`W.taskCard` 里写过一句 `line.setOnClickListener(null)`,本意是"行内不挂监听、 让点击气泡到卡片"(`card.setOnClickListener(onOpen)`)。但 **AutoX 的桥对 `null` 并不保证是 "清掉监听"** —— 它很可能装上一个**吃掉点击却什么都不做**的空监听。 于是触摸被那一层吞掉,永远到不了 card: | 点哪里 | 结果 | 为什么 | |---|---|---| | 「运行」按钮 | ✅ 起跑 | 按钮是它自己的子视图,装着真监听 | | 卡片其它地方 | ❌ 毫无反应 | 触摸被中间那层的空监听吃掉 | **这两件事实拼起来才看得出问题** —— 单看"按钮能用"会以为整条点击链路是好的。 **修法**:删掉那句 null 监听,并让 **card / 图标框 / 文本组 各挂一次同一个回调**: 谁接到都做同一件事,既不依赖气泡顺序,也不怕中间某层被点成 clickable。 `smoke.js` 新增 5 条断言逐个点这三个区域(外加守住"中间那层不许有空监听")。 > 顺便纠正上次的一个误判:这**不是** `uiautomator dump` 滞后造成的假象 —— > dump 滞后确实存在,但卡片点击当时是真的坏的。 > ⚠️ **另一个假象,记下来**:验证过程中一度以为「内容区点不动、只有底栏能点」, > 排查了半天(甚至怀疑新增的底部渐隐层挡触摸)。真相是—— > **这台设备上 `uiautomator dump` 在自绘界面上会滞后一步**,我拿 dump 当"点击后的事实", > 于是每次看到的都是点击**之前**的界面。改用 `screencap` 截图核对后一切正常 > (点「解绑」→ 弹窗确实弹出)。**结论:核对交互结果永远以截图为准,dump 只用来取坐标。** > 顺带做的对照实验证明了这一点:把 2026-10-11 的旧产物推到设备上,同样"点不动"。 ### 悬浮球的真机验证(2026-10-13,Pixel 5a) `ball-debug` 打开后逐项验,全部有可读取的证据(不是"看着像"): | 验的东西 | 证据 | |---|---| | 折叠态几何 | `ball-layout.txt`:`window 616x528 @ -16,696` / `root 568x480` / `ball center` 反算 = 锚点 ✅ | | 展开扇形 | 8 个子视图:4 按钮 + 4 标签,位置与 `R=96 / 角度 60,20,-20,-60` **逐像素吻合** ✅ | | 尺寸变化引发 CANCEL | `ball-touch.log` 里 UP 之后紧跟一条 `action=3 (0,0)` —— 被正确忽略(`open` 仍为 true)✅ | | **扇形第 1 个按钮(运行)** | 点下去 → `run-state.json` 变成新 runId、`status:"running"`、`phase:"进入信息流"` —— **引擎真的被这个按钮跑起来了** ✅ | | **扇形第 1 个按钮(停止)** | 运行中再点 → 轨迹三步 `click fan#0 primary` → `confirmThenAct bringToFront=true` → `弹窗已弹出`;`dumpsys window` 显示前台是 `org.autojs.autoxjs.v7/…ScriptExecuteActivity` ✅ | | **「回到 App」** | 先 `KEYCODE_HOME` 回桌面,再点它 → 前台焦点变回 `ScriptExecuteActivity` ✅ | | **「隐藏悬浮球」** | 点它 → `ball-rect.json` = `{"hidden":true}`,之后再点球位**一行新事件都没有**(窗口真没了)✅ | | **「退出应用」** | 点它 → `uiautomator dump` 里出现「退出飞舟脚本? / 将停止正在运行的脚本,并关闭悬浮球 / 取消 / 退出应用」✅ | | 隐藏后能恢复 | 「我的」页悬浮球开关 → `ball-rect.json` 回到可见,点击重新有事件 ✅ | | 引擎避让 | 引擎跑完一整轮搜索→打开→观看,**没有一次点到球位** ✅ | ### ★ 又一个"界面全对、点了没反应":扇形第 1 个按钮(同一天修掉) **现象**:球的展开、拖动、吸附、三态环全对,其余三个按钮都能用, **只有「运行 / 停止」点了毫无反应**。触摸事件收到了、按钮也在、`bounds` 也对。 **根因**:那个按钮的动作存在一个**可变 holder**(`FB.firstAction.fn`)里, 由 `applyFirstButton()` 在**三态发生变化时**写入。而"展开扇形"这一步通常是 **idle → idle** —— 状态根本没变,`FB.sync()` 判定 `changed === false`,于是对**新按钮实例**从不调用 `applyFirstButton()`,holder 里始终是最初那个空函数 `function () { }`。 ``` 展开 → openFan 建新按钮(holder 还是空的) → sync() → tone 没变 → applyFirstButton() 不执行 → 回调是空壳 → 点下去:事件到了、handler 跑了、什么也没发生 ``` **修法**:动作**在点击那一刻现算** —— `action: function () { primaryState().fn(); }`, holder 整个删掉。"忘记接线"这种可能就不存在了。 另外把扇形按钮的 handler 包上 `try/catch` 并写进 `ball-error.txt`:UI 回调里的异常在 AutoX 上 **不报错、不弹窗、不结束脚本**,不写下来就是彻底消失。 **这一条是被离线自检钉死的**:`tools/smoke.js` 现在有一条 「空闲态点扇形第 1 个按钮 → 真的发起运行」(点完断言 `App.requestRun` 被调用、参数是当前任务)。 写完立刻做了**反向验证**:把动作临时改回空函数 → **7 条断言立刻失败**;改回来 → 全绿。 (这正是之前漏掉它的原因:`floaty` 在那之前**根本不在离线桩里**,悬浮球整块功能零覆盖。 现在桩补上了 `floaty.window / ImageView / MotionEvent / images`,悬浮球有 40+ 条断言。) > 顺手改掉的两个小问题:① 选完按钮扇形不收起,会挡住用户此刻要看的 TikTok → 现在**选完即收**; > ② 就地刷新按钮后不再 dump 布局 → 快照永远停在"展开那一刻",事后回看会误判按钮状态 → 现在补 dump。 ### ★ 「点击后先跳一下,然后才收起/展开」(2026-10-15 重做窗口层后修掉) **现象**:点球,球先"跳"一下,之后才展开或收起。 **定位手法**(可复用):给球挂**布局轨迹**,每次真正发生布局就把「窗口尺寸 + 球当时画在哪」 记一行到 `ball-touch.log`(`ball-debug` 门控)。第一次点击就露出了因果链: `+37ms 窗口已是 616×528 但画面还是旧视图 → +49ms 系统按新窗口重排(发 ACTION_CANCEL,这是证据) → +94ms 新视图才建好`。**光看这条还不够** —— 后来用 `screenrecord` 录屏 + `ffmpeg -vsync 0` 抽帧 + 逐帧量球心,才看清球在切换瞬间**从下往上"爬"了几级才停**(旧画面被摆到新窗口位置上)。 **两次"聪明的补救"都失败了**(真机实测,别再试): | 尝试 | 结果 | |---|---| | 视图只建一次(不再每次重建 8 个视图 + 重解码品牌图,省掉 57ms) | 57ms 的窗口确实消失了,但切换瞬间**照样有 1~2 帧错位** | | 切换时"先藏球、再改尺寸" | 藏只是标记,改尺寸已经发给系统了 —— 旧画面照样被摆到新位置 | | "藏起来 → 等这一帧画完(`onPreDraw`)→ 再改尺寸" | 合成器有 **2~3 帧缓冲队列**,它照样能把"藏之前"那一帧摆到新位置 | **根因(一句话)**:改窗口尺寸/位置由系统**立刻生效**,而 App 的视图重排要等下一帧; 只要窗口几何变了,中间那几帧就必然是"旧画面 + 新窗口"。 **真正的修法:让窗口几何根本不变** —— 拆成**两个 overlay 窗口**: - 球窗口 88×88dp:环 + 球 + 透明热区,永远可触摸,只在用户拖动时移动; - 扇形窗口 308×264dp:4 按钮 + 4 标签,**折叠时整窗 `FLAG_NOT_TOUCHABLE`**(触摸自动穿透)。 展开/收起 = 改显隐 + 改一个 flag。没有任何几何变化 ⇒ 不可能有位移。 代价是要自己走**裸 WindowManager**(`addView` / `updateViewLayout` / `removeView`)而不是 AutoX 的 `floaty.window` —— 而 `floaty` 恰恰做不到"两个窗口同时绘制"(第二个建得出来但画不出来), 这也是之前一直卡在"一个窗口"上的原因。**两个裸窗口能同时绘制已实测** (`_probe/fb_two_windows.js`:红块与绿块都出现)。 **实测(修复后)**: | 验的 | 结果 | |---|---| | 连续 6 次"点击 + 立刻截图" | 球心**每次都是 1170**(= 锚点),无位移、也不消失 | | 切换时的触摸事件 | **一次 `ACTION_CANCEL` 都没有**(不再改窗口尺寸) | | 展开/收起前后的窗口几何 | 两个窗口的 x/y/w/h **完全不变**(自检里有专门的断言钉住) | | 折叠态点扇形窗口的透明区 | **触摸穿透到 App**(用滚动验证:列表真的滚了)→ 没有死区 | | 展开态点扇形第 2 个按钮 | `click fan#1 回到 App` ✓,且自动收起 | | 拖动 | 锚点平滑跟随,松手吸附右侧;扇形镜像到球的左侧 ✓ | | 「隐藏悬浮球」 | 两个窗口都摘掉(截图确认球真的没了、没有孤儿窗口),从「我的」能恢复 ✓ | > ⚠️ 顺带踩到并修掉的两个坑: > ① **覆盖窗口必须带 `FLAG_LAYOUT_IN_SCREEN`** —— 否则窗口被状态栏内缩,整套坐标整体下移 > 48px(正好一个状态栏高)。AutoX 的 floaty 带这个 flag,所以换成裸窗口后才暴露。 > ② **同类型窗口按加入顺序叠放**:球窗口必须**后加**(在最上层)。反过来的话,扇形展开时 > 球的矩形整个落在扇形窗口里,点球会被扇形窗口吃掉 → 再点一次**收不起来**(实测踩到)。 > ⚠️ **验证手法上的一个坑(很重要)**:跑动中执行 `uiautomator dump` 会把 AutoX 的**无障碍服务撞掉** > ⚠️ **验证手法上的一个坑(很重要)**:跑动中执行 `uiautomator dump` 会把 AutoX 的**无障碍服务撞掉** > —— logcat 里能看到 AutoX 进程随后**连续 7 次**发 `android.settings.ACCESSIBILITY_SETTINGS` > (子引擎卡在 `auto.waitFor()` 循环里)。这会把 App 和它的弹窗一起盖住, > 让人误判成"确认弹窗没出来"。**运行时只能看文件与 logcat,`uiautomator dump` 只在没有运行任务时用。** --- ## 9. 构建期与离线检查 `tools/build.js` **四道**检查,任一不过直接阻断构建: 1. **`"ui";` 指令必须在第一行**——被注释放到后面,脚本会以非 UI 模式执行 2. **语法检查**(`new Function`)——设备端没有 linter 3. **无未完成标记**(TODO / FIXME / debugger) 4. **项目入口检查**(`assertProjectEntry`,**写盘之前**跑)——`project.json` 的 `main` 指向的文件 必须存在、第一行是 `"ui";`、且确实加载了界面产物。这类错误在设备上的表现是 「以非 UI 模式执行 + 一堆不着边际的报错」,从日志里**看不出根因**,所以必须在 PC 侧拦住。 `tools/smoke.js` 配合 `tools/android-stub.js` 在 PC 上把产物**真实跑一遍**(387 项断言)—— v6 新增的部分:任务卡直跑 / F3 全局单跑确认(取消与「停止并开始」两条路径)/ 校验顺序 (卡密 → 权限 → 已选脚本 → 引擎 → 是否在跑)/ 运行记录落盘与上限 / F5 报错态与红色运行条 / D3 日志读取(坏行只丢自己)/ `Records` 文案纯函数 / **悬浮球 80+ 项** (显示前置三条件、几何、单击/拖动/CANCEL、四个按钮的动作、隐藏与恢复、球位发布给引擎、 **视图只建一次的回归防线**、切换后不留下看不见的球)。 > `tools/android-stub.js` 里悬浮球相关的桩是**为了消灭"零覆盖"才加的**: > `floaty.window` / `ImageView` / `MotionEvent` / `images` 以前一个都没有,于是产物把建窗包在 > `try` 里静默失败,悬浮球整块功能在离线检查里**一条断言都跑不到** —— 那个 > "界面全对、点了没反应"的 bug 就是这么溜过去的。 > 桩里另外两条纪律没变:`setInterval` **只登记不自动跑**(自动跑就是死循环,用 > `env.flushIntervals()` 推进);`MockTextView.setText` **同步触发 TextWatcher**(真机就是这个顺序)。 ``` 加载与挂载 · 模块依赖顺序正确、界面树建得出来 启动屏路由 · 卡密未激活时进登录页,且登录页没有页签栏 主题计算 · argb / alphaOf / 渐变 / 描边的数值正确 颜色令牌 · 所有色值合法、不会解析成全透明、界面用到的键都存在 数据层 · 字段表默认值类型、set→读回→落盘闭环、深合并、校验收敛、枚举可逆 服务层 · 卡密格式与分组、licenseDisplay 带 status、过期判定、更新检查降级 设备层 · 多包名探测、权限顺序与「可选」标记、阻断权限判定 界面构建 · 5 个页签 + 5 个弹窗全部可构建,且弹窗文案与设计稿逐字一致 登录流程 · 空卡密不切屏;合法卡密 → 主界面 + 状态落盘 + 三态复位 交互闭环 · 单选互斥 / 取消回到「未选」、切页签后按钮的有无、运行配置落盘 运行态闭环 · 未选脚本拦截 → 缺权限拦截 → **未接入引擎的脚本被拦下(且不起引擎)** → **ID提取已接入:点运行真的起引擎且配置里带全取材清单** → 开始运行 → 定位引擎产物 → **唤起引擎 + 参数与映射配置逐项核对** → 状态条 + 计时推进 → 引擎上报进度就地在状态条刷新 → **点停止发请求 → 「正在收尾」→ 引擎确认后才复位** → finished 终态接住 / **引擎消失且无终态的兜底判定** → 卡密失效自动停跑 + 向引擎发停止请求 + 弹窗 → 点确认回登录页 悬浮球 · 三态前置(卡密/悬浮窗权限/未隐藏)→ 建窗与幂等 → 折叠态几何与密度换算 → **球位发布给引擎** → 单击展开 / 拖动吸附记忆 / **CANCEL 不当点击** → **四个扇形按钮的动作都真的触发**(运行 / 停止确认 / 回到 App / 退出确认)→ 选完自动收起 → 隐藏落盘 + 通知引擎「球没了」→ 开关恢复后能重建 ``` ```bash $ node tools/smoke.js 通过 387 项 SMOKE_ALL_PASS ``` `tools/audit-values.js` 专门盯「**界面取值与交互**」,六个场景(**250+ 项断言**): | 场景 | 验什么 | |---|---| | A 全新配置 | 5 个页签 + 登录页渲染后,没有任何控件显示 `undefined` / `null` / `NaN` | | B **脏数据** | 灌入 v1 老存档(缺字段 + 类型错 + 枚举非法 + 越界 + 空串 + 多余字段 + **v4 的旧单值字段**),验证归一化与迁移,并**重新渲染**确认界面干净 | | C 字段表完整性 | 每个字段的默认值都与声明类型相符;除卡密与 `optional` 外文本默认值非空;默认配置通过校验且幂等;序列化往返不变样 | | D **输入与弹窗** | **直接在输入框里逐字敲**(桩里的 `type` / `backspace` 与输入法走同一条通路)→ 断言「打字即落盘」、点输入框**不弹窗**、越界值失焦后被夹紧并写回栏位、**清空是中间态不把配置冲成 0**、退格期间不落盘但**离开前整体提交不丢**、区间行两个数各自独立可改、联动只刷兄弟且不抢当前输入、非法输入不改配置、单选写回、弹窗唯一性 | | E 登录页三态 + 运行状态条 | idle / verifying / failed 三态的文案与保留的卡密;状态条可就地刷新时长;时长格式化(**超过 24 小时不回绕**) | | F **持久化覆盖** | ① **界面上绑定的每个路径都必须在字段表里**——表外路径会被下次启动的 `validate()` 丢掉,是「改了但存不住」最隐蔽的一种;② 逐个控件真操作一遍 + **模拟重启**(丢内存 state、重新从 `storages` 读)后界面显示新值:勾选框 / 开关 / 下拉 / 输入框 / 区间行 / 多选开关 / 脚本卡勾选与展开 / 文件路径 / 登录页卡密草稿;③ 落盘 JSON 是「全量」而不是子集,且带版本号 | > 场景 F 的 ① 是对着代码看不出来的那类问题:路径拼错时 `Store.set` 照样写进内存、 > 全程不报错,但下次启动按字段表重建配置就把它丢了。做法是渲染一遍所有页面、 > 记录所有读过的路径(所有取值函数最终都走 `Store.get`),再逐个对照字段表 —— > 顺带把「条件渲染」的控件也覆盖到了(把所有布尔开关都打开再扫一遍, > 否则「云端去重」揭开后才出现的表名输入框会被误判为「没有界面的字段」)。 > > 这一条同时保证了 **ID提取四张脚本卡的展开态被覆盖到**:`expanded` 本身也是 bool, > 全开一遍就等于把四张卡都展开再扫。场景 A 里另有一条断言直接检查 > 「这 25 个字段都有界面控件」—— 只要哪张卡展开后其实什么都没渲染,它就会报出来。 ```bash $ node tools/audit-values.js AUDIT_CLEAN ``` > 场景 B / D / F 是这套检查里最有价值的三条。 > 「关键词只显示占位符」只有拿老存档跑才暴露; > 「双值只能输一个」「弹窗被提前关掉」连真机手点都要点好几次才发现; > 「25 小时回绕成 01 小时」这种只在长跑时才出现。 > > 场景 B 里还专门放了一个 **v4 存档**,用来看五处旧单值是否被正确搬进新区间 > (含「旧值非数字 → 不迁移、直接用默认区间」这条分支)。 > > 另有**反向断言**守住两条底线: > ① 界面上不再出现 `200 / 30`、`100 ~ 500`、`20 ~ 20` 这类把两个值挤进一个胶囊的写法; > ② 上一版的旧标签(`关注上限` / `私信上限` / `休息间隔` / `单次操作上限`) > 不再出现 —— 改名时漏掉一处就会在这里暴露。 > > 还有一条**结构断言**:每个区间行必须真的是「标签 + 两个数字胶囊 + 一个 `~`」, > 数一数那一行里纯数字的胶囊个数即可(多一个少一个都会报)。 > 这类检查之所以必要,是因为「改了标签但忘了改控件」在肉眼看来几乎一样。 --- ## 10. 已知取舍 - **输入是「直接在栏位里改」**:所有文本与数字都是常驻输入框,点一下就能改, 没有弹窗这一步。代价是必须自己把「输入与落盘」这条链路做扎实 (防自触发、退格不写、失焦提交、离开前整体提交,见第 7 节)—— 比弹窗取值多写几十行,但省掉用户每改一个数就要「点开 → 填 → 确定」三次操作。 - **数字框的越界值在失焦时才被夹紧**:输入过程中不夹(夹了会打断用户继续敲), 失焦后回写成夹紧后的值,用户能看见「99999 被夹到 500」。 - **登录页的卡密草稿会留着**(`license.draftCode`):手打 16 位卡密时被系统回收进程, 重开还能接着填;激活成功后也不清(解绑后回登录页,上次的卡密已经填好,一键重新激活)。 不想要这个便利的话,把 `W.inlineField` 的 `path` 去掉即可(它就退回一次性取值)。 - **数量门槛是「一行一个区间」**(`关注量 [0] ~ [500000]`):两个数各自一个输入框、 各自可改,但排在同一行。这样功能设置卡短了一半,四个门槛不用滚很久才看完; 也不至于像「关注量下限 / 关注量上限」两行那样,看着像两个互不相干的设置。 - **ID提取的四张脚本卡是「可选其一、也可全不选」**:点已选中的那张可以取消。 严格单选一旦选过就再也回不到「未选择脚本」那一屏(设计稿 16:23 专门画了它), 那一屏会变成永远看不到的死代码。 - **ID提取页默认一个脚本都不选**:四个选项里替用户挑一个属于越权; 「还没选」配上「请先选择要运行的脚本」的拦截弹窗,是一条清晰的路。 其余页签只有一两个脚本,默认勾上更省事。 - **弹窗居中,键盘弹起时会被系统顶到上方**(`adjustPan`)。真机验证过不影响操作。 - **页签图标是几何字符近似**,不是稿子里的矢量图标。换 PNG 只需改 `W.TABS` 的 `icon` 字段。 - **「下载 / 更新」不做自动下载**:没有可依赖的官方下载源。 现改为扫描本机 `/sdcard/Download` 等目录里的 APK,交给系统安装器。 - **「购买卡密 · 联系客服」目前只弹说明**:没有可用的客服渠道就如实说, 不编一个不存在的联系方式。 - **检查更新默认无远端端点**,显示「已是最新」是本地自比的结论。 - **卡密本地模式不具防伪能力**,见第 4 节。 - **运行态的「执行」是解耦的**:界面只落盘 `run-config.json` + 显示状态条, 真正驱动 TikTok 的动作由 `tiktok-auto/` 负责。 - **悬浮球的「停止 / 退出」确认仍走 App 内弹窗**(不是规格书 §10.6 画的球自己的浮窗)。 ⚠️ 这条的**理由已经变了**:当初是因为"第二个 floaty 窗口画不出来",而 2026-10-15 改用裸 WindowManager 之后已证明**两个 overlay 窗口可以同时绘制**,技术障碍不再存在。 没改的原因是现在的路径(`App.bringToFront()` + App 内已验证过的弹窗)**真机验证过、稳定**, 而换成浮窗形态要重做 H5/H6 的样式与触摸,收益只是"不用跳一下 App"。 真要改的话,更稳的做法是把确认**内联进扇形**(按钮就地变成"确认停止?"),不需要第三个窗口。 - **悬浮球未做 `setAdjustEnabled(false)`**:`floaty.window` 默认带一个 AutoX 的调整手柄。 实测球的几何与手势都不受影响(手柄与球不重叠),所以没有动它 —— 这项设置在真实项目里没有验证过,改它属于"顺手改一个没验证的开关",容易引入新问题。 - **球贴边时窗口会露出屏幕外**(左右各 `slack = 12dp`,即根视图那圈透明内边距): 这是刻意的 —— 夹窗口会让球多内缩 8dp、看起来不贴边。发布给引擎的 `ball-rect.json` 用的是**窗口**矩形(比球大一圈),因为透明区同样会吃掉触摸。