简介:这是一份面向Android开发者的悬浮球功能实现源码,聚焦于通过一个后台服务让悬浮按钮在任意应用界面之上显示并交互,适合需要掌握悬浮窗、WindowManager与系统级权限处理的中级开发者参考。压缩包共35个文件,约2.02MB,以10个xml布局与配置、5个java核心代码、7个png截图素材为主,另含gradle构建脚本、properties配置、jar依赖及说明文档,工程结构完整可直接导入Android Studio运行。内容围绕FloatingViewService服务类展开,涵盖SYSTEM_ALERT_WINDOW权限声明与Android 6.0动态申请、TYPE_PHONE窗口参数设置、addView添加视图、触摸与点击事件绑定、START_STICKY保活及横竖屏切换重布局等关键环节,并附有运行截图便于对照效果。目前已有2420人学习下载,读者可据此快速搭建悬浮球原型,理解跨界面悬浮窗的完整实现链路与常见排错思路。
1. 一个悬浮球,凭什么能在任意界面之上活下来
做过 Android 悬浮窗的开发者大概都经历过这样的场景:在 Activity 里调WindowManager.addView一切正常,切到别的应用或者回到桌面,悬浮球直接消失。原因不复杂——你把它挂在了当前 Activity 的 Window 上,Activity 一销毁,View 自然跟着走。这个项目解决的就是这个问题:用 Service 承载悬浮球,让它脱离 Activity 生命周期,在任意界面之上存活。
项目定位很明确,就是一个悬浮球功能模块,适合两类人:一是需要在应用里加全局悬浮入口的开发者,比如快捷操作、录屏控制、辅助工具;二是想搞明白 WindowManager 与 Service 配合方式的 Android 学习者。技术栈是纯原生 Android,Java 语言,核心依赖只有WindowManager、Service和Settings.canDrawOverlays权限判断,没有引入任何第三方库。换句话说,你拿到源码,不用配一堆 Gradle 依赖就能跑。
2. 架构拆解:Service + WindowManager 是怎么把球挂到系统层
2.1 为什么是 Service 而不是 Activity
悬浮球要跨界面存在,就必须挂在一个不随界面销毁的载体上。Activity 显然不行,它的生命周期跟着用户操作走。Service 是 Android 里少数能在后台长期存活、又不依赖界面的组件,所以用 Service 来持有 WindowManager 和悬浮球 View 是最自然的选择。
但这里有个容易翻车的点:Service 本身也分启动方式和前台/后台状态。从 Android 8.0 开始,后台 Service 会被系统限制,如果你只是startService然后退到后台,Service 可能被回收,悬浮球就没了。常见做法是把 Service 提升为前台 Service,挂一个常驻通知,这样系统不会轻易杀它。项目里如果没做前台 Service,在低版本上跑没问题,高版本上就要注意。
// 在 Service 的 onCreate 中启动前台通知 @Override public void onCreate() { super.onCreate(); // 创建通知渠道(Android 8.0+ 必须) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( "float_ball_channel", "悬浮球服务", NotificationManager.IMPORTANCE_LOW ); NotificationManager manager = getSystemService(NotificationManager.class); manager.createNotificationChannel(channel); } // 构建常驻通知 Notification notification = new NotificationCompat.Builder(this, "float_ball_channel") .setContentTitle("悬浮球运行中") .setSmallIcon(R.drawable.ic_float) .build(); // 提升为前台服务,id 固定为 1 startForeground(1, notification); }这段代码的关键在startForeground,它把 Service 绑定到一个用户可见的通知上,系统会把它当作前台进程对待,回收优先级大大降低。通知渠道的IMPORTANCE_LOW是为了不打扰用户,悬浮球这种工具不需要响铃或震动。startForeground的第二个参数是通知对象,第一个参数是通知 ID,后续更新通知时用同一个 ID 即可。
2.2 WindowManager 的 LayoutParams 怎么配
悬浮球本质上是一个通过 WindowManager 添加到系统窗口的 View。要让它在任意界面之上显示,LayoutParams的type必须选对。Android 各版本对窗口类型的要求不一样,这是最容易踩坑的地方。
// 根据版本选择窗口类型 int layoutType; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Android 8.0 及以上使用应用覆盖窗口 layoutType = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } else { // 8.0 以下使用系统警告窗口 layoutType = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT; } WindowManager.LayoutParams params = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, layoutType, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, PixelFormat.TRANSLUCENT ); params.gravity = Gravity.TOP | Gravity.START; params.x = 100; // 初始 X 坐标 params.y = 200; // 初始 Y 坐标TYPE_APPLICATION_OVERLAY是 Android 8.0 引入的,专门给应用覆盖窗口用,比旧的TYPE_SYSTEM_ALERT更安全也更规范。FLAG_NOT_FOCUSABLE表示悬浮球不抢焦点,这样用户点击悬浮球之外的区域时,底层应用还能正常响应。FLAG_LAYOUT_NO_LIMITS允许 View 超出屏幕边界,拖拽时不会因为贴边而被裁剪。PixelFormat.TRANSLUCENT保证背景透明,否则悬浮球会带一个黑色底。
params.x和params.y是相对 gravity 的偏移量,gravity 设为左上角后,这两个值就是距离屏幕左上角的像素距离。拖拽时更新这两个值再调windowManager.updateViewLayout就能移动悬浮球。
2.3 悬浮球 View 的构建与拖拽逻辑
悬浮球本身可以用一个ImageView或者自定义 View 来实现。项目里大概率是用ImageView加载一张圆形图标,然后通过OnTouchListener处理拖拽和点击。
// 创建悬浮球 View ImageView floatView = new ImageView(this); floatView.setImageResource(R.drawable.ic_float_ball); // 设置触摸监听 floatView.setOnTouchListener(new View.OnTouchListener() { private int initialX, initialY; private float touchX, touchY; @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // 记录按下时的窗口坐标和触摸点坐标 initialX = params.x; initialY = params.y; touchX = event.getRawX(); touchY = event.getRawY(); return true; case MotionEvent.ACTION_MOVE: // 计算偏移量并更新窗口位置 params.x = initialX + (int) (event.getRawX() - touchX); params.y = initialY + (int) (event.getRawY() - touchY); windowManager.updateViewLayout(floatView, params); return true; case MotionEvent.ACTION_UP: // 判断是点击还是拖拽,拖拽距离小于阈值视为点击 if (Math.abs(event.getRawX() - touchX) < 10 && Math.abs(event.getRawY() - touchY) < 10) { // 处理点击事件 performClick(); } return true; } return false; } });拖拽逻辑的核心是记录按下时的窗口位置和手指位置,移动时用差值更新params.x和params.y。event.getRawX()和getRawY()获取的是相对屏幕的坐标,这样即使悬浮球本身有偏移也能算准。ACTION_UP里判断拖拽距离小于 10 像素就当作点击,这是悬浮球交互的常见做法,避免用户想点却轻微滑动导致点击失效。
windowManager.updateViewLayout是刷新窗口位置的关键方法,每次移动都要调用,否则 View 不会动。注意这个方法必须在主线程调用,如果你在子线程处理触摸事件,需要切回主线程。
3. 跑起来:从权限申请到服务启动的完整流程
3.1 悬浮窗权限申请
Android 6.0 之后,悬浮窗权限SYSTEM_ALERT_WINDOW属于特殊权限,不能通过运行时权限对话框申请,必须跳转到系统设置页让用户手动开启。这是新手最容易卡住的地方——代码写了,但没权限,悬浮球根本不显示,还不报错。
// 检查是否已有悬浮窗权限 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!Settings.canDrawOverlays(this)) { // 没有权限,跳转到系统设置页 Intent intent = new Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName()) ); startActivityForResult(intent, REQUEST_CODE_OVERLAY); } else { // 已有权限,直接启动服务 startFloatService(); } } else { // 6.0 以下默认有权限 startFloatService(); }Settings.canDrawOverlays是判断权限的入口,返回 false 就说明用户没授权。ACTION_MANAGE_OVERLAY_PERMISSION是系统设置页的 action,带上包名可以直接跳到当前应用的悬浮窗权限开关。startActivityForResult的回调里要重新检查权限,因为用户可能开了也可能没开。
注意:部分国产 ROM 对悬浮窗权限的管理和原生 Android 有差异,
canDrawOverlays可能返回 true 但实际仍然无法显示悬浮球。这种情况下需要引导用户去应用管理里手动开启「显示悬浮窗」或「后台弹出界面」权限。
3.2 启动 Service 并添加悬浮球
权限拿到之后,启动 Service 就是常规操作了。Service 的onStartCommand里执行添加悬浮球的逻辑,onDestroy里移除,保证资源不泄漏。
// 在 Activity 中启动服务 private void startFloatService() { Intent intent = new Intent(this, FloatBallService.class); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // 8.0 以上必须用 startForegroundService startForegroundService(intent); } else { startService(intent); } } // Service 中处理添加和移除 @Override public int onStartCommand(Intent intent, int flags, int startId) { if (floatView == null) { showFloatBall(); } return START_STICKY; // 被系统杀死后尝试重建 } @Override public void onDestroy() { super.onDestroy(); if (floatView != null && windowManager != null) { windowManager.removeView(floatView); floatView = null; } }startForegroundService是 Android 8.0 引入的,用于启动前台 Service。如果你用startService启动一个需要前台的 Service,系统会抛异常。START_STICKY表示 Service 被系统杀死后,系统会尝试重新创建它,但不保证一定成功,也不保证 intent 会重新传递。onDestroy里移除 View 是必须的,否则窗口泄漏会导致下次启动时出现多个悬浮球。
3.3 验证悬浮球是否生效
跑起来之后怎么验证?最简单的办法是打开悬浮球,然后按 Home 键回到桌面,再打开另一个应用,看悬浮球是否还在。如果还在,说明 Service 和 WindowManager 配合正常。如果切到某个界面就消失,大概率是权限问题或者窗口类型不对。
还可以通过adb shell dumpsys window windows | grep -i overlay查看当前系统里的覆盖窗口,确认你的悬浮球窗口是否在列。这个命令在排查窗口不显示时很有用,能看到窗口的 type、flags 和可见性状态。
4. 避坑指南:悬浮球开发中最容易翻车的五个点
4.1 悬浮球在部分机型上不显示
现象:代码逻辑没问题,权限也申请了,但在某些手机上悬浮球就是不出来。
原因:国产 ROM 对悬浮窗权限有额外限制,canDrawOverlays返回 true 不代表真的能显示。部分 ROM 还有「后台弹出界面」权限,不开启的话,从后台启动的窗口会被拦截。
解决:在权限申请之外,增加一个引导页,提示用户去应用管理里手动开启「显示悬浮窗」和「后台弹出界面」。可以在onStartCommand里加一个延迟检查,如果添加 View 后 500ms 内没有可见回调,就弹提示。
4.2 拖拽时悬浮球跳动或位置偏移
现象:拖动悬浮球时,球会突然跳到手指位置,或者移动方向和手指不一致。
原因:params.x和params.y的基准是 gravity 决定的,如果 gravity 设了CENTER,那 x 和 y 就是相对屏幕中心的偏移,和手指的屏幕坐标对不上。另外,event.getX()和event.getRawX()混用也会导致偏移。
解决:gravity 统一用TOP | START,这样 x 和 y 就是相对屏幕左上角的距离,和getRawX、getRawY的坐标系一致。拖拽时始终用getRawX和getRawY,不要用getX和getY。
4.3 点击悬浮球后底层应用也响应了
现象:点击悬浮球执行操作的同时,悬浮球下面的按钮也被点击了。
原因:LayoutParams没有设置FLAG_NOT_FOCUSABLE,或者触摸事件没有消费掉。
解决:在LayoutParams的 flags 里加上FLAG_NOT_FOCUSABLE,并且在OnTouchListener的ACTION_DOWN里返回 true,表示事件已被消费,不再向下传递。
4.4 服务被系统杀死后悬浮球消失
现象:应用切到后台一段时间后,悬浮球不见了。
原因:Service 被系统回收,或者被厂商的省电策略杀掉。
解决:使用前台 Service 并挂常驻通知,onStartCommand返回START_STICKY。如果还不行,可以监听onTaskRemoved,在应用被从最近任务划掉时重新启动 Service。但要注意,频繁重启 Service 可能被系统判定为恶意行为。
4.5 悬浮球遮挡了系统关键区域
现象:悬浮球挡住了状态栏或者导航栏,导致用户无法操作。
原因:params.y设置得太靠上,或者FLAG_LAYOUT_NO_LIMITS让 View 可以覆盖到系统栏区域。
解决:在拖拽结束时判断悬浮球位置,如果超出安全区域就自动吸附回边缘。可以通过getWindowVisibleDisplayFrame获取安全区域范围,然后对params.x和params.y做边界限制。
5. 二次开发:把悬浮球改成你想要的样子
拿到这个项目之后,最直接的改造方向是换悬浮球的外观和交互。比如把圆形图标换成自定义 View,加个展开菜单,或者做成可吸附边缘的半隐藏样式。这些改动都集中在showFloatBall方法里,替换ImageView为你的自定义 View,然后在触摸逻辑里增加状态判断即可。
另一个方向是增加功能入口。悬浮球本身只是个触发器,点击之后干什么才是业务核心。你可以在performClick里弹出一个悬浮菜单,菜单项包括「截屏」「录屏」「返回桌面」「打开设置」等。菜单的实现可以用PopupWindow,也可以再往 WindowManager 里加一个 View。用PopupWindow更简单,但要注意它需要依附在一个锚点 View 上,悬浮球本身就是现成的锚点。
// 点击悬浮球弹出菜单 private void performClick() { // 构建菜单内容 String[] items = {"截屏", "录屏", "返回桌面"}; PopupWindow popup = new PopupWindow( createMenuView(items), WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, true ); // 设置背景,否则点击外部不消失 popup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); popup.setOutsideTouchable(true); // 显示在悬浮球上方 popup.showAsDropDown(floatView, 0, -floatView.getHeight() * 2); }PopupWindow的setOutsideTouchable(true)配合背景设置,才能实现点击外部自动关闭。showAsDropDown的偏移量用负数让菜单显示在悬浮球上方,避免被底部导航栏遮挡。菜单项的点击事件里再去调具体功能,比如截屏可以用MediaProjectionAPI,录屏也是同一套机制。
改造时有个血泪经验:每次修改LayoutParams之后一定要调updateViewLayout,否则改动不会生效。我见过有人改了params.width然后纳闷为什么悬浮球大小没变,就是漏了这一步。另外,如果你在 Service 里做耗时操作,记得开子线程,主线程卡住会导致悬浮球拖拽不跟手,用户体验直接崩掉。
从那以后我每次改悬浮球相关代码,都会先在updateViewLayout后面打一行日志确认参数生效,再往下写业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取