floatUI深色控制台风格悬浮窗日志
floatUI深色控制台风格悬浮窗日志/**
* ╔══════════════════════════════════════════════════════════╗
* ║ floatUI深色控制台风格悬浮窗日志
* ║ 知识点:floatUI 悬浮窗、彩色日志(Html)、 ║
* ║ 进度条、自动滚屏、event.onStopBtnEvent、 ║
* ║ runTime.stopAllRuning() ║
* ╚══════════════════════════════════════════════════════════╝
*
* 目标:深色控制台风格悬浮窗(标题栏 + 统计栏 + 进度条 + 彩色日志 + 状态栏),
* 循环跑模拟任务实时打日志,点停止按钮时自动关闭并清理线程。
* 难度:★★☆☆☆ 进阶第二课
* 运行:直接在 AIWROK 里跑,不需要装任何 App
*
* 注意(Rhino ES5 规则):
* 1. 不支持 ES6 模板字符串,一律用 + 拼接
* 2. 函数必须先定义再调用
* 3. 全局变量统一在文件顶部声明,避免 ReferenceError
* 4. 日志用 printl,不要用 print.log()
* 5. Import / importClass 必须放在全局最外层,不能进代码块
*/
importClass(android.text.Html);
// ==================== 顶部:全局变量声明 ====================
var gWin = null; // 悬浮窗对象(floatUI)
var gTvTitle = null; // 标题栏计数控件
var gTvTask = null; // 统计栏:任务数
var gTvOk = null; // 统计栏:成功数
var gTvErr = null; // 统计栏:失败数
var gTvTime = null; // 统计栏:耗时
var gTvBar = null; // 进度条控件
var gTvStatus = null; // 底部状态栏
var gTvLog = null; // 日志 TextView 控件
var gScroll = null; // 日志 ScrollView(用于自动滚屏)
var gHtmlLines = []; // 彩色日志历史(存 Html 字符串)
var gLogCount = 0; // 日志总条数
var gMaxLines = 100; // 最多保留 100 行
var gOkCount = 0; // 成功计数
var gErrCount = 0; // 失败计数
var gStartTs = 0; // 启动时间戳
var gStopTick = false;// 计时线程退出标志
// ==================== 调色板(想换配色改这里)====================
var C_BG_OUTER = "#33000000"; // 外框(半透明黑,形成描边感)
var C_HEADER = "#0EA5E9"; // 标题栏:天蓝
var C_PANEL = "#0F172A"; // 面板底:深蓝黑
var C_BODY = "#0B1220"; // 日志区底:更深
var C_TEXT = "#E2E8F0"; // 正文
var C_DIM = "#64748B"; // 时间戳(暗)
var C_OK = "#4ADE80"; // 成功(绿)
var C_ERR = "#F87171"; // 失败(红)
var C_WARN = "#FBBF24"; // 提醒(黄)
var C_ACCENT = "#38BDF8"; // 强调(亮蓝)
// ==================== 工具函数(先定义,后使用)====================
// 阻塞延时:Rhino 里用 Thread.sleep,统一封装成 doSleep
function doSleep(ms) {
try { java.lang.Thread.sleep(ms); } catch (e) {}
}
// 取两位数时间戳,例如 09:05:03
function getTimeStr() {
var d = new Date();
var h = d.getHours(); if (h < 10) h = "0" + h;
var m = d.getMinutes(); if (m < 10) m = "0" + m;
var s = d.getSeconds(); if (s < 10) s = "0" + s;
return h + ":" + m + ":" + s;
}
// 取 HH:MM:SS 耗时
function getElapsedStr() {
var ms = new Date().getTime() - gStartTs;
var mm = Math.floor(ms / 60000);
var ss = Math.floor((ms % 60000) / 1000);
return (mm < 10 ? "0" : "") + mm + ":" + (ss < 10 ? "0" : "") + ss;
}
// 拼一行彩色日志:<font> 包时间戳 / 图标 / 正文
function buildHtmlLine(ts, icon, iconColor, msg, msgColor) {
return "<font color='" + C_DIM + "'>" + ts + "</font>" +
"<font color='" + iconColor + "'>" + icon + "</font>" +
"<font color='" + msgColor + "'>" + msg + "</font>";
}
// ==================== 第 1 步:创建悬浮窗 ====================
// floatUI 是 AIWROK 的悬浮窗控件,loadXML 用 XML 描述布局
// 规则:尺寸用 "fill_parent" 而非 "*";padding 用单值;没有 <View> 组件
function initFloatWindow() {
var sw = screen.getScreenWidth();
var sh = screen.getScreenHeight();
printl("[屏幕] " + sw + "x" + sh);
// 按屏幕百分比算尺寸,换任何手机都不会遮挡界面
var w = Math.floor(sw * 0.52); // 宽 52%
var h = Math.floor(sh * 0.30); // 高 30%
var x = 8;
var y = Math.floor(sh * 0.08); // 距顶部 8%
// 布局结构:外框(描边感) → 标题栏 → 统计栏 → 进度条 → 日志区 → 状态栏
var xml =
'<LinearLayout orientation="vertical" w="' + w + '" h="' + h +
'" background="' + C_BG_OUTER + '" padding="3">' +
// ── 标题栏(标题不能用 fill_parent,否则右侧计数被挤出屏幕)──
'<LinearLayout orientation="horizontal" w="fill_parent" h="wrap_content" ' +
'background="' + C_HEADER + '" padding="8">' +
'<TextView text="🚀 运行日志" textColor="#FFFFFF" textSize="13" ' +
'textStyle="bold"/>' +
'<TextView id="hdrCount" text="0 条" textColor="#E0F2FE" textSize="11" ' +
'w="fill_parent" gravity="right"/>' +
'</LinearLayout>' +
// ── 统计栏 ──
'<LinearLayout orientation="horizontal" w="fill_parent" h="wrap_content" ' +
'background="' + C_PANEL + '" padding="6">' +
'<TextView id="stTask" text="任务 0" textColor="#94A3B8" textSize="11"/>' +
'<TextView id="stOk" text="✓ 0" textColor="' + C_OK + '" textSize="11"/>' +
'<TextView id="stErr"text="✗ 0" textColor="' + C_ERR + '" textSize="11"/>' +
'<TextView id="stTime" text="⏱ 00:00" textColor="' + C_WARN + '" textSize="11" ' +
'w="fill_parent" gravity="right"/>' +
'</LinearLayout>' +
// ── 进度条(用 ▰▱ 字符画,任何版本都支持)──
'<LinearLayout orientation="horizontal" w="fill_parent" h="wrap_content" ' +
'background="' + C_PANEL + '" padding="4">' +
'<TextView id="progBar" text="▰▰▰▰▰▰▰▰▰▰▰▰ 0%" ' +
'textColor="' + C_ACCENT + '" textSize="11" w="fill_parent"/>' +
'</LinearLayout>' +
// ── 日志区 ──
'<ScrollView id="logScroll" w="fill_parent" h="fill_parent" background="' + C_BODY + '">' +
'<TextView id="logBox" w="fill_parent" h="wrap_content" text="" ' +
'textColor="' + C_TEXT + '" textSize="11" padding="6"/>' +
'</ScrollView>' +
// ── 状态栏 ──
'<TextView id="statusBar" text="● 正在运行..." textColor="' + C_OK + '" ' +
'textSize="11" background="' + C_PANEL + '" padding="6" gravity="center"/>' +
'</LinearLayout>';
try {
gWin = new floatUI();
gWin.loadXML(xml);
gWin.setPosition(x, y); // 必须在 loadXML 成功后再调用,否则会空指针
gTvTitle= gWin.findViewById("hdrCount");
gTvTask = gWin.findViewById("stTask");
gTvOk = gWin.findViewById("stOk");
gTvErr = gWin.findViewById("stErr");
gTvTime = gWin.findViewById("stTime");
gTvBar = gWin.findViewById("progBar");
gTvStatus = gWin.findViewById("statusBar");
gTvLog = gWin.findViewById("logBox");
gScroll = gWin.findViewById("logScroll");
printl("[悬浮窗] 创建成功 pos=(" + x + "," + y + ") size=" + w + "x" + h);
return true;
} catch (e) {
printl("[悬浮窗] 创建失败: " + e);
return false;
}
}
// ==================== UI 线程调度(本例最关键的修复)====================
// 从脚本线程直接 setText 只会触发"重绘",不会触发"重新测量布局":
// - wrap_content 的日志区高度永远卡在第 1 行 → 后面的日志全被裁掉(只显示一行)
// - 文本变长的控件("任务 10")新内容被裁 → 数字显示不出来
// 解决:用 view.post() 把更新调度到 UI 线程执行,布局才能正常重算。
// (参考:floatUI功能/安卓可移动悬浮窗功能.js 的方案2)
function uiPost(view, fn) {
if (view && view.post) {
try { view.post(fn); return; } catch (e) {}
}
try { fn(); } catch (e2) {}
}
// ==================== 第 2 步:写日志(彩色 + 自动滚屏)====================
// 关键:用数组存历史 Html 行,setText 时 join 全部输出(防覆盖)。
// Html.fromHtml 让每行的时间戳/图标/正文各显各的颜色。
function appendLog(msg, color) {
gLogCount++;
var msgColor = color || C_TEXT;
if (msg.length > 200) msg = msg.substring(0, 200) + "...";
// 不同类型给不同图标和颜色
var icon = "•", iconColor = C_ACCENT;
if (color == C_OK) { icon = "✓"; iconColor = C_OK; }
if (color == C_ERR){ icon = "✗"; iconColor = C_ERR; }
if (color == C_WARN) { icon = "➤"; iconColor = C_WARN; }
gHtmlLines.push(buildHtmlLine(getTimeStr(), icon, iconColor, msg, msgColor));
if (gHtmlLines.length > gMaxLines) gHtmlLines.shift();// 超出上限丢掉最旧的一行
// 在 UI 线程里更新日志 + 计数 + 滚屏,布局才会重新测量
uiPost(gTvLog, function () {
try {
var html = gHtmlLines.join("<br/>");
gTvLog.setText(Html.fromHtml(html));
gTvTitle.setText(gLogCount + " 条");
// 自动滚到最新一行(FOCUS_DOWN = 130)
if (gScroll) gScroll.fullScroll(130);
} catch (e) {}
});
// 同步打印到 AIWROK 控制台,方便调试
printl(msg);
}
// ==================== 第 3 步:统计栏 / 进度条刷新 ====================
function refreshStats(taskName) {
var taskTotal = gOkCount + gErrCount;
var ok = gOkCount, err = gErrCount, elapsed = getElapsedStr();
uiPost(gTvTask, function () {
try {
gTvTask.setText("任务 " + taskTotal);
gTvOk.setText("✓ " + ok);
gTvErr.setText("✗ " + err);
gTvTime.setText("⏱ " + elapsed);
} catch (e) {}
});
}
// 进度条:12 格方块,按完成比例填充
function refreshBar(done, total) {
var blocks = 12;
var filled = Math.round(done * blocks / total);
if (filled > blocks) filled = blocks;
var bar = "";
for (var i = 0; i < blocks; i++) bar += (i < filled) ? "▰" : "▱";
var pct = Math.round(done * 100 / total);
var text = bar + " " + pct + "%";
uiPost(gTvBar, function () {
try { gTvBar.setText(text); } catch (e) {}
});
}
function setStatus(text, color) {
var shown = "● " + text;
uiPost(gTvStatus, function () {
try { gTvStatus.setTextColor(android.graphics.Color.parseColor(color)); } catch (e) {}
try { gTvStatus.setText(shown); } catch (e2) {}
});
}
// ==================== 第 4 步:后台计时线程 ====================
// new thread().runJsCode(fn, 名称) → 在独立线程里跑一段 JS 代码
// 主线程结束不会自动杀它,所以必须靠 gStopTick 标志让它自己退出
function startTickerThread() {
new thread().runJsCode(function () {
while (!gStopTick) {
doSleep(1000);
if (gStopTick) break;
refreshStats();
}
}, "计时线程");
printl("[线程] 计时线程已启动");
}
// ==================== 第 5 步:优雅停止 ====================
// event.onStopBtnEvent → 点停止按钮【之前】触发,适合做清理
// 注意:runTime.stopAllRuning() 必须放在清理动作的【最后】,
// 否则它会中断还没执行完的清理代码。
function cleanup() {
gStopTick = true; // 先让计时线程自己退出
setStatus("正在清理资源...", C_WARN);
appendLog("🧹 清理资源,关闭悬浮窗", C_WARN);
doSleep(400);
try { if (gWin) { gWin.close(); gWin = null; } } catch (e) {} // floatUI 用 .close(),没有 .destroy()
printl("[清理] 悬浮窗已关闭,线程标志已置位");
runTime.stopAllRuning(); // 最后才停掉所有线程
stop();
}
event.onStopBtnEvent(function () {
cleanup();
});
// ==================== 第 6 步:主流程 ====================
function runMain() {
printl("╔══════════════════════════════════════════╗");
printl("║案例6:悬浮窗日志 + 优雅停止(美化版)║");
printl("╚══════════════════════════════════════════╝");
if (!initFloatWindow()) {
printl("悬浮窗创建失败,脚本退出");
return;
}
gStartTs = new Date().getTime();
// 模拟任务列表:每条 [任务名, 是否成功]
var tasks = [
["检测设备权限", true],
["连接远程服务器", true],
["拉取配置文件", true],
["OCR 图像识别", false], // 故意失败,演示重试
["重试 OCR", true],
["解析识别结果", true],
["写入本地数据库", true],
["校验数据完整性", false],
["自动修复", true],
["生成运行报告", true]
];
setStatus("正在运行...", C_OK);
appendLog("🎬 演示开始(约 15 秒)", C_WARN);
appendLog("═══════ 任务开始 ═══════", C_WARN);
startTickerThread();
refreshBar(0, tasks.length);
for (var i = 0; i < tasks.length; i++) {
var name = tasks;
var ok = tasks;
if (ok) {
gOkCount++;
appendLog("[ " + (i + 1) + "/" + tasks.length + " ] " + name, C_OK);
} else {
gErrCount++;
appendLog("[ " + (i + 1) + "/" + tasks.length + " ] " + name + " → 重试中", C_ERR);
refreshStats(name);
doSleep(800);
gErrCount--; // 重试成功,失败数减回去
gOkCount++;
appendLog("↳ 重试成功", C_OK);
}
refreshStats(name);
refreshBar(i + 1, tasks.length);
doSleep(800); // 真人节奏,别刷太快
}
// 收尾:让观众看清统计
appendLog("═══════ 任务全部结束 ═══════", C_WARN);
appendLog("共 " + gOkCount + " 成功 / " + gErrCount + " 失败,3 秒后自动关闭", C_OK);
setStatus("全部完成 ✓", C_OK);
refreshBar(tasks.length, tasks.length);
doSleep(3000);
// 正常跑完也要清理,否则悬浮窗会残留在屏幕上
cleanup();
}
runMain();
页:
[1]