梦境 0a12730ff7 ID提取执行引擎:取材 → 过滤 → 去重 → 落盘,界面点运行即可真抓 1 неделя назад
..
dist 0a12730ff7 ID提取执行引擎:取材 → 过滤 → 去重 → 落盘,界面点运行即可真抓 1 неделя назад
src 0a12730ff7 ID提取执行引擎:取材 → 过滤 → 去重 → 落盘,界面点运行即可真抓 1 неделя назад
tools 0a12730ff7 ID提取执行引擎:取材 → 过滤 → 去重 → 落盘,界面点运行即可真抓 1 неделя назад
8px 8b1e6876db 完整工程入库:界面 v6 + 悬浮球(双窗口)+ 执行引擎 + ID提取两份方案 2 недель назад
PLAN_floatball_polish.md 8b1e6876db 完整工程入库:界面 v6 + 悬浮球(双窗口)+ 执行引擎 + ID提取两份方案 2 недель назад
README.md 0a12730ff7 ID提取执行引擎:取材 → 过滤 → 去重 → 落盘,界面点运行即可真抓 1 неделя назад

README.md

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 的说明)。

# 构建 + 离线自检 + 取值审计
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 打坏了(卡密状态读不出 → 所有运行都被送回登录页,没有任何报错)。 新增模块前先查一遍:

    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):

{
  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 / 退出确认)→
              选完自动收起 → 隐藏落盘 + 通知引擎「球没了」→ 开关恢复后能重建
$ 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 个字段都有界面控件」—— 只要哪张卡展开后其实什么都没渲染,它就会报出来。

$ 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 用的是窗口矩形(比球大一圈),因为透明区同样会吃掉触摸。