Android悬浮球开发实战:Service+WindowManager实现全局悬浮窗 简介本资源面向Android开发学习者与需要实现跨界面悬浮交互的开发者围绕“一个服务开启悬浮球、任意界面可用”这一核心场景提供可运行的悬浮窗服务示例工程。内容涉及Service生命周期、WindowManager添加视图、SYSTEM_ALERT_WINDOW权限申请、触摸事件处理及Android 6.0以上动态权限适配等关键知识点适合具备一定Android基础、希望掌握悬浮窗与系统级窗口技术的开发者参考。压缩包共35个文件约2.02MB包含10个xml布局与配置、5个java源码、7个png截图、3个gradle构建脚本及properties、jar、bat等辅助文件工程结构完整便于直接导入Android Studio运行调试。目前已有2420人学习下载。通过该工程可直观理解悬浮球从服务创建、视图添加到权限请求的完整链路并借鉴其生命周期恢复与横竖屏适配思路快速应用到快捷操作、辅助工具等实际项目中。1. 悬浮球为什么必须跑在 Service 里而不是 Activity 里做过 Android 悬浮球的人大概率都经历过一个反直觉的瞬间你在 Activity 里addView加了个圆形按钮切到桌面它还在你以为成了结果一按 Home 键回到桌面球没了。原因不玄学——Activity 一旦onPause或onStop它所属的 Window 就不再是前台可见窗口系统不会让一个已经不可见的窗口继续往屏幕上画东西。悬浮球要「任何界面都能用」本质是要求这个 View 挂在一个不随页面切换而销毁的窗口上而 Android 里唯一能长期持有这种窗口的组件就是 Service。标题里的「一个服务开启悬浮球任何界面都可以使用」拆开看是三个技术点Service 提供生命周期容器、WindowManager 负责把 View 挂到系统窗口层、悬浮窗权限负责拿到跨应用绘制的入场券。这三者缺一不可而且顺序不能乱——没有权限addView直接抛BadTokenException没有 ServiceView 活不过一个页面没有正确的 WindowManager.LayoutParams球要么点不动要么被状态栏盖住。适合读这篇的人已经会写 Activity、想做一个全局悬浮入口的 Android 开发者或者被「球一退出就消失」「球挡住了输入法」这类问题卡住的同行。2. 从零搭一个能跨界面存活的悬浮球服务2.1 权限声明与运行时申请先把入场券拿到手悬浮窗权限在 Android 6.0 之后是危险权限但它的申请方式和相机、定位不一样——不能用requestPermissions必须跳系统设置页让用户手动开。常见做法是先用Settings.canDrawOverlays判断没开就startActivity到ACTION_MANAGE_OVERLAY_PERMISSION。这里有个血泪经验不要在主 Activity 的onCreate里直接跳用户还没理解你要干嘛就被弹到设置页通过率极低。我一般会在用户点击「开启悬浮球」按钮时再申请。!-- AndroidManifest.xml -- uses-permission android:nameandroid.permission.SYSTEM_ALERT_WINDOW / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / service android:name.FloatBallService android:exportedfalse /// 检查并申请悬浮窗权限 fun checkOverlayPermission(context: Context): Boolean { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { Settings.canDrawOverlays(context) } else true // 6.0 以下默认有权限 } fun requestOverlayPermission(activity: Activity, requestCode: Int) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { val intent Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse(package:${activity.packageName}) ) activity.startActivityForResult(intent, requestCode) } }逻辑说明canDrawOverlays是唯一可靠的权限判断入口别用checkSelfPermission它对SYSTEM_ALERT_WINDOW返回的结果不可信。Uri.parse(package:...)带上包名系统设置页会直接定位到本应用用户少点两下。参数上requestCode自己定回调里再查一次canDrawOverlays确认结果因为用户可能点了返回但没真正开启。2.2 用 WindowManager 把球挂上去LayoutParams 才是核心Service 里拿WindowManager用getSystemService然后构造LayoutParams。这里参数选错后面全是坑。type在 Android 8.0 之后必须用TYPE_APPLICATION_OVERLAY用老的TYPE_PHONE在 8.0 会直接崩。flags决定球的行为要能拖动就得加FLAG_NOT_FOCUSABLE否则球会抢焦点导致输入法弹不出来要跨界面显示就得配合PixelFormat.TRANSLUCENT。class FloatBallService : Service() { private lateinit var windowManager: WindowManager private lateinit var ballView: View private lateinit var params: WindowManager.LayoutParams override fun onCreate() { super.onCreate() windowManager getSystemService(WINDOW_SERVICE) as WindowManager ballView LayoutInflater.from(this).inflate(R.layout.float_ball, null) params WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY else WindowManager.LayoutParams.TYPE_PHONE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, PixelFormat.TRANSLUCENT ).apply { gravity Gravity.TOP or Gravity.START x 100 y 300 } windowManager.addView(ballView, params) setupTouchListener() } override fun onBind(intent: Intent?): IBinder? null }逻辑说明FLAG_NOT_FOCUSABLE是悬浮球的命门加上它球才不会拦截按键和输入事件FLAG_LAYOUT_NO_LIMITS允许球拖到屏幕边缘外一部分手感更顺。gravity用TOP or START配合x、y定位比用CENTER再算偏移直观得多。参数x、y是相对 gravity 的偏移量拖动时更新这两个值再调updateViewLayout即可。2.3 拖动与点击的冲突处理别让球「粘手」悬浮球既要能拖又要能点直接给OnTouchListener返回true会把点击事件吃掉。常见做法是在ACTION_DOWN记录初始坐标和时间ACTION_MOVE里判断移动距离超过阈值才认为是拖动否则在ACTION_UP里当作点击处理。阈值一般取ViewConfiguration.getScaledTouchSlop()不同屏幕密度下都合理。private fun setupTouchListener() { val touchSlop ViewConfiguration.get(this).scaledTouchSlop var initialX 0 var initialY 0 var initialTouchX 0f var initialTouchY 0f var isDragging false ballView.setOnTouchListener { _, event - when (event.action) { MotionEvent.ACTION_DOWN - { initialX params.x initialY params.y initialTouchX event.rawX initialTouchY event.rawY isDragging false true } MotionEvent.ACTION_MOVE - { val dx event.rawX - initialTouchX val dy event.rawY - initialTouchY if (abs(dx) touchSlop || abs(dy) touchSlop) { isDragging true params.x initialX dx.toInt() params.y initialY dy.toInt() windowManager.updateViewLayout(ballView, params) } true } MotionEvent.ACTION_UP - { if (!isDragging) { // 触发点击逻辑比如展开菜单 onBallClick() } true } else - false } } }逻辑说明用rawX、rawY而不是x、y因为后者是相对球自身的坐标拖动时会跟着变算出来的位移是错的。isDragging标志位保证拖动结束后不会误触发点击。参数touchSlop由系统按密度换算别自己写死 10 像素在高分屏上会灵敏到没法用。3. 前台服务与保活让球在后台不被系统回收3.1 为什么必须升级为前台服务Android 8.0 之后后台 Service 随时可能被系统限制普通startService启动的悬浮球服务在应用退到后台几分钟后就会被杀。要让球稳定存活必须用startForegroundService并在 5 秒内调用startForeground绑定一个通知。这不是可选项是硬性要求。通知渠道在 8.0 必须显式创建否则startForeground会失败。override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channel NotificationChannel( float_ball_channel, 悬浮球服务, NotificationManager.IMPORTANCE_LOW ) val manager getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } val notification NotificationCompat.Builder(this, float_ball_channel) .setContentTitle(悬浮球运行中) .setSmallIcon(R.drawable.ic_ball) .setPriority(NotificationCompat.PRIORITY_LOW) .build() startForeground(1001, notification) return START_STICKY }逻辑说明IMPORTANCE_LOW让通知不响不震用户不会反感。START_STICKY让服务被系统杀掉后尝试重建但别指望它百分百生效国产 ROM 上还需要引导用户加白名单。通知 ID1001自己定只要不和别的通知冲突即可。3.2 国产 ROM 的保活差异与应对不同厂商 ROM 对后台服务的限制策略差异很大这是做悬浮球最头疼的部分。常见现象是在原生或类原生系统上跑得好好的到了某些定制 ROM 上锁屏几分钟后球就没了。应对方式分两层代码层用前台服务加START_STICKY兜底产品层引导用户手动开启「自启动」和「后台运行」权限。别试图用双进程互拉、JobScheduler 轮询这些手段新版本系统基本都堵死了投入产出比极低。系统类型后台限制强度推荐应对类原生低前台服务即可主流定制 ROM中前台服务 引导加白名单激进定制 ROM高前台服务 白名单 用户手动锁定后台4. 避坑与排查悬浮球最容易翻车的五个地方4.1 球显示出来了但点不动现象球能看见手指按上去没反应或者点击事件传到了下面的应用。原因通常是LayoutParams里加了FLAG_NOT_TOUCHABLE或者FLAG_NOT_FOCUSABLE没加导致焦点被球抢走。解决检查 flags确保没有FLAG_NOT_TOUCHABLE同时保留FLAG_NOT_FOCUSABLE。如果球区域内有子 View确认子 View 没有把触摸事件消费掉。4.2 一加 View 就抛 BadTokenException现象windowManager.addView直接崩日志里是Unable to add window -- token null is not valid。原因有两个一是没拿到悬浮窗权限二是type用了TYPE_APPLICATION_OVERLAY但权限没开。解决addView前先查Settings.canDrawOverlays没权限就引导申请别硬加。另外确认type和系统版本匹配8.0 以下用TYPE_PHONE8.0 及以上用TYPE_APPLICATION_OVERLAY。4.3 球挡住了输入法或状态栏现象球拖到屏幕底部时输入法弹出来被球挡住或者球跑到状态栏上面去了。原因FLAG_LAYOUT_NO_LIMITS让球可以超出屏幕边界加上y坐标没做边界限制。解决在ACTION_MOVE里对params.y做 clamp上限取screenHeight - statusBarHeight - ballHeight下限取 0。状态栏高度通过resources.getIdentifier拿别写死。4.4 服务被杀后球消失但通知还在现象通知栏还挂着「悬浮球运行中」但球已经不见了。原因服务被系统回收onDestroy里没有清理 View或者START_STICKY重建后没有重新addView。解决在onDestroy里调windowManager.removeView重建时在onCreate里重新走一遍addView流程。注意removeView前判断 View 是否还 attached否则会抛IllegalArgumentException。4.5 拖动时球闪烁或跳动现象手指拖动球球的位置和手指对不上或者一抖一抖的。原因用了event.x、event.y而不是rawX、rawY或者每次ACTION_MOVE都重新计算初始位置。解决统一用rawX、rawY初始位置只在ACTION_DOWN记录一次ACTION_MOVE里用「初始位置 总位移」的方式更新不要累加增量。5. 进阶技巧让悬浮球从「能用」到「好用」5.1 吸附边缘与半隐藏球拖到屏幕边缘时自动吸附是提升手感的常用技巧。实现方式是在ACTION_UP里判断params.x离左边缘近还是右边缘近然后用ValueAnimator把x动画到 0 或screenWidth - ballWidth。半隐藏则是在吸附后把球的一部分移出屏幕只留一条边点击时再滑出来。注意半隐藏状态下球的可见区域要保证至少 8dp否则用户找不到它。private fun snapToEdge() { val screenWidth resources.displayMetrics.widthPixels val ballWidth ballView.width val targetX if (params.x ballWidth / 2 screenWidth / 2) { -ballWidth / 3 // 半隐藏到左边 } else { screenWidth - ballWidth * 2 / 3 // 半隐藏到右边 } ValueAnimator.ofInt(params.x, targetX).apply { duration 200 addUpdateListener { params.x it.animatedValue as Int windowManager.updateViewLayout(ballView, params) } start() } }逻辑说明targetX的计算让球保留三分之一在屏幕内既不明显挡内容又能被用户找到。动画时长 200ms 是手感比较顺的区间太短显得生硬太长显得拖沓。参数ballWidth要在addView之后取之前取是 0。5.2 用 SharedPreferences 记住球的位置用户把球拖到某个位置下次启动服务时应该还在那。做法是在ACTION_UP里把params.x、params.y存进SharedPreferencesonCreate里读出来初始化。注意存的时候要判断是不是吸附状态吸附后的位置才是用户期望的最终位置。private fun savePosition() { getSharedPreferences(float_ball, MODE_PRIVATE) .edit() .putInt(pos_x, params.x) .putInt(pos_y, params.y) .apply() } private fun restorePosition(): PairInt, Int { val sp getSharedPreferences(float_ball, MODE_PRIVATE) return sp.getInt(pos_x, 100) to sp.getInt(pos_y, 300) }逻辑说明默认值给(100, 300)是避免第一次启动球贴在左上角不好拖。apply()异步写入不阻塞主线程。读取时如果屏幕旋转过存的坐标可能超出新屏幕范围需要在restorePosition后做一次 clamp。5.3 验证悬浮球是否真的跨界面可用写完别只在桌面试。完整的验证路径是打开应用启动服务 → 回到桌面看球在不在 → 打开任意第三方应用看球在不在 → 下拉状态栏看球在不在 → 弹出输入法看球有没有挡住 → 锁屏再解锁看球还在不在 → 进设置页看球在不在。每一步都过了才算「任何界面都可以使用」。我自己的习惯是拿一个全屏视频应用和一个输入法密集的聊天应用做对照测试这两个场景最容易暴露FLAG_NOT_FOCUSABLE和边界 clamp 的问题。最后说个教训早期我做悬浮球时总想着把保活做到极致加了一堆互相唤醒的逻辑结果在新系统上全被限制反而因为逻辑复杂引入了更多崩溃。后来想明白了悬浮球的核心价值是「随时能点到」不是「永远不死」。把前台服务、权限申请、边界处理这三件事做扎实比堆保活手段有用得多。希望帮到你。本文还有配套的精品资源点击获取