悬浮窗控制台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 ? '🔼 已展开' : '🔽 已收起');
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('🔄 已重新装填 ' + 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 ? '👉 按住标题栏拖动窗口' : '👉 点标题栏可循环换位');
log(' 【▶ 启动】跑任务【⏸ 暂停】/【▶ 恢复】【⏹ 停止】【-】收起【✕】关闭');
log(' 任务跑完后再点【▶ 启动】会自动重新装填一轮');
}
main();
页:
[1]