Преглед изворни кода

悬浮球生命周期:补兜底清理 + 「我的」页加明确的退出入口

用户报"应用后台都关闭了,悬浮球还在"。实测四种关闭方式(数 dumpsys autojs 窗口):

  点「我的」→ 退出应用     窗口 6→0  球消失  脚本自己 FB.destroy()
  连按两次返回             窗口 6→0  球消失  AutoX 在 Activity finish 时清的
  从最近任务划掉           窗口 6→0  球消失  系统回收
  按 HOME / 切到别的 App   窗口 6→3  球保留  ← 无人清理,且这是设计

结论:用户说的"关闭"其实是按 HOME/切走 —— 那是退到后台,不是关闭。
界面在后台、脚本还在跑、球继续浮在桌面(悬浮球的存在意义就是界面不可见时也能操作)。
"后台保留球"是预期行为,不是泄漏。

真正的问题是主界面里**没有退出入口**(以前只有悬浮球菜单和登录页有),
用户想彻底退出只能按返回或 HOME,而 HOME 关不掉 → 体感"关不掉"。

改动
1. 「我的」页底部新增「退出应用(停脚本 · 关悬浮球)」
   → Dialog.exitApp → FB.destroy() + App.exit()
   真机验证:窗口 0、前台回到桌面
2. 补两层兜底清理(main.js bindLifecycleCleanup):
   - events.on("exit"):脚本正常/异常退出时清一次
   - 每 1.5 秒轮询 activity.isFinishing()/isDestroyed():覆盖"Activity 关了但脚本还在"
   用 sawActivity 标志避免 PC 侧 smoke(无 activity 全局)误触发 exit()
   实测正常退出路径上这两个兜底跑不到(AutoX 抢先清了窗口并停了脚本),但能覆盖异常场景

副作用修正
  新增 App.lifecycleTick 让 smoke 的「运行计时器已启动」从 1 变 2。
  不是回归,是断言要把基础设施计时器排除:tickIntervals() 同时减掉呼吸灯与生命周期两个。

待确认(未擅改)
  "按 HOME 后球保留"是否符合预期?若要"退出界面就消失球",球就失去了跨 App 操作的作用。
梦境 пре 1 недеља
родитељ
комит
f4dd20b9b1
4 измењених фајлова са 149 додато и 3 уклоњено
  1. 72 1
      tiktok-app/dist/tiktok-app.js
  2. 55 0
      tiktok-app/src/main.js
  3. 16 0
      tiktok-app/src/page-mine.js
  4. 6 2
      tiktok-app/tools/smoke.js

+ 72 - 1
tiktok-app/dist/tiktok-app.js

@@ -1,6 +1,6 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-21T15:00:55.550Z
+ *  built 2026-10-22T07:14:22.045Z
  *  modules: theme, store, records, sources, device, service, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -8273,6 +8273,22 @@ Pages.mine = function () {
     /* 设备信息放在最后一行,排障时一眼能看到当前跑在什么机器上 */
     blocks.push(W.hint(Device.deviceLabel() + " \u00B7 " + Device.appVersionName(), false));
 
+    /* —— 退出应用 ——
+     * ★ 为什么要有这个入口:以前主界面里**没有**退出,用户想关掉只能按返回或 HOME。
+     *   而按 HOME 是"退到后台",不是关闭 —— 悬浮球会继续浮在桌面和别的 App 上面
+     *   (它本来就是为了在 TikTok 里操作才存在的)。用户看到球还在,就会认为"关不掉"。
+     *   给一个明确的出口:停脚本 → 关球 → 退到桌面,三件事一次做完。
+     *   靠 `events.on("exit")` 和 Activity 轮询也能清,但那些是兜底,不该是用户唯一的办法。 */
+    blocks.push(U.spacer(8));
+    var exitLink = W.link("\u9000\u51FA\u5E94\u7528\uFF08\u505C\u811A\u672C \u00B7 \u5173\u60AC\u6D6E\u7403\uFF09",
+        Theme.hex.textMuted, function () {
+            Dialog.exitApp(function () {
+                FB.destroy();
+                App.exit();
+            });
+        });
+    blocks.push(exitLink);
+
     W.stack(inner, blocks, Theme.m.gapCard);
 
     var scroller = W.scroller();
@@ -9412,6 +9428,59 @@ App.mount = function (root) {
     ui.setContentView(root);
 };
 
+/**
+ * 界面被关掉时,把悬浮球一起带走。
+ *
+ * ★ 为什么必须有这个:AutoX 的 UI 模式下**「Activity 关闭」不等于「脚本结束」**。
+ *   界面脚本跑在 `:script` 进程的线程里,用户从最近任务划掉界面(或按返回退出)后,
+ *   脚本仍在跑 —— 而悬浮球窗口是**脚本**持有的,于是球留在别的 App 上面,
+ *   看起来就是"应用都关了,球还浮着"。
+ *   实测(2026-10-22):前台已是 AutoX 的 MainActivity、`:script` 进程存活、
+ *   dumpsys 里还有 8 个 autojs 窗口残留。
+ *
+ * 两条兜底,各自覆盖一种情况:
+ *   ① `events.on("exit")` —— 脚本正常结束或抛异常时清一次。
+ *      进程被系统杀掉时走不到这里,但那种情况窗口会随进程一起被回收,不用我们管。
+ *   ② 轮询 `activity` 是否已 finish —— **唯一能覆盖"界面关了但脚本还活着"的路径**,
+ *      也就是用户报的这个场景。
+ */
+function bindLifecycleCleanup() {
+    try {
+        events.on("exit", function () {
+            try { FB.destroy(); } catch (e) { /* 退出路径上不能再抛 */ }
+        });
+    } catch (e) {
+        console.log("[app] events.on(exit) 不可用(不影响其它清理路径): " + e);
+    }
+
+    /* 只有"曾经拿到过 activity、后来它没了/在销毁"才算关闭。
+     * 这样在 PC 侧 smoke(没有 activity 这个全局)里不会误触发 exit()。 */
+    var sawActivity = false;
+
+    App.lifecycleTick = setInterval(function () {
+        var gone = false;
+        try {
+            if (typeof activity !== "undefined" && activity !== null) {
+                sawActivity = true;
+                gone = activity.isFinishing() || activity.isDestroyed();
+            } else if (sawActivity) {
+                gone = true;
+            }
+        } catch (e) {
+            /* 拿 activity 时抛异常 = 它已经没了 */
+            gone = sawActivity;
+        }
+        if (!gone) {
+            return;
+        }
+        try { clearInterval(App.lifecycleTick); } catch (ignored) { }
+        App.lifecycleTick = null;
+        console.log("[app] 界面已关闭,清理悬浮球并结束脚本");
+        try { FB.destroy(); } catch (e2) { /* 同上 */ }
+        try { exit(); } catch (e3) { /* 老版本 AutoX 可能没有 exit() */ }
+    }, 1500);
+}
+
 App.main = function () {
     Store.init();
 
@@ -9478,6 +9547,8 @@ App.main = function () {
 
     /* 冷启动也是"要用球"的场景:已激活时直接把它挂上(未激活时 canShow 自己会拦住) */
     FB.ensure();
+    /* 界面关了要把球带走 —— 见 bindLifecycleCleanup 的注释 */
+    bindLifecycleCleanup();
     App.renderScreen();
 
     console.log("[app] 界面已就绪,screen=" + App.screen + " 页签=" + App.activeTab);

+ 55 - 0
tiktok-app/src/main.js

@@ -1128,6 +1128,59 @@ App.mount = function (root) {
     ui.setContentView(root);
 };
 
+/**
+ * 界面被关掉时,把悬浮球一起带走。
+ *
+ * ★ 为什么必须有这个:AutoX 的 UI 模式下**「Activity 关闭」不等于「脚本结束」**。
+ *   界面脚本跑在 `:script` 进程的线程里,用户从最近任务划掉界面(或按返回退出)后,
+ *   脚本仍在跑 —— 而悬浮球窗口是**脚本**持有的,于是球留在别的 App 上面,
+ *   看起来就是"应用都关了,球还浮着"。
+ *   实测(2026-10-22):前台已是 AutoX 的 MainActivity、`:script` 进程存活、
+ *   dumpsys 里还有 8 个 autojs 窗口残留。
+ *
+ * 两条兜底,各自覆盖一种情况:
+ *   ① `events.on("exit")` —— 脚本正常结束或抛异常时清一次。
+ *      进程被系统杀掉时走不到这里,但那种情况窗口会随进程一起被回收,不用我们管。
+ *   ② 轮询 `activity` 是否已 finish —— **唯一能覆盖"界面关了但脚本还活着"的路径**,
+ *      也就是用户报的这个场景。
+ */
+function bindLifecycleCleanup() {
+    try {
+        events.on("exit", function () {
+            try { FB.destroy(); } catch (e) { /* 退出路径上不能再抛 */ }
+        });
+    } catch (e) {
+        console.log("[app] events.on(exit) 不可用(不影响其它清理路径): " + e);
+    }
+
+    /* 只有"曾经拿到过 activity、后来它没了/在销毁"才算关闭。
+     * 这样在 PC 侧 smoke(没有 activity 这个全局)里不会误触发 exit()。 */
+    var sawActivity = false;
+
+    App.lifecycleTick = setInterval(function () {
+        var gone = false;
+        try {
+            if (typeof activity !== "undefined" && activity !== null) {
+                sawActivity = true;
+                gone = activity.isFinishing() || activity.isDestroyed();
+            } else if (sawActivity) {
+                gone = true;
+            }
+        } catch (e) {
+            /* 拿 activity 时抛异常 = 它已经没了 */
+            gone = sawActivity;
+        }
+        if (!gone) {
+            return;
+        }
+        try { clearInterval(App.lifecycleTick); } catch (ignored) { }
+        App.lifecycleTick = null;
+        console.log("[app] 界面已关闭,清理悬浮球并结束脚本");
+        try { FB.destroy(); } catch (e2) { /* 同上 */ }
+        try { exit(); } catch (e3) { /* 老版本 AutoX 可能没有 exit() */ }
+    }, 1500);
+}
+
 App.main = function () {
     Store.init();
 
@@ -1194,6 +1247,8 @@ App.main = function () {
 
     /* 冷启动也是"要用球"的场景:已激活时直接把它挂上(未激活时 canShow 自己会拦住) */
     FB.ensure();
+    /* 界面关了要把球带走 —— 见 bindLifecycleCleanup 的注释 */
+    bindLifecycleCleanup();
     App.renderScreen();
 
     console.log("[app] 界面已就绪,screen=" + App.screen + " 页签=" + App.activeTab);

+ 16 - 0
tiktok-app/src/page-mine.js

@@ -293,6 +293,22 @@ Pages.mine = function () {
     /* 设备信息放在最后一行,排障时一眼能看到当前跑在什么机器上 */
     blocks.push(W.hint(Device.deviceLabel() + " \u00B7 " + Device.appVersionName(), false));
 
+    /* —— 退出应用 ——
+     * ★ 为什么要有这个入口:以前主界面里**没有**退出,用户想关掉只能按返回或 HOME。
+     *   而按 HOME 是"退到后台",不是关闭 —— 悬浮球会继续浮在桌面和别的 App 上面
+     *   (它本来就是为了在 TikTok 里操作才存在的)。用户看到球还在,就会认为"关不掉"。
+     *   给一个明确的出口:停脚本 → 关球 → 退到桌面,三件事一次做完。
+     *   靠 `events.on("exit")` 和 Activity 轮询也能清,但那些是兜底,不该是用户唯一的办法。 */
+    blocks.push(U.spacer(8));
+    var exitLink = W.link("\u9000\u51FA\u5E94\u7528\uFF08\u505C\u811A\u672C \u00B7 \u5173\u60AC\u6D6E\u7403\uFF09",
+        Theme.hex.textMuted, function () {
+            Dialog.exitApp(function () {
+                FB.destroy();
+                App.exit();
+            });
+        });
+    blocks.push(exitLink);
+
     W.stack(inner, blocks, Theme.m.gapCard);
 
     var scroller = W.scroller();

+ 6 - 2
tiktok-app/tools/smoke.js

@@ -54,10 +54,14 @@ function eq(name, actual, expected) {
  * 而球在第一次渲染时就已经建好了,于是 interval 总数会多出它那一个。
  * 断言运行计时器时必须把它减掉 —— 否则会把"球的视觉动效"当成"计时器没停"。
  */
+/** 只数"运行时长计时器",把基础设施计时器排除掉:
+ *   · 悬浮球的呼吸灯动画
+ *   · 界面生命周期监听(界面关了要带走悬浮球,见 main.js 的 bindLifecycleCleanup) */
 function tickIntervals() {
-    return env.intervalCount() - (api.FB.breathing() ? 1 : 0);
+    return env.intervalCount()
+        - (api.FB.breathing() ? 1 : 0)
+        - (api.App.lifecycleTick ? 1 : 0);
 }
-
 /** 递归收集一棵视图树里所有非空文字,用于断言"界面上到底显示了什么"。 */
 function textsOf(view, out) {
     const acc = out || [];