Android悬浮球开发实战:前台Service与WindowManager权限避坑指南 简介本资源面向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.0API 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.0API 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存进 SharedPreferencesService 启动时读出来初始化。注意存的是相对屏幕的偏移屏幕旋转后要重新计算否则球可能跑到屏幕外。简单处理是旋转时把位置重置到默认角落或者按比例换算。// 保存位置 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显示 isForegroundtrue触摸是否透传手动测试下层点击下层正常响应从那以后我每次做悬浮球都强制先跑一遍权限和窗口类型的检查再动交互代码。这个顺序反过来后面排查起来就是黑匣子日志都看不出问题在哪。希望帮到你。本文还有配套的精品资源点击获取