简介:本资源面向Android开发学习者与需要实现跨界面悬浮交互的开发者,围绕“一个服务开启悬浮球、任意界面可用”这一核心场景,提供可运行的悬浮窗服务示例工程。内容涉及Service生命周期、WindowManager添加视图、SYSTEM_ALERT_WINDOW权限申请、触摸事件处理及Android 6.0以上动态权限适配等关键知识点,适合具备一定Android基础、希望掌握悬浮窗与系统级窗口管理的开发者参考。压缩包共35个文件,约2.02MB,包含5个java源码文件、10个xml布局与配置、7个png截图素材,以及gradle构建脚本、properties配置、jar依赖和说明文档,工程结构完整,便于直接导入Android Studio运行调试。目前已有2420人学习下载。通过该工程可快速理解悬浮球从服务创建、视图添加到权限请求的完整实现链路,并借鉴其生命周期恢复与横竖屏适配思路,减少自行摸索成本。
1. 悬浮球不是"画个圆"那么简单:一个 Service 撑起全局交互
做过 Android 悬浮窗的人多半有过这种体验:在自己 App 里显示得好好的悬浮球,切到桌面或者别的应用就消失了,或者干脆被系统拦下来根本弹不出来。问题往往不在绘制,而在权限模型和窗口层级没搞对。这份资源给的是一个最小可跑的悬浮球实现,核心思路是用一个前台 Service 持有 WindowManager,把悬浮球挂到 TYPE_APPLICATION_OVERLAY 类型的窗口上,这样不管当前前台是哪个应用,悬浮球都能浮在最上层。它解决的是"跨界面常驻交互入口"这个需求——比如快捷返回、录屏按钮、辅助操作面板。适合已经会写 Activity 和 Service、但对 WindowManager 和悬浮窗权限还不熟的 Android 开发者,新手照着能跑通,熟手能直接拿去改交互逻辑。
2. 权限与窗口类型:悬浮球能不能弹出来的第一道关
2.1 为什么 Android 6.0 之后必须动态申请悬浮窗权限
早期版本里,应用只要在清单里声明 SYSTEM_ALERT_WINDOW 就能直接弹悬浮窗,用户装完就有。从 Android 6.0(API 23)开始,这个权限变成了特殊权限,不在运行时权限那一套里,得单独引导用户去系统设置页手动开。判断有没有开,用Settings.canDrawOverlays();没开就跳ACTION_MANAGE_OVERLAY_PERMISSION对应的设置页。这里有个容易翻车的点:不同厂商 ROM 对这个设置页的入口处理不一致,有的直接跳到应用详情,有的跳到全局悬浮窗管理列表,所以跳转时最好带上包名 Uri,让系统尽量定位到当前应用。
// 检查并申请悬浮窗权限 if (!Settings.canDrawOverlays(this)) { Intent intent = new Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName()) // 带上包名,尽量定位到本应用 ); startActivityForResult(intent, REQUEST_OVERLAY_CODE); }逻辑说明:canDrawOverlays返回 false 说明当前没有权限,必须走设置页。参数package:后面拼包名,是为了让部分 ROM 直接跳到本应用的授权开关,而不是让用户在一堆应用里翻。注意这个 Intent 在个别定制系统上可能没有对应 Activity,稳妥做法是加 try-catch,失败时退回到应用详情页。
2.2 TYPE_APPLICATION_OVERLAY 与老类型的区别
Android 8.0(API 26)引入了TYPE_APPLICATION_OVERLAY,专门给应用层悬浮窗用。在这之前大家用的是TYPE_PHONE、TYPE_SYSTEM_ALERT这些,它们层级更高、更"系统",但新版本上如果还用老类型,要么被降级处理,要么直接抛异常。所以正确的做法是按版本分支:API 26 及以上用TYPE_APPLICATION_OVERLAY,以下用TYPE_PHONE。这个判断不写,在新机型上大概率弹不出来,而且报错信息不一定直观,属于典型的"玄学"翻车点。
int layoutType; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { layoutType = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } else { layoutType = WindowManager.LayoutParams.TYPE_PHONE; } WindowManager.LayoutParams params = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, layoutType, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, // 不抢焦点,否则会挡住下层输入 PixelFormat.TRANSLUCENT );参数说明:宽高用 WRAP_CONTENT 让悬浮球按自身大小显示;FLAG_NOT_FOCUSABLE是关键,加了它悬浮球不抢输入焦点,下层应用还能正常操作,否则悬浮球会变成一个"黑洞"把点击都吃掉。PixelFormat.TRANSLUCENT保证背景透明,圆形悬浮球边缘才不会带黑底。
2.3 前台 Service 与通知渠道的绑定
悬浮球要长期存活,Service 就得是前台服务,否则系统在内存紧张时优先干掉它。Android 8.0 之后前台服务必须配一个通知渠道,否则startForeground会直接抛异常。渠道的创建要在startForeground之前完成,ID 和通知要对应上。
// 创建通知渠道(API 26+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( CHANNEL_ID, "悬浮球服务", NotificationManager.IMPORTANCE_LOW // 低重要性,不打扰用户 ); NotificationManager manager = getSystemService(NotificationManager.class); manager.createNotificationChannel(channel); } Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("悬浮球运行中") .setSmallIcon(R.drawable.ic_ball) .build(); startForeground(NOTIFICATION_ID, notification);逻辑说明:渠道重要性设成 LOW,通知不会响铃震动,但服务能正常前台化。NOTIFICATION_ID和CHANNEL_ID用常量管理,避免多次启动时重复创建。这一步不做,在 Android 8 以上会直接崩,日志里是Bad notification for startForeground,新手容易卡在这。
3. 悬浮球的触摸拖动与点击判定:别让手指白动
3.1 onTouch 里区分拖动和点击
悬浮球要能拖,又要能点,这两个动作都在onTouch里处理。核心是记录按下时的坐标和移动距离,ACTION_UP时如果移动距离小于阈值就判定为点击,否则算拖动结束。阈值一般取 ViewConfiguration 里的 touchSlop,或者直接写死 10dp 左右。不区分的话,用户想点一下结果球被拖走了,体验很差。
ballView.setOnTouchListener(new View.OnTouchListener() { private int startX, startY; private float downRawX, downRawY; private boolean isDrag; @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: startX = params.x; startY = params.y; downRawX = event.getRawX(); downRawY = event.getRawY(); isDrag = false; return true; case MotionEvent.ACTION_MOVE: int dx = (int) (event.getRawX() - downRawX); int dy = (int) (event.getRawY() - downRawY); if (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop) { isDrag = true; } params.x = startX + dx; params.y = startY + dy; windowManager.updateViewLayout(ballView, params); // 实时更新位置 return true; case MotionEvent.ACTION_UP: if (!isDrag) { // 判定为点击,执行交互 performClickAction(); } return true; } return false; } });逻辑说明:params.x/y是悬浮窗相对屏幕的偏移,event.getRawX()拿的是相对屏幕的坐标,两者配合才能算出正确的位移。updateViewLayout每次移动都要调,否则位置不刷新。touchSlop从ViewConfiguration.get(context).getScaledTouchSlop()取,保证和系统手感一致。点击动作放在ACTION_UP且isDrag为 false 时触发,这样拖动过程中不会误触。
3.2 贴边吸附与半隐藏
很多悬浮球拖到边缘会自动吸过去,松手后只露一半,点一下再展开。实现思路是在ACTION_UP里判断params.x离左右边缘的距离,取小的那边,把params.x设成负的半个球宽或者屏幕宽减半个球宽。半隐藏状态用一个布尔值标记,点击时切换。这个逻辑不复杂,但边界判断要小心:屏幕旋转、分屏模式下宽度会变,最好动态取windowManager.getDefaultDisplay()的当前宽度,而不是写死。
// 松手后贴边 int screenWidth = windowManager.getDefaultDisplay().getWidth(); if (params.x < screenWidth / 2) { params.x = -ballWidth / 2; // 吸左边,露一半 } else { params.x = screenWidth - ballWidth / 2; // 吸右边 } windowManager.updateViewLayout(ballView, params);参数说明:ballWidth是悬浮球实际测量宽度,可以在onWindowFocusChanged或者post里拿。半隐藏时点击展开,把params.x设回 0 或screenWidth - ballWidth,再updateViewLayout。注意半隐藏状态下悬浮球仍然占着窗口区域,如果FLAG_NOT_FOCUSABLE没加,边缘区域会挡住下层点击。
3.3 多指触摸与事件冲突
悬浮球窗口如果加了FLAG_NOT_TOUCH_MODAL,窗口外的触摸事件会传给下层,但窗口内的多指操作还是归悬浮球。实际场景里,用户可能一边拖球一边操作下层应用,这时候如果悬浮球把多指事件都吃了,下层就收不到。常见做法是只处理单指,event.getPointerCount() > 1时直接返回 false 让事件透传。这个细节不做,在游戏或者地图类应用上会明显感觉"球一碰,下面就不灵了"。
4. 避坑与排查:悬浮球不显示、不跟手、被杀怎么办
4.1 悬浮球完全不显示
现象:代码跑完没报错,但屏幕上什么都没有。原因通常是权限没给或者窗口类型不对。先确认Settings.canDrawOverlays返回 true,再检查layoutType是否按版本分支。如果用的是老类型TYPE_PHONE在 Android 8 以上,系统会静默忽略,不报错也不显示。解决:按 2.2 的分支改类型,并在addView前打日志确认权限状态。
4.2 悬浮球能显示但拖不动
现象:球出来了,手指按上去没反应。原因多半是FLAG_NOT_FOCUSABLE之外还加了FLAG_NOT_TOUCHABLE,或者onTouch返回了 false。检查params.flags里有没有误加FLAG_NOT_TOUCHABLE,它会让整个窗口不接收触摸。另外onTouch的ACTION_DOWN必须返回 true,否则后续 MOVE、UP 都不会回调。解决:去掉FLAG_NOT_TOUCHABLE,确保 DOWN 分支 return true。
4.3 切到某些应用后悬浮球消失
现象:在桌面和其他应用上正常,一切到某个特定应用球就没了。原因是那个应用可能设置了更高的窗口层级,或者本身是系统级全屏应用,把悬浮窗盖住了。另外部分 ROM 有"悬浮窗管理"白名单,没加进去的应用切前台时会被临时隐藏。解决:确认悬浮球窗口类型是TYPE_APPLICATION_OVERLAY,引导用户把本应用加入 ROM 的悬浮窗白名单,这个没有代码层面的万能解,只能做引导。
4.4 服务跑一段时间被系统杀掉
现象:用着用着球突然没了,通知也消失。原因是前台服务虽然优先级高,但在极端内存压力下仍可能被回收,尤其是没有做onStartCommand返回START_STICKY的情况下。解决:onStartCommand返回START_STICKY,让系统在资源恢复后尝试重建服务;同时在onDestroy里做好清理,避免重复addView导致IllegalStateException。另外不要在主线程做耗时操作,ANR 也会导致服务被杀。
4.5 重复启动导致多个悬浮球
现象:多次点击启动按钮,屏幕上出现好几个球。原因是每次startService都走了一遍addView,而WindowManager不允许同一个 View 被添加两次,但如果是不同 View 实例就会叠在一起。解决:在 Service 里用一个静态布尔值或者检查ballView.getParent()来判断是否已经添加,已存在就先removeView再重新加,或者直接 return。更稳妥的做法是把启动入口收敛到一个地方,用单例管理悬浮球状态。
5. 进阶:把悬浮球做成可配置的交互入口
5.1 用策略模式替换写死的点击动作
原始实现里点击动作是写死的,改起来要动 Service 代码。实际项目里悬浮球往往要支持多种动作:返回、截图、录屏、打开面板。可以用一个接口把动作抽出来,Service 只负责调用,具体行为由外部注入。这样换功能不用改悬浮球核心逻辑。
public interface BallAction { void onBallClick(); // 单击 void onBallLongClick(); // 长按 } // Service 里持有 private BallAction ballAction; public void setBallAction(BallAction action) { this.ballAction = action; } // onTouch 的 UP 分支里 if (!isDrag) { if (ballAction != null) ballAction.onBallClick(); }逻辑说明:BallAction把行为从视图层解耦,Service 只关心触摸判定,具体做什么由调用方决定。长按可以在ACTION_DOWN里用postDelayed实现,ACTION_UP或ACTION_MOVE超过阈值时取消。参数上注意长按阈值一般取ViewConfiguration.getLongPressTimeout(),和系统一致。
5.2 悬浮球位置持久化
用户把球拖到某个位置,下次启动应该还在那。做法是在ACTION_UP里把params.x/y存进 SharedPreferences,Service 启动时读出来初始化。注意存的是相对屏幕的偏移,屏幕旋转后要重新计算,否则球可能跑到屏幕外。简单处理是旋转时把位置重置到默认角落,或者按比例换算。
// 保存位置 getSharedPreferences("ball", MODE_PRIVATE).edit() .putInt("x", params.x) .putInt("y", params.y) .apply(); // 读取位置 SharedPreferences sp = getSharedPreferences("ball", MODE_PRIVATE); params.x = sp.getInt("x", 0); params.y = sp.getInt("y", 200);参数说明:默认 y 给 200 是让球初始不在最顶部,避免和状态栏重叠。存的时候用apply()异步写,不阻塞触摸回调。读取后要判断是否超出当前屏幕范围,超出就 clamp 回边界内。
5.3 验证清单与调试习惯
改完悬浮球,我一般按这个顺序过一遍:先确认权限状态和窗口类型,再看addView有没有抛异常,然后测拖动和点击的阈值,最后切三到五个不同应用验证常驻效果。调试时用adb shell dumpsys window windows | grep -i overlay能看到当前有哪些 overlay 窗口,确认自己的窗口在不在列表里。另外adb shell settings get secure enabled_accessibility_services虽然和悬浮窗无关,但排查辅助类功能时能确认服务有没有被系统启用。
| 检查项 | 命令/方法 | 预期结果 |
|---|---|---|
| 悬浮窗权限 | Settings.canDrawOverlays | 返回 true |
| 窗口类型 | 代码分支判断 | API 26+ 用 OVERLAY |
| 窗口是否添加 | dumpsys window | 列表里有本应用包名 |
| 服务是否前台 | dumpsys activity services | 显示 isForeground=true |
| 触摸是否透传 | 手动测试下层点击 | 下层正常响应 |
从那以后我每次做悬浮球,都强制先跑一遍权限和窗口类型的检查,再动交互代码。这个顺序反过来,后面排查起来就是黑匣子,日志都看不出问题在哪。希望帮到你。
本文还有配套的精品资源,点击获取