简介:本资源面向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;没有 Service,View 活不过一个页面;没有正确的 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:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <service android:name=".FloatBallService" android:exported="false" />// 检查并申请悬浮窗权限 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_PHONE,8.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存进SharedPreferences,onCreate里读出来初始化。注意存的时候要判断是不是吸附状态,吸附后的位置才是用户期望的最终位置。
private fun savePosition() { getSharedPreferences("float_ball", MODE_PRIVATE) .edit() .putInt("pos_x", params.x) .putInt("pos_y", params.y) .apply() } private fun restorePosition(): Pair<Int, 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 的问题。
最后说个教训:早期我做悬浮球时总想着把保活做到极致,加了一堆互相唤醒的逻辑,结果在新系统上全被限制,反而因为逻辑复杂引入了更多崩溃。后来想明白了,悬浮球的核心价值是「随时能点到」,不是「永远不死」。把前台服务、权限申请、边界处理这三件事做扎实,比堆保活手段有用得多。希望帮到你。
本文还有配套的精品资源,点击获取