YYPOST群发软件 发表于 2026-9-14 06:48:40

悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制

悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制








/* =============================================================================
   AIWROK 技巧项目 04 —— 悬浮窗控制台(floatUI 可拖动悬浮窗 + 按钮控制)

   作用
   ────
   一个能拖动、能点按钮的悬浮小窗,飘在屏幕最上层:
   ▶启动 / ⏸暂停 / ▶恢复 / ⏹停止 / 收起展开 / ✖关闭,
   后台异步跑多线程任务,窗口实时显示状态。

   这个项目演示的特殊技巧
   ──────────────────────
   1. floatUI 悬浮窗:loadXML 加载布局、findViewById 拿控件、setPosition 定位
   2. 拖动手势:给标题栏挂 OnTouchListener,用 MotionEvent 的 ACTION_DOWN/MOVE/UP
      + getRawX/getRawY 算位移
      ⚠ 真机实测坑:直接写 new View.OnTouchListener(...) 会打印"当前环境不支持拖拽",
      因为短类名 View / MotionEvent 并不是全局可用的。
      正确写法:先 importClass(android.view.View) / importClass(android.view.MotionEvent),
      或用全类名 android.view.View.OnTouchListener、android.view.MotionEvent.ACTION_MOVE。
      本脚本两者都做了(resolveView / resolveMotionEvent),拖不动时还能"点标题栏换位"兜底。
   3. 按钮回调:控件.setOnClickListener(function(){...})
   4. 折叠:View.GONE / View.VISIBLE 控制主体显隐(环境不支持时会自动降级)
   5. 关闭:ui.close(),同时把线程停掉,不留后台野线程
   6. 点击去重:真机上一次点击可能被派发多次(日志里刷 3 条"⏹ 已停止"就是这个原因),
      所以命令要 ① 300ms 同命令去重 ② 池内方法自身幂等,双保险。

   注意
   ────
   本项目的悬浮窗就是"控制台"本身,不是自动弹出来刷屏的日志窗。
   脚本里没有额外弹日志窗,全部日志走 printl 输出到 IDE。
   ============================================================================= */
new floatUI().hideSystemFloat();//隐藏aiwork系统自带的悬浮图标
var CFG = {
    workerCount: 3,
    taskCount: 15,         // 每轮任务数(重启时自动重新装填)
    posX: 40, posY: 220,   // 悬浮窗初始位置
    cmdDebounce: 300         // 同一命令 300ms 内的重复点击直接忽略
};

// ============================== Java 类导入(拖拽必需) ==============================
// 坑:直接写 new View.OnTouchListener(...) 会报"不支持拖拽",
// 因为短类名 View / MotionEvent 并不是全局可用的,必须先 importClass。
try { importClass(android.view.View); } catch (e) {}
try { importClass(android.view.MotionEvent); } catch (e) {}

/** 取 View 类:短名 → 全类名 → null */
function resolveView() {
    if (typeof View !== 'undefined' && View) return View;
    try { if (android && android.view && android.view.View) return android.view.View; } catch (e) {}
    return null;
}

/** 取 MotionEvent 类:短名 → 全类名 → null */
function resolveMotionEvent() {
    if (typeof MotionEvent !== 'undefined' && MotionEvent) return MotionEvent;
    try { if (android && android.view && android.view.MotionEvent) return android.view.MotionEvent; } catch (e) {}
    return null;
}

var ui = null;               // floatUI 实例
var ctrls = {};            // 控件引用
var screenW = 1080, screenH = 1920;
var dragLastX = 0, dragLastY = 0, dragging = false;
var expanded = true;
var winX = CFG.posX, winY = CFG.posY;
var dragEnabled = false;   // 拖拽是否真的绑上了
var lastCmd = { name: '', t: 0 };

// ============================== 工具 ==============================

function log(m) { try { printl(m); } catch (e) {} }
function sleepMs(ms) { try { java.lang.Thread.sleep(ms); } catch (e) {} }
function nowMs() { return java.lang.System.currentTimeMillis(); }

function getScreenSize() {
    try {
      var m = context.getResources().getDisplayMetrics();
      if (m && m.widthPixels) { screenW = m.widthPixels; screenH = m.heightPixels; }
    } catch (e) {
      try { screenW = screen.getScreenWidth(); screenH = screen.getScreenHeight(); } catch (e2) {}
    }
    log('屏幕尺寸: ' + screenW + ' x ' + screenH);
}

// ============================== 后台任务池(精简版) ==============================

function MiniPool(workerCount) {
    var workerN = workerCount || 3;
    var workers = [], queue = [], lock = false;
    var running = false, paused = false, accepting = true;
    var active = 0, total = 0, done = 0, failed = 0;
    var exited = 0;            // 已自然退出的线程数

    function section(fn) {
      var g = 0;
      while (lock && g < 3000) { sleepMs(2); g++; }
      lock = true;
      try { return fn(); } finally { lock = false; }
    }

    function take() {
      var t = null;
      section(function () { if (queue.length > 0) { t = queue.shift(); active++; } });
      return t;
    }

    function loop(idx) {
      while (running) {
            if (paused) { sleepMs(120); continue; }
            var task = take();
            if (task == null) {
                if (!accepting && queue.length === 0) break;
                sleepMs(60);
                continue;
            }
            var ok = true, err = '';
            try { task.fn('T' + idx); } catch (e) { ok = false; err = String(e); }
            section(function () { active--; if (ok) done++; else failed++; });
            consoleLog((ok ? '✔ ' : '✘ ') + 'T' + idx + ' ' + task.name + (ok ? '' : ' ' + err));
      }
      // 关键:线程自然跑完退出时,要把池子状态归位;
      // 否则 running 一直是 true,任务跑完后再点【启动】会被当成"已在运行"而空转。
      section(function () {
            exited++;
            if (exited >= workerN) { running = false; paused = false; accepting = true; }
      });
    }

    this.add = function (name, fn) { section(function () { queue.push({ name: name, fn: fn }); total++; }); return this; };

    /** 本轮是否已跑完 */
    this.isFinished = function () { return total > 0 && (done + failed) >= total; };

    /** 清空队列与计数(只能在没有线程运行时调用) */
    this.reset = function () {
      if (running || active > 0) return false;
      section(function () {
            queue = []; total = 0; done = 0; failed = 0; exited = 0; accepting = true; paused = false;
      });
      return true;
    };

    // 所有控制方法都要幂等:重复点击只提示、不重复执行、不重复打日志
    this.start = function () {
      if (running) { consoleLog('· 已经在运行了'); return false; }
      if (queue.length === 0) { consoleLog('· 队列是空的,先装任务'); return false; }
      running = true; paused = false; accepting = true; exited = 0;
      for (var i = 0; i < workerN; i++) {
            var th = new thread();
            workers.push(th);
            (function (n) { th.runJsCode(function () { loop(n); }, '悬浮台线程' + n); })(i + 1);
            sleepMs(140);
      }
      consoleLog('▶ 已启动 ' + workerN + ' 个线程');
      return true;
    };

    this.pause = function () {
      if (!running) { consoleLog('· 没在运行,不用暂停'); return false; }
      if (paused) { consoleLog('· 已经是暂停状态'); return false; }
      paused = true;
      consoleLog('⏸ 已暂停');
      return true;
    };

    this.resume = function () {
      if (!paused) { consoleLog('· 当前不是暂停状态'); return false; }
      paused = false;
      consoleLog('▶ 已恢复');
      return true;
    };

    this.stop = function () {
      // 幂等:确实没东西在跑就什么都不做("⏹ 已停止"刷 3 条就是这么来的)
      var inf = this.info();
      if (!inf.running && inf.alive === 0 && inf.active === 0) {
            consoleLog('· 已经停止了');
            return false;
      }
      accepting = false; running = false; paused = false;
      for (var i = 0; i < workers.length; i++) {
            try { if (workers.isAlive()) workers.stop(); } catch (e) {}
      }
      exited = 0;
      consoleLog('⏹ 已停止');
      return true;
    };

    this.info = function () {
      var a = 0;
      for (var i = 0; i < workers.length; i++) { try { if (workers.isAlive()) a++; } catch (e) {} }
      return { total: total, done: done, failed: failed, queue: queue.length, active: active, alive: a, paused: paused, running: running };
    };
}

var pool = null;

// ============================== 窗口内的状态显示 ==============================

var consoleLines = [];

function consoleLog(text) {
    var n = new Date();
    var t = ('0' + n.getHours()).slice(-2) + ':' + ('0' + n.getMinutes()).slice(-2) + ':' + ('0' + n.getSeconds()).slice(-2);
    consoleLines.unshift('[' + t + '] ' + text);
    if (consoleLines.length > 4) consoleLines = consoleLines.slice(0, 4);
    log(text);
    // 注意:这里不直接刷控件。控件更新统一放到主线程心跳里做,
    // 避免工作线程直接操作 UI 触发 Android 的 "only the original thread can touch its views" 异常。
}

function renderConsole() {
    try {
      if (ctrls.log) ctrls.log.setText(consoleLines.join('\n'));
    } catch (e) {}
}

function refreshStatus() {
    if (!pool) return;
    var i = pool.info();
    var pct = i.total > 0 ? Math.floor((i.done + i.failed) * 100 / i.total) : 0;
    var txt = '进度 ' + pct + '%   ✔' + i.done + '✘' + i.failed +
            '队列' + i.queue + '线程' + i.alive;
    try { if (ctrls.status) ctrls.status.setText(txt); } catch (e) {}
}

// ============================== 浮动窗口 ==============================

function buildXml() {
    return '' +
    '<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" ' +
    'android:id="box" android:layout_width="230dp" android:layout_height="wrap_content" ' +
    'android:orientation="vertical" android:background="#E6111A2B" android:padding="6dp">' +

    '<LinearLayout android:id="title" android:layout_width="match_parent" ' +
    '    android:layout_height="34dp" android:orientation="horizontal" android:gravity="center_vertical" ' +
    '    android:background="#1F2A44">' +
    '    <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" ' +
    '      android:text="✥" android:textColor="#8FA3C8" android:textSize="15sp" android:paddingLeft="6dp" android:paddingRight="6dp"/>' +
    '    <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" ' +
    '      android:text="AIWROK 控制台" android:textColor="#FFFFFF" android:textSize="13sp"/>' +
    '    <Button android:id="btnFold" android:layout_width="34dp" android:layout_height="28dp" ' +
    '      android:text="-" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#334155" android:padding="0dp"/>' +
    '    <Button android:id="btnClose" android:layout_width="34dp" android:layout_height="28dp" ' +
    '      android:text="✕" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#EF4444" android:padding="0dp"/>' +
    '</LinearLayout>' +

    '<LinearLayout android:id="body" android:layout_width="match_parent" ' +
    '    android:layout_height="wrap_content" android:orientation="vertical" android:paddingTop="6dp">' +

    '    <TextView android:id="status" android:layout_width="match_parent" android:layout_height="wrap_content" ' +
    '      android:text="进度 0%   ✔0✘0队列0线程0" android:textColor="#60A5FA" android:textSize="11sp" android:paddingBottom="6dp"/>' +

    '    <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" ' +
    '      android:orientation="horizontal">' +
    '      <Button android:id="btnStart" android:layout_width="0dp" android:layout_weight="1" ' +
    '      android:layout_height="36dp" android:text="▶ 启动" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#16A34A" android:padding="0dp"/>' +
    '      <Button android:id="btnPause" android:layout_width="0dp" android:layout_weight="1" ' +
    '      android:layout_height="36dp" android:text="⏸ 暂停" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#D97706" android:padding="0dp"/>' +
    '    </LinearLayout>' +

    '    <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" ' +
    '      android:orientation="horizontal" android:paddingTop="4dp">' +
    '      <Button android:id="btnResume" android:layout_width="0dp" android:layout_weight="1" ' +
    '      android:layout_height="36dp" android:text="▶ 恢复" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#2563EB" android:padding="0dp"/>' +
    '      <Button android:id="btnStop" android:layout_width="0dp" android:layout_weight="1" ' +
    '      android:layout_height="36dp" android:text="⏹ 停止" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#B91C1C" android:padding="0dp"/>' +
    '    </LinearLayout>' +

    '    <TextView android:id="log" android:layout_width="match_parent" android:layout_height="wrap_content" ' +
    '      android:text="" android:textColor="#86EFAC" android:textSize="10sp" android:paddingTop="6dp"/>' +
    '</LinearLayout>' +
    '</LinearLayout>';
}

function moveTo(x, y) {
    if (x < 0) x = 0;
    if (y < 0) y = 0;
    if (x > screenW - 120) x = screenW - 120;
    if (y > screenH - 80) y = screenH - 80;
    winX = x; winY = y;
    try { ui.setPosition(x, y); } catch (e) {}
}

/** 绑定拖拽。设备实测:短类名 View/MotionEvent 不存在 → 必须用 importClass 后的类 */
function setupDrag() {
    var V = resolveView();
    var M = resolveMotionEvent();
    if (!V || !M || !ctrls.title || !ctrls.title.setOnTouchListener) {
      log('⚠ 拖拽不可用(缺 ' + (V ? '' : 'View ') + (M ? '' : 'MotionEvent') + '),改用【点标题栏换位】');
      bindTitleTapToMove();
      return false;
    }
    try {
      ctrls.title.setOnTouchListener(new V.OnTouchListener({
            onTouch: function (view, event) {
                try {
                  switch (event.getAction()) {
                        case M.ACTION_DOWN:
                            dragging = true;
                            dragLastX = event.getRawX();
                            dragLastY = event.getRawY();
                            break;
                        case M.ACTION_MOVE:
                            if (dragging) {
                              var dx = event.getRawX() - dragLastX;
                              var dy = event.getRawY() - dragLastY;
                              moveTo(winX + dx, winY + dy);
                              dragLastX = event.getRawX();
                              dragLastY = event.getRawY();
                            }
                            break;
                        case M.ACTION_UP:
                            dragging = false;
                            break;
                  }
                  return true;
                } catch (e2) { return false; }
            }
      }));
      dragEnabled = true;
      log('✔ 拖拽已绑定:按住标题栏可移动窗口');
      return true;
    } catch (e) {
      log('⚠ 拖拽绑定失败: ' + e);
      bindTitleTapToMove();
      return false;
    }
}

/** 拖拽不可用时的兜底:点一下标题栏,在几个预设位置间循环 */
var presetIdx = -1;
function bindTitleTapToMove() {
    if (!ctrls.title || !ctrls.title.setOnClickListener) return;
    try {
      ctrls.title.setOnClickListener(function () {
            var presets = [
                , ,
                , ,
               
            ];
            presetIdx = (presetIdx + 1) % presets.length;
            moveTo(presets, presets);
            log('↔ 窗口已换位到第 ' + (presetIdx + 1) + ' 个位置');
      });
    } catch (e) {}
}

function toggleFold() {
    expanded = !expanded;
    var V = resolveView();
    try {
      if (V && V.GONE !== undefined && ctrls.body && ctrls.body.setVisibility) {
            ctrls.body.setVisibility(expanded ? V.VISIBLE : V.GONE);
            log(expanded ? '&#128316; 已展开' : '&#128317; 已收起');
            return true;
      }
    } catch (e) {}
    expanded = true;
    log('⚠ 当前环境不支持折叠,窗口保持展开');
    return false;
}

function closeConsole() {
    log('✖ 关闭控制台');
    try { pool.stop(); } catch (e) {}
    try { ui.close(); } catch (e) { log('关闭悬浮窗失败: ' + e); }
    alive = false;
}

// ============================== 命令分发(去重 + 立即刷界面) ==============================
// 实测发现:一次点击可能被派发多次(3 条一模一样的"已停止"就是这么来的)。
// 双保险:① 同一命令 300ms 内只认第一次;② MiniPool 内部各方法本身幂等。

function onceCmd(name) {
    var t = nowMs();
    if (name === lastCmd.name && t - lastCmd.t < CFG.cmdDebounce) return false;
    lastCmd = { name: name, t: t };
    return true;
}

/** 按钮回调跑在 UI 线程,这里直接刷控件是安全的,点完立刻能看到变化 */
function afterCmd() {
    refreshStatus();
    renderConsole();
}

function cmdStart() {
    if (!onceCmd('start')) return;
    if (pool.isFinished()) {               // 上一轮已跑完 → 自动重新装填,避免"重启空跑"
      pool.reset();
      refillTasks();
      consoleLog('&#128260; 已重新装填 ' + CFG.taskCount + ' 个任务');
    }
    pool.start();
    afterCmd();
}

function cmdPause(){ if (!onceCmd('pause'))return; pool.pause();afterCmd(); }
function cmdResume() { if (!onceCmd('resume')) return; pool.resume(); afterCmd(); }
function cmdStop()   { if (!onceCmd('stop'))   return; pool.stop();   afterCmd(); }
function cmdFold()   { if (!onceCmd('fold'))   return; toggleFold();afterCmd(); }
function cmdClose(){ if (!onceCmd('close'))return; closeConsole(); }

// ============================== 界面装配 ==============================

var alive = true;

function createConsole() {
    getScreenSize();
    ui = new floatUI();
    ui.loadXML(buildXml());

    ctrls.title   = ui.findViewById('title');
    ctrls.body      = ui.findViewById('body');
    ctrls.status    = ui.findViewById('status');
    ctrls.log       = ui.findViewById('log');
    ctrls.btnStart= ui.findViewById('btnStart');
    ctrls.btnPause= ui.findViewById('btnPause');
    ctrls.btnResume = ui.findViewById('btnResume');
    ctrls.btnStop   = ui.findViewById('btnStop');
    ctrls.btnFold   = ui.findViewById('btnFold');
    ctrls.btnClose= ui.findViewById('btnClose');

    if (!ctrls.btnStart) { log('✘ 悬浮窗控件未取到,设备可能不支持 floatUI'); return false; }

    // 按钮事件(统一走 cmd* 分发器:带去重 + 幂等 + 立即刷界面)
    ctrls.btnStart.setOnClickListener(function () { try { cmdStart(); } catch (e) { log('启动失败: ' + e); } });
    ctrls.btnPause.setOnClickListener(function () { try { cmdPause(); } catch (e) {} });
    ctrls.btnResume.setOnClickListener(function () { try { cmdResume(); } catch (e) {} });
    ctrls.btnStop.setOnClickListener(function () { try { cmdStop(); } catch (e) {} });
    ctrls.btnFold.setOnClickListener(function () { try { cmdFold(); } catch (e) {} });
    ctrls.btnClose.setOnClickListener(function () { try { cmdClose(); } catch (e) {} });

    setupDrag();
    moveTo(CFG.posX, CFG.posY);
    consoleLog('控制台就绪,点【▶ 启动】跑任务');
    return true;
}

// ============================== 任务与心跳 ==============================

function buildTask(idx) {
    return function (tag) {
      sleepMs(rand.randNumber(400, 1200));
      if (idx % 8 === 0) throw new Error('第' + idx + '个任务异常');
    };
}

/** 装填一轮任务 */
function refillTasks() {
    for (var i = 1; i <= CFG.taskCount; i++) pool.add('任务' + i, buildTask(i));
}

/** 心跳(跑在主线程):刷新窗口状态 + 给主线程保活 */
function heartbeat() {
    if (!alive) { log('心跳结束,主线程退出'); return; }
    refreshStatus();
    renderConsole();
    runTime.setTimeout(heartbeat, 1000);
}

// ============================== 入口 ==============================

function main() {
    log('════════════════════════════════════════════');
    log('悬浮窗控制台(floatUI)');
    log('════════════════════════════════════════════');

    pool = new MiniPool(CFG.workerCount);
    refillTasks();

    if (!createConsole()) return;

    heartbeat();
    log(dragEnabled ? '&#128073; 按住标题栏拖动窗口' : '&#128073; 点标题栏可循环换位');
    log('   【▶ 启动】跑任务【⏸ 暂停】/【▶ 恢复】【⏹ 停止】【-】收起【✕】关闭');
    log('   任务跑完后再点【▶ 启动】会自动重新装填一轮');
}

main();


页: [1]
查看完整版本: 悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制