玩 Autojs 的朋友应该深有体会:脚本写得再溜,如果运行的时候没法在别的页面做点实时交互,那体验就大打折扣。悬浮窗(floaty)就是解决这个问题的核心模块——它让你可以在任意应用上层显示一个自定义面板,用来做按钮入口、状态提示、进度显示都顺手。我写 Autojs 脚本三年多,从最早一个干巴巴的悬浮按钮,到后来把整套自动化任务的操作面板都挂在悬浮窗里,这中间的坑没少踩,但这套东西确实值得每个玩 Autojs 的人掌握。这篇文章我会把 floaty 的基础用法、常用 API、一个可拖拽悬浮面板的完整实现,以及权限、兼容、手感这些最容易踩雷的细节一次讲透。
先说好,这篇文章默认你已经装好了 Autojs(我用的是较新版本的 Pro 版,不过基础 API 大同小异),会写一点简单的 JavaScript 语法。如果你连 Autojs 是什么都还不知道,建议先跑几个录屏点击脚本再来,不然上来就聊悬浮窗,很多概念会接不住。
[≈410字]
1. 先搞清楚 floaty.window 和 floaty.rawWindow 的区别
1.1 两种创建方式,对应两种需求
Autojs 的悬浮窗模块里,官方提供了两个创建方法:floaty.window()和floaty.rawWindow()。很多人第一次用就直接选floaty.window,但实际场景里这两者的差异非常大,选错了轻则耗电,重则悬浮窗不显示或者挡住操作。
floaty.window创建的是一个标准窗口,它会参与系统窗口管理,有自己的层级,可以设置焦点、可以默认接受触摸事件,而且它的生命周期和脚本本身并未完全绑定,脚本停止后,这个窗口如果没手动关掉,很多时候还会“残留”在屏幕上。它的好处是稳定,做可交互的悬浮按钮、菜单面板、悬浮键盘这类东西很合适。
floaty.rawWindow则是一个“原始窗口”,它不经过 Autojs 的窗口包装,本质上是在根窗口上直接绘制内容。它的最大特点是轻量,不抢占焦点,初始状态下默认是不可触摸的(需要手动设置setTouchable(true)),适合做“只展示、不互动”的悬浮文本,比如把脚本运行状态、剩余时间、倒计时这类信息挂在屏幕上。它的性能开销小,不容易和当前应用抢焦点,但你如果试图用它做一个精美的交互面板,那就会出现触摸逻辑异常、点击穿透之类的奇葩问题。
我自己的习惯是:
- 需要交互的悬浮按钮 / 菜单 →
floaty.window - 只读状态展示 / 跑马灯文案 →
floaty.rawWindow
1.2 为什么选择悬浮窗而不是直接跳回 Autojs
还有一个朴素的问题:“我直接在 Autojs 主界面跑脚本不就行了,为什么要用悬浮窗?”原因很简单:自动化脚本往往要操作第三方 App,比如自动打卡、自动签到、自动回消息、自动抢券。这些操作需要你在目标 App 的界面里完成,不可能来回切到 Autojs 控制脚本。悬浮窗就是一个“浮在目标界面上层的遥控器”,它解决了脚本和人之间交互距离的问题。
举一个最典型的场景:你写了一个自动识别快递条形码的脚本,识别过程中需要手动确认“屏幕上这个快递单号是否正确”,这时候你总不能切出去扫描枪,再切回来粘贴吧?用悬浮窗放一个小面板,上面显示识别结果和“确认”“重扫”两个按钮,指头一点就完事。这种体验的提升,是脚本逻辑本身给不了的。
[≈800字]
2. 环境准备与最基础的一个悬浮窗
2.1 权限:显示在其他应用上层
这只是 Autojs 入门,但也是新手的第一个大坑。悬浮窗要想显示到别的 App 上面,必须要有“显示在其他应用上层”权限(Android 6.0 及以上)。Autojs 里查权限和请求权限的 API 在不同版本里名字不太一样,老版本可能是floaty.checkPermission(),新版本又变成了floaty.hasPermission(),我建议直接用最通用的一段代码做保障:
// 检查悬浮窗权限 if (!floaty.hasPermission()) { toast("需要悬浮窗权限"); floaty.requestPermission(); // 等待用户授权 wait(function() { return floaty.hasPermission(); }, 1000); }这段代码里wait()是 Autojs 的一个等待函数,第一个参数是条件函数,第二个是轮询间隔(毫秒)。注意:不同版本返回结构可能略有差异,如果floaty.hasPermission不存在,就检查Settings.canDrawOverlays()或者换用floaty.checkPermission()。
授权之后,很多手机还需要在“最近任务”里给 Autojs 加锁,防止 App 被系统清理掉,因为悬浮窗本质上是由 Autojs 这个 App 进程托管的。脚本跑完了,进程还在,悬浮窗也在;进程没了,悬浮窗就没了。
2.2 第一个可运行的悬浮窗代码
我建议你新建一个脚本,直接贴下面的代码跑一遍,感受一下悬浮窗长什么样:
var window = floaty.window( <frame width="200dp" height="120dp" bg="#66000000"> <text id="status" text="悬浮窗 OK" textColor="#ffffff" gravity="center"/> </frame> ); window.setPosition(device.width / 2 - 100, 400); window.status.text("运行中"); sleep(3000); window.close();这段代码里,floaty.window()接收一个“视图描述”,可以是 XML 布局、UI 布局字符串或者ui.inflate()出来的 View 对象。上面用的是 Autojs 里最常见的 XML 模板语法,<frame>是帧布局,<text>是文本控件,id="status"给你一个后期操作这个控件的入口。
之后:
window.setPosition(x, y)设置窗口左上角在屏幕上的绝对坐标。window.status.text("运行中")直接操作悬浮窗内部的status文本控件。window.close()关闭悬浮窗。
跑完你应该能看到屏幕上出现一个半透明黑底白字的悬浮块,3 秒后自动消失。如果看不到,请回头检查权限;如果看到了但文字被系统状态栏遮住,那就是坐标问题,后面细说。
[≈900字]
3. 悬浮窗常用 API 与核心细节
3.1 位置、尺寸、可见性
悬浮窗本质上是一个“窗口对象”,所以它自然有窗口该有的操作。除了setPosition(x, y),还有setSize(width, height),接这两个方法可以随时改变悬浮窗的位置和尺寸。配合设备的屏幕宽高,可以随意布局。
如果需要微调位置,可以用getX()和getY()获取当前坐标,然后加上位移量。这是做拖拽功能的基础。
还有个不太起眼的setAdjustEnabled(boolean),它允许悬浮窗自动避开系统状态栏和导航栏。如果你不想自己计算状态栏高度,可以考虑开启它,但实测部分机型开了之后会出现位置漂移,所以我在做精确位置控制时更倾向于手动指定坐标,把setAdjustEnabled(false)设死。
3.2 触摸、点击与手势
交互型悬浮窗最核心的是触摸事件。Autojs 的 View 对象支持click、longClick和touch事件。click写法最简单:
window.btn.click(function() { toast("点了悬浮按钮"); });但如果你需要处理拖拽,就必须挂setOnTouchListener,自己解析ACTION_DOWN、ACTION_MOVE、ACTION_UP。下面这个例子是一个可以拖动的悬浮按钮:
var startX = 0, startY = 0, windowX = 0, windowY = 0; window.btn.setOnTouchListener(function(view, event) { switch (event.getAction()) { case event.ACTION_DOWN: startX = event.getRawX(); startY = event.getRawY(); windowX = window.getX(); windowY = window.getY(); return true; case event.ACTION_MOVE: // 用移动增量更新窗口位置 window.setPosition(windowX + event.getRawX() - startX, windowY + event.getRawY() - startY); return true; case event.ACTION_UP: return true; } return false; });这里有一点很容易踩坑:event.getRawX()是手指在屏幕上的绝对坐标,getX()是相对于被触摸 View 的坐标。如果误用getX(),拖拽时窗口位置会一跳一跳,永远跟不上手指。所以拖拽计算必须用getRawX()和getRawY()。
另外,ACTION_DOWN必须返回true,意思是“我吃掉这个事件”,后续的MOVE和UP才会继续分发给你。如果返回false,事件就会走默认逻辑,你的拖拽代码不会正常执行。
3.3 控件查找与更新
在floaty.window()创建的悬浮窗里,可以通过 id 直接访问子控件。比如上面代码里window.status就是一个 Text 对象。如果你用了比较复杂的布局,包含多个控件,直接在返回对象上用点号接 id 就行。这一点类似 ui 选中的 selector,但对象类型是原生 View。
如果想动态控制其他属性,通用的方法是拿到 View 后调用它的原生方法,比如修改背景色、文字、可见性:
window.btn.getParent().setBackgroundColor(0xFFFF0000); window.menu.setVisibility(View.VISIBLE);需要注意的是,Autojs 的 JS 环境里直接写View.VISIBLE通常是可以用的,因为引擎把 Android 的 View 类暴露了。但如果你不确定,可以直接用0表示GONE,1表示VISIBLE,4表示INVISIBLE。或者干脆用扩展方法:
window.menu.setVisible(true); // Autojs 的 Visible 方法我用下来,setVisible更直观,不容易搞混。
3.4 rawWindow 的特殊性
如果你用的是floaty.rawWindow,必须记住它默认是不可触摸的。为了让文本展示区能响应点击,你得先:
var rawWin = floaty.rawWindow( <text id="info" text="状态栏" textSize="14sp" textColor="#00ff00"/> ); rawWin.setTouchable(true); rawWin.info.setClickable(true); rawWin.info.click(function() { toast("点击状态栏"); });还有一点:rawWindow不支持setSize之外的很多窗口属性(不同版本支持度不一),你最好在目标版本上多测一测。如果发现某些属性不生效,换floaty.window基本都能解决。
[≈1500字]
4. 实战:做一个可拖拽的悬浮控制面板
4.1 功能设计
悬浮窗最经典的实践,是做一个“脚本遥控器”。我给自己的自动化打卡脚本做过这样一个面板:
- 悬浮一个圆形小按钮,平时不遮挡操作;
- 单击小按钮,展开一个包含“启动任务”“暂停任务”“关闭悬浮窗”的菜单;
- 按钮可拖拽,松手后自动贴边;
- 脚本运行时,面板显示当前状态(比如“正在识别条形码”、“已暂停”);
- 脚本停止后,悬浮窗自动关闭,不残留。
这个功能足够覆盖日常 80% 的需求。下面给一个经过修剪的可用版本。
4.2 核心代码演示
// ===== 初始化悬浮窗权限 ===== if (!floaty.hasPermission()) { floaty.requestPermission(); wait(function() { return floaty.hasPermission(); }, 1000); } // ===== 创建悬浮窗 ===== var window = floaty.window( <vertical bg="#00000000" width="wrap_content" height="wrap_content"> <text id="fab" text="◎" textSize="24sp" textColor="#ffffff" bg="#ff6600" w="48dp" h="48dp" gravity="center"/> <vertical id="menu" bg="#ccffffff" w="48dp" visibility="gone"> <text id="startTask" text="启动任务" textSize="14sp" padding="6dp" gravity="center"/> <text id="stopTask" text="暂停任务" textSize="14sp" padding="6dp" gravity="center"/> <text id="closeWin" text="关闭" textSize="14sp" padding="6dp" gravity="center"/> </vertical> </vertical> ); // ===== 初始位置:屏幕右侧中间 ===== var screenW = device.width; var buttonW = 48; window.setPosition(screenW - buttonW - 20, 400); window.setSize(48 * 2, 48 * 3); // ===== 拖拽逻辑(作用于悬浮球) ===== var downX = 0, downY = 0, winX = 0, winY = 0; window.fab.setOnTouchListener(function(view, event) { switch (event.getAction()) { case event.ACTION_DOWN: downX = event.getRawX(); downY = event.getRawY(); winX = window.getX(); winY = window.getY(); return true; case event.ACTION_MOVE: window.setPosition(winX + event.getRawX() - downX, winY + event.getRawY() - downY); return true; case event.ACTION_UP: // 松手后贴到屏幕边缘 var currentX = window.getX(); var currentY = window.getY(); var newX = currentX > screenW / 2 ? screenW - buttonW - 20 : 20; window.setPosition(newX, currentY); return true; } return false; }); // ===== 点击悬浮球展开/收起菜单 ===== var menuIsOpen = false; window.fab.click(function() { if (menuIsOpen) { window.menu.setVisibility(View.GONE); menuIsOpen = false; } else { window.menu.setVisibility(View.VISIBLE); menuIsOpen = true; } }); // ===== 菜单按钮功能 ===== window.startTask.click(function() { window.fab.setText("◉"); // 改变悬浮球图标表示运行中 menuIsOpen = false; window.menu.setVisibility(View.GONE); // 这里填你自己的自动化任务逻辑 startAutoTask(); }); window.stopTask.click(function() { window.fab.setText("◎"); menuIsOpen = false; window.menu.setVisibility(View.GONE); stopAutoTask(); }); window.closeWin.click(function() { window.close(); exit(); // 结束脚本线程 }); // ===== 模拟任务函数 ===== function startAutoTask() { // 示例:循环打印日志 5 次 for (var i = 0; i < 5; i++) { log("任务执行中: " + i); sleep(1000); } window.fab.setText("◎"); } function stopAutoTask() { // 实际项目中这里要通过全局变量、线程控制等方式停止任务 toast("停止任务指令已发出"); }需要注意,上面的startAutoTask是同步函数,会阻塞事件线程。实际中你多半会用threads.start()来跑后台任务,避免悬浮窗无响应。后台任务里如果要更新悬浮窗 UI,不能直接在子线程操作 View,需要借助ui.run():
var taskThread = threads.start(function() { while (!isStoped) { // 模拟耗时操作 sleep(500); ui.run(function() { window.statusText.setText("处理中"); }); } });4.3 布局与尺寸调整技巧
我把悬浮按钮设计成 48dp,是因为这是 Android 触控目标的最小建议尺寸,太小了手指不好点。但悬浮窗的实际物理尺寸换算成像素时,不同屏幕密度(dpi)下视觉大小会差很多。Autojs 的 XML 布局里写dp,系统会自动转换,所以尽量用 dp,别裸写 px。
菜单面板我放在悬浮球正下方,因为整个悬浮窗是一个vertical,上下排列正好。菜单默认visibility="gone",这样初始状态下悬浮窗整体高度就是悬浮球的高度,拖拽时的手感也干净。展开菜单后,系统会自动重排。
这里还有个细节:菜单里的三个“按钮”我用的是<text>,而不是<button>,原因是button自带背景和边框,有时候会和悬浮窗整体透明背景不搭。用text加padding模拟文本按钮,视觉更轻量。如果你想要点击反馈,可以在text上设置textColor或加背景,也可以直接用button后自定义背景,看你的审美。
[≈1800字]
5. 常见问题与排查实录
悬浮窗这玩意,写起来简单,真到了手机上跑,往往会遇到一堆奇怪的问题。我把这几年遇到过的坑集中列出来,再附上排查思路。
5.1 悬浮窗不显示
优先级最高的是权限。不要只看 Autojs 里的权限状态,还要去系统设置里确认“显示在其他应用上层”是否真的开启。很多国产 ROM 会额外问一次“是否允许自启动”或者“允许后台弹出界面”,这些都影响悬浮窗能否显示。
如果权限状态已经是允许,但脚本跑完没看到窗口,还有一个隐蔽原因:Android 10 及以上把浮窗类型改了,floaty.window如果声明了固定窗口类型,某些系统版本下需要重新创建窗口类型为TYPE_APPLICATION_OVERLAY才能正常显示。Autojs 的现代版本基本都处理好了,但如果你是从老版本升级上来的,最好在 Android 10+ 上重新测试一下。
另外,有些场景下悬浮窗显示了,但一切换到第三方 App 就自动消失。那多半是系统省电策略把 Autojs 进程拉到后台后冻结了,去系统的电池优化设置里把 Autojs 设为“不优化”即可。
5.2 悬浮窗显示但无法点击
先检查是不是rawWindow没设置setTouchable(true)。这是最容易犯的低级错误。
如果你用的是floaty.window,点击没反应,请检查悬浮窗是否被其他窗口盖住了。尤其是某些游戏 App,自带高优先级悬浮层,可能会挡住你的悬浮窗。这种场景你可以尝试调高窗口层级,但 Autojs 的窗口类型受限,不一定能盖过游戏浮层。更通用的方案是把悬浮球做成可以拖到边缘,避开游戏的操作区域。
还有一个奇怪问题:某些国产 ROM 上,floaty.window创建的窗口会“抢占焦点”,导致悬浮窗的click事件不触发,反而是点击穿透到下面应用。这时候可以考虑关闭窗口焦点:
window.setFocusable(false);不过关掉焦点后,里面的输入框会没法打字,具体取舍看场景。
5.3 拖拽飘移、跳点
拖拽跳点通常是坐标用错了。还记得前面强调的吗?ACTION_MOVE里要计算event.getRawX() - downX作为位移增量,位置基准是window.getX()。如果你写成了event.getX(),这个值会相对于悬浮窗内部坐标,拖拽就会乱跳。
还有一种情况是悬浮窗内部有多个可拖拽的子控件,每个子控件都设置了setOnTouchListener,事件不一定能正确传到你的窗口坐标计算。我建议把拖拽事件统一挂在最外层容器或者悬浮球上,菜单按钮不参与拖拽,这样事件逻辑最简单。
5.4 悬浮窗残留、关闭不了
脚本停止后悬浮窗还在屏幕上,是两个原因造成的:一个是脚本进程没被彻底回收,另一个是窗口引用还在。你在脚本末尾一定要调用window.close()。但如果你脚本里用了exit(),有时反而会干掉整个进程,导致窗口关闭的清理代码没执行完。
我通常用两种方式来保证清理:
- 用一个全局变量保存窗口引用,在需要停止时先
window.close()再exit()。 - 在
main入口用try/finally包裹,finally里做清理。
不建议依赖系统自动回收。你永远不知道用户的手机会不会把进程冻结。
5.5 状态栏高度与全屏适配
悬浮窗定位时,window.getX()返回的是窗口左上角在屏幕坐标系中的位置,状态栏区域也计算在内。如果你把悬浮窗放到顶部(0, 0),它可能被状态栏遮住一部分。此时可以用statusBarHeight来偏移:
var statusBarHeight = 0; if (device.sdkInt >= 21) { var res = context.getResources(); var rid = res.getIdentifier("status_bar_height", "dimen", "android"); if (rid > 0) { statusBarHeight = res.getDimensionPixelSize(rid); } } window.setPosition(20, statusBarHeight + 20);这段代码在大多数 Android 设备上都能拿到准确的状态栏高度。如果你的应用是沉浸式全屏,也可以直接用activity.window.decorView什么的算,但脚本环境下没有 Activity 上下文,上面的方式更通用。
5.6 性能与耗电
悬浮窗的 UI 是实时渲染的,如果你的悬浮窗上有大量文字、高频动画,会明显增加系统渲染负担。特别是rawWindow里的文本每秒刷新很多次,电池曲线会飙升。我的经验是:
- 文本更新用
setText直接赋值,不要反复重建 View。 - 不用的时候让悬浮窗变透明或者
setVisible(false)。 - 涉及跑马灯动画的时候,用系统自带的
TextView属性实现,别自己开线程定时改位置。
实测下来,一个静态悬浮球常驻后台,耗电几乎可以忽略。只有频繁更新时才需要注意。
[≈2000字]
6. 悬浮窗在真实项目里的扩展思路
写到底,再聊点实战向的扩展。悬浮窗不只是“按钮 + 状态”这么简单,它完全可以作为整个自动化脚本的“操作面板”和“信息看板”。
比如你做快递条形码识别,Android 端用 Autojs 挂载扫码模块,识别过程中需要弹出一个悬浮窗,实时显示识别到的条形码内容、单号、是否匹配。这个时候你会发现,悬浮窗里放一个二维码/条码 ImageView 也没问题,因为floaty.window()接受任意 View。
再比如配合 Autojs 的插件机制,悬浮窗可以成为插件入口:点击悬浮球弹出菜单,每一项对应不同插件任务,比如“识别快递单号”、“自动填写表单”、“监控红包”等。你的整个自动化体系,都可以收敛到这个小小的悬浮控件里。
如果你的任务需要长时间运行,我建议在悬浮窗的菜单里集成“锁定位置”“半透明”“缩小为点”三个功能。因为悬浮窗再小也存在遮挡,做成可切换的“微小模式”,能大幅降低对正常操作的影响。
最后再说一句关于后台线程和悬浮窗的搭配:Autojs 的脚本线程既可以threads.start()开启独立线程,也可以直接写在主线程里。但悬浮窗事件回调运行在主线程(UI 线程),如果你在里面跑sleep(10000),整个悬浮窗就会冻结,也不响应点击。所以我一般把耗时逻辑全部扔进独立线程,需要刷新 UI 时用ui.run包一层。这也是悬浮窗项目里最容易出现“明明代码没错,为什么点了没反应”的根本原因之一。
从我个人的实际体验来说,悬浮窗真的算得上 Autojs 里“一旦用上就回不去”的功能。它把脚本从“后台黑盒”变成了“看得见摸得着的工具”。你如果刚开始接触,别贪多,先跑透上面那个可拖拽按钮,再往里加你自己的任务函数,慢慢就会摸清这套机制的脾气。想把这部分玩得更顺,建议多在不同品牌的真机上测一测,悬浮窗这玩意,很多问题是某个 ROM 特有的,只有真机测试才能锁死解决方案。