news 2026/9/13 7:20:39

Android悬浮窗开发:从权限到WindowManager实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android悬浮窗开发:从权限到WindowManager实战

简介:压缩包内含四个安卓悬浮窗实例,覆盖基础悬浮窗、桌面悬浮球、悬浮歌词和浮动播放器,适合想要掌握窗口管理器、服务与自定义视图配合使用的安卓开发者,也适合有界面自定义需求的进阶学习者。项目由浅入深,既包含系统警告窗口权限申请、动态添加视图等基础操作,也涉及触摸事件处理、视频渲染、多线程同步与动态权限适配等进阶技术。全包共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 - 25TYPE_PHONE可用,需权限
API 26 及以上TYPE_APPLICATION_OVERLAYTYPE_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 5

mCurrentFocus 表示当前焦点窗口,如果悬浮球挡住输入法,检查这里是不是悬浮球的 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 上把双段权限引导文案做进去。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 7:19:58

AI知识生成与活字印刷:模块化思维的千年对话

1. 项目背景与核心价值 当活字印刷术的模块化思维遇上AI知识生成技术&#xff0c;一场跨越千年的信息革命对话正在我们眼前展开。毕昇在北宋时期发明的活字印刷&#xff0c;通过可移动的单个字模实现了印刷效率的质的飞跃&#xff0c;这种模块化、可复用的思想&#xff0c;与当…

作者头像 李华
网站建设 2026/9/13 7:19:53

双指针算法实现回文串验证与优化技巧

1. 问题背景与核心需求 回文串验证是算法面试中的经典问题&#xff0c;LeetCode第125题要求我们判断给定字符串是否为回文。所谓回文串&#xff0c;是指正读和反读都相同的字符串&#xff0c;忽略大小写和非字母数字字符。例如"A man, a plan, a canal: Panama"就是一…

作者头像 李华
网站建设 2026/9/13 7:17:01

3DT-STAP:雷达空时联合处理的张量降维实践

简介&#xff1a;本资源是一份面向雷达信号处理与自适应滤波方向的科研学习材料&#xff0c;聚焦3DT&#xff08;三维变换&#xff09;算法在STAP&#xff08;空间-时间自适应处理&#xff09;降维中的实现与对比分析&#xff0c;适用于通信、雷达系统方向的研究生、工程师及高…

作者头像 李华
网站建设 2026/9/13 7:15:43

Mermaid Live Editor实战:用代码画流程图、ER图与文档协作指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 7:15:37

Bun vs Node.js:JavaScript运行时性能重构与工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 7:12:56

世界观的裂缝与勇者的起步:序章设计方法论

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华