简介:压缩包内含四个安卓悬浮窗实例,覆盖基础悬浮窗、桌面悬浮球、悬浮歌词和浮动播放器,适合想要掌握窗口管理器、服务与自定义视图配合使用的安卓开发者,也适合有界面自定义需求的进阶学习者。项目由浅入深,既包含系统警告窗口权限申请、动态添加视图等基础操作,也涉及触摸事件处理、视频渲染、多线程同步与动态权限适配等进阶技术。全包共162个文件,以Java源码、XML布局、PNG图片为主,附带编译产物、安装包和工程配置,便于直接导入开发环境对比阅读;整体压缩后仅1.73MB,体量轻巧。已有1791人学习下载,这类示例对理解安卓权限管理、通知机制和系统窗口层级很有帮助。开发者可通过四个案例掌握悬浮窗在不同场景下的实现思路,并学习如何处理用户交互、适配不同安卓版本、避免与其他应用冲突,为进一步开发辅助工具、音乐插件或桌面应用打下扎实基础。
1. 悬浮窗源码的 4 个实例,先落在 WindowManager 这一层
悬浮窗类安卓源代码(4例)这类压缩包,在 Android Studio 里跑通第一遍往往比想象中费劲:模拟器上 addView 一个 TextView 就显示出来,换到真机却可能在 WindowManager.addView 这行直接抛异常;就算显示出来,也可能出现“看得见、点不动、拖不走”。原因在于悬浮窗不是 Activity,也不是 Dialog,它是一张插进系统窗口层级里的 View,权限、窗口类型、触摸事件三者缺一不可。常见的 4 个实例一般拆成基础悬浮球、触摸拖动、点击展开菜单、列表或进度型面板四种形态,公共地基全是 WindowManager 与 LayoutParams。这篇按我拿到实例包后的拆解顺序讲:先搞懂窗口机制与权限,再实现可拖拽、可交互的悬浮窗,最后处理真机上 TYPE 与 ROM 的兼容问题。
2. SYSTEM_ALERT_WINDOW:悬浮窗权限的申请、判断与失效场景
想让 WindowManager.addView 成功,系统会先检查三件事:App 是否持有“显示在其他应用上层”的授权、窗口 type 是否跟 targetSdk 匹配、窗口 flags 会不会造成触摸事件冲突。权限是第一位,也是国内机型上报率最高的崩溃来源。
2.1 权限判断与申请代码
以 targetSdk 31 的项目为例,manifest 里至少要有这两行:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />启动前判断授权,未授权则跳转系统设置页。代码:
fun ensureOverlayPermission(context: Context, onGranted: () -> Unit) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) { val intent = Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}") ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } else { onGranted() } }逻辑说明:ACTION_MANAGE_OVERLAY_PERMISSION 后面的 package 参数不是所有 ROM 都认。原生系统会定位到当前应用的悬浮窗开关,部分国产 ROM 会忽略它直接跳到应用列表页,用户需要手动再进一层。授权页返回后不要依赖 onActivityResult 的 resultCode,标准做法是回到前台后重新调一次 canDrawOverlays 确认。很多悬浮窗 bug 就是缓存了“上次已授权”状态导致的。
2.2 授权成功但仍然不显示的三个场景
| 场景 | 现象 | 处理 |
|---|---|---|
| type 与 targetSdk 不匹配 | addView 抛异常或窗口不显示 | API 26 以上一律用 TYPE_APPLICATION_OVERLAY |
| 服务没跑在前台 | 窗口短暂出现后消失 | Service 里调 startForeground |
| 系统后台清理 | 回桌面后再进来窗口没了 | 在 onResume 检查 canDrawOverlays 并重建 |
第一行对应 Android 8.0 把 TYPE_PHONE 标为过时的行为:targetSdk 26 以上继续用老 type,addView 直接抛 SecurityException。第三行对应国内厂商的双段权限设计:小米的“悬浮窗”和“后台弹出界面”是两套开关,华为把“悬浮窗”单独拆成管理页,微信会议悬浮窗、直播悬浮窗在这些机型上都需要用户额外打开第二道开关,这也是 Auto.js 这类工具申请权限时要反复勾选的原因。权限判断代码要写成函数,在启动、onResume、被系统回收后三个时机都调用,而不是只在 MainActivity 里调一次。
3. 实例一、二:可拖拽悬浮球的代码与点击/拖动手势区分
前两个实例通常是最基础的悬浮球:一个圆形 View 挂在屏幕上,能拖动,点击后有反馈。难点不在 addView,而在“拖动”和“点击”两个手势怎么在同一个 View 上共存。
3.1 最小悬浮球骨架
我在 Service 里持有 WindowManager 和 View,Service 作为窗口的宿主,Activity 销毁不影响悬浮球:
class FloatingBallService : Service() { private lateinit var windowManager: WindowManager private lateinit var ballView: View private lateinit var params: WindowManager.LayoutParams override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() windowManager = getSystemService(WINDOW_SERVICE) as WindowManager ballView = LayoutInflater.from(this).inflate(R.layout.view_ball, null) params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.START x = dp(16f) y = dp(240f) } windowManager.addView(ballView, params) } override fun onDestroy() { if (::ballView.isInitialized) { windowManager.removeView(ballView) } super.onDestroy() } private fun dp(value: Float): Int = (value * resources.displayMetrics.density + 0.5f).toInt() }参数说明:WRAP_CONTENT 让 View 自己决定大小;FLAG_NOT_FOCUSABLE 表示窗口不抢键盘焦点,这是悬浮球“不打扰输入法”的关键;gravity 配合 x、y 决定初始位置,用 TOP or START 比 CENTER 更容易计算坐标。注意 inflate 用的 context 是这个 Service 本身,不要用 Activity,否则 Activity 销毁后 View 还持着它的引用,内存泄漏就是这么来的。
3.2 区分“拖动”与“点击”的状态机
“手机触摸拖动悬浮球、手机点击正常展开”这个需求,正确写法是自己在触摸事件里维护状态,而不是靠 OnClickListener。完整实现:
private var downRawX = 0f private var downRawY = 0f private var downX = 0 private var downY = 0 private var isDragging = false ballView.setOnTouchListener { _, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { downRawX = event.rawX downRawY = event.rawY downX = params.x downY = params.y isDragging = false true } MotionEvent.ACTION_MOVE -> { val deltaX = event.rawX - downRawX val deltaY = event.rawY - downRawY if (!isDragging && hypot(deltaX, deltaY) > touchSlop ) { isDragging = true } if (isDragging) { params.x = downX + deltaX.toInt() params.y = downY + deltaY.toInt() windowManager.updateViewLayout(ballView, params) } true } MotionEvent.ACTION_UP -> { if (!isDragging) { togglePanel() } isDragging = false true } else -> true } }触摸事件全部分支返回 true,表示这个事件流由我在 View 内完全消费。DOWN 记录起点;MOVE 里用距离判断是否开始拖动,touchSlop 取自 ViewConfiguration.get(this).scaledTouchSlop,一般 8 到 16dp,比硬编码 10dp 更抗设备差异;UP 时如果没拖过就是点击,展开面板。
这里有一个高频误用要避开:如果给 ballView 同时设置了 setOnClickListener,那么 UP 事件返回 true 后系统仍会补发一次 performClick,导致“点击展开两次”。解决办法是不要混用 OnClickListener,所有点击逻辑都放在 UP 分支里写。拖动时每次 updateViewLayout 都会触发系统 relayout,单指拖动频率下没有性能问题,不需要自己做节流;但如果同时更新了文本内容和坐标,记得把文本更新放在坐标更新之后,避免一次 relayout 里出现半个屏幕的视觉跳变。
4. 实例三、四:展开面板与菜单栏的窗口参数和动画控制
后两个实例一般是点击悬浮球后展开的面板:菜单列表、快捷开关、进度条或音量条。实现方式有两种:在悬浮球同一个根布局里切 visibility,或者再 addView 一个独立面板窗口。我一般选后者,理由是面板宽高和悬浮球完全不同,共用一个父布局会导致触摸区域互相覆盖。
4.1 独立面板窗口的 addView 参数
面板窗口和悬浮球的 LayoutParams 有两个关键差异。一是宽高不再用 WRAP_CONTENT,而是给固定 dp,避免布局测量抖动;二是要加 FLAG_LAYOUT_NO_LIMITS,让面板可以贴近屏幕边缘而不被系统裁掉。示例:
private fun showMenuPanel() { if (::panelView.isInitialized && panelView.isAttachedToWindow) return panelView = LayoutInflater.from(this) .inflate(R.layout.view_menu_panel, null) panelParams = WindowManager.LayoutParams( dp(200f), WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.END x = dp(8f) y = dp(48f) } try { windowManager.addView(panelView, panelParams) playPanelInAnimation() } catch (e: SecurityException) { // 权限被回收,回到申请流程 } }这段代码里 isAttachedToWindow 用来防重复 addView,重复添加同一个 View 会抛 IllegalStateException,这是实例包里最常见的崩溃之一。FLAG_LAYOUT_NO_LIMITS 是把双刃剑:窗口可以延伸到状态栏底部甚至刘海区域,但内容要自己留出安全边距,我一般会给面板根布局加 android:fitsSystemWindows="false" 再手写 padding。
4.2 面板动画的时长与插值参数
面板进入动画我习惯用 ValueAnimator 同时驱动 translationY 和 alpha,而不是 AnimationSet,因为前者能拿到实时动画值做其他联动:
private fun playPanelInAnimation() { panelView.translationY = 16f * resources.displayMetrics.density panelView.alpha = 0f ValueAnimator.ofFloat(0f, 1f).apply { duration = 220 interpolator = DecelerateInterpolator() addUpdateListener { val v = animatedValue as Float panelView.translationY = 16f * resources.displayMetrics.density * (1f - v) panelView.alpha = v } start() } }duration 220 是权衡值:小于 150 在低端机有明显的顿挫感,大于 300 会让人感觉悬浮窗不跟手。插值用 DecelerateInterpolator,进入时快后慢;退出动画反过来用 AccelerateInterpolator,时长砍到 160,避免面板“拖着不消失”。注意 ValueAnimator 的更新回调里不要调 updateViewLayout,这会导致窗口每帧 relayout,面板行数多时掉帧;只改 View 的 translationY 和 alpha 让系统走硬件合成,性能开销小一个量级。面板里如果放 RecyclerView,我建议单独加一条:在窗口 show 之后等一帧再 notifyDataSetChanged,否则首帧可能出现白底闪烁。
4.3 面板与主进程的通信方式
面板里的按钮、开关要回传数据给主界面,实例包里有两种写法:用 Intent 发广播,或者持有 Service 的 Binder。广播适合低频事件,音量条这种高频更新用 Binder 更稳。我在 Service 里维护一个回调列表,面板 View 通过接口回调直接触发 Service 方法,Service 再通过 StateFlow 对外暴露状态,这样主界面订阅变化即可,不用管理跨进程生命周期。
5. Android 真机兼容:悬浮窗 5 个高频崩溃点与对应修复
实例包在作者的手机上跑得好好的,换一台设备就崩,绝大多数是版本适配问题。下面 5 个点,是我处理线上悬浮窗崩溃时修复频率最高的。
5.1 API 26 之后还在用 TYPE_PHONE
| Android 版本 | 合规模 type | 旧 type 的行为 |
|---|---|---|
| API 23 - 25 | TYPE_PHONE | 可用,需权限 |
| API 26 及以上 | TYPE_APPLICATION_OVERLAY | TYPE_PHONE 抛 SecurityException |
targetSdk 26 以上的应用继续用 TYPE_PHONE,addView 时直接抛异常,没有任何协商空间。所以源码里如果看到 window type 是从 Build.VERSION 判断后各自赋值,不要手贱改成统一值。
5.2 后台启动服务被限制
Android 8.0 之后,应用处于后台时调 startService 会抛 IllegalStateException。悬浮窗必须常驻,标准做法是在 onStartCommand 里立刻调 startForeground:
override fun onStartCommand( intent: Intent?, flags: Int, startId: Int ): Int { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForeground(NOTIFICATION_ID, buildNotification()) } return START_STICKY }如果 targetSdk 34(Android 14),前台服务还要在 manifest 里声明类型:
<service android:name=".FloatingBallService" android:foregroundServiceType="specialUse" />Android 14 上没声明类型,启动前台服务直接崩 MissingForegroundServiceTypeException。buildNotification 里的 channel 要提前创建,否则通知不显示,部分系统会连带把服务杀掉。
5.3 重复 addView 与泄漏
同一 View 被 addView 两次抛 IllegalStateException;悬浮球在 onDestroy 没 removeView,Service 销毁后窗口还挂在系统层级上,造成内存泄漏。我一般在 addView 前先判断 isAttachedToWindow,onDestroy 里 removeView 后把 View 引用置空,并包一层 try-catch,因为窗口可能已被系统移除,removeView 一个不存在的 View 会抛 IllegalArgumentException。
5.4 国产 ROM 的双段权限
部分厂商把悬浮窗权限拆成两段:第一段在“设置-应用-悬浮窗”,第二段在“后台弹出界面”或“锁屏显示”。Settings.canDrawOverlays 返回 true 只代表第一段通过。第二段被关掉的典型现象是:App 在前台时悬浮球正常,切到桌面或锁屏就消失。这个问题没有通用 API 可以判断,唯一可行方案是引导用户到厂商自启管理页,并且每次 App 回到前台时检查 canDrawOverlays 和窗口存在状态,发现窗口丢了就自动重建并弹提示。
5.5 刘海屏与窗口定位偏移
没有适配刘海屏之前,悬浮球靠近顶部时会被系统安全区域顶下来一截,看起来像“跳了一下”。正确做法是给 LayoutParams 设置 layoutInDisplayCutoutMode:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES }SHORT_EDGES 表示窗口可以延伸到刘海区域,配合 FLAG_LAYOUT_NO_LINITS 使用时,要手动把初始 y 设置到状态栏高度以下,否则悬浮球会躲进刘海凹槽里。高度获取用 resources.displayMetrics 再根据横竖屏换算,不要硬编码 24dp。
6. 用 dumpsys 验证悬浮窗,再补上边缘吸附与触摸热区
真机上出现“悬浮球不在该在的位置”“窗口没被移除”这种情况,不要反复打印日志猜,用一条命令直接看系统窗口层级:
adb shell dumpsys window windows | grep -E "Window #|FloatingBall"输出里能看到每个窗口的包名、类名、坐标和尺寸。窗口存在但坐标不对时,执行:
adb shell dumpsys window windows | grep -E "mCurrentFocus|FloatingBall" -A 5mCurrentFocus 表示当前焦点窗口,如果悬浮球挡住输入法,检查这里是不是悬浮球的 Window;如果窗口状态是“NAN”说明 addView 后没成功 relayout,回代码查 flags 配置。
验证通过后,我通常会补两个体验级细节。第一个是边缘吸附:手指松开后让悬浮球自动滚到最近的屏幕边缘。核心逻辑在 UP 分支里追加判断:
private fun settleToScreenEdge() { val screenWidth = resources.displayMetrics.widthPixels val ballCenter = params.x + ballView.width / 2 val targetX = if (ballCenter < screenWidth / 2) { 0 } else { screenWidth - ballView.width } ValueAnimator.ofInt(params.x, targetX).apply { duration = 180 interpolator = DecelerateInterpolator() addUpdateListener { params.x = animatedValue as Int windowManager.updateViewLayout(ballView, params) } start() } }注意吸附动画结束前,手指再次按下会打断动画,所以要记录当前 params.x 并在 DOWN 时 cancel 掉正在跑的 ValueAnimator,否则会出现动画和手指抢坐标的抖动。
第二个细节是触摸热区。视觉上 28dp 的小球,实际要保证 48dp 的可点击区域,做法是在根布局留透明 padding,而不是放大整个 View:
<FrameLayout android:layout_width="48dp" android:layout_height="48dp"> <ImageView android:layout_width="28dp" android:layout_height="28dp" android:layout_gravity="center" android:src="@drawable/ball_icon" /> </FrameLayout>触摸命中区域变成了 48dp,视觉上还是小球。吸附计算时使用根布局的宽度而不是 ImageView 的宽度,也就是代码里的 ballView.width,它在 inflate 后就是 48dp,这样吸附后视觉小球和屏幕边缘的距离刚好是 10dp,不会贴死。这两个细节加完后,悬浮球的跟手感和系统控件基本一致,剩下的就是不同 ROM 上把双段权限引导文案做进去。
本文还有配套的精品资源,点击获取