ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android全局悬浮球源码解析:Service+WindowManager实现任意界面悬浮窗

Android全局悬浮球源码解析:Service+WindowManager实现任意界面悬浮窗

简介:这是一份面向Android开发者的悬浮球功能实现源码,聚焦于通过一个后台服务让悬浮按钮在任意应用界面之上显示并交互,适合需要掌握悬浮窗、WindowManager与系统级权限处理的中级开发者参考。压缩包共35个文件,约2.02MB,以xml布局与配置、java服务与Activity代码、gradle构建脚本为主,另含png截图、properties与jar等辅助文件,结构完整可直接导入Android Studio运行。资源围绕FloatingViewService展开,涵盖SYSTEM_ALERT_WINDOW权限声明与Android 6.0以上动态申请、TYPE_PHONE窗口参数设置、addView添加视图、触摸与点击事件绑定、START_STICKY保活及横竖屏切换重布局等关键环节,并附有README说明与运行截图,便于对照理解服务生命周期与窗口管理机制。目前已有2420人学习下载,适合希望快速搭建悬浮球原型、排查权限与窗口层级问题的开发者借鉴。

1. 一个服务搞定全局悬浮球:为什么我建议你从这份源码开始拆

做 Android 开发绕不开一个需求:悬浮球。产品经理一句“我要在任何界面都能拖动的悬浮球”,背后是 WindowManager、Service 生命周期、权限适配、触摸事件分发、内存泄漏一堆坑。很多教程只给你一个 Activity 里加个 View 的 Demo,切到桌面就没了。这份源码解决的就是这个问题——用一个 Service开启悬浮球,任何界面都能用,包括桌面、其他 App 之上。它适合两类人:一是刚接触 Android 系统级 UI 的开发者,想搞懂 WindowManager 到底怎么用;二是手上有类似需求、需要一个能跑通的完整参考实现的人。关键词“Android开发-其它控件”里,悬浮球属于典型的自定义控件 + 系统窗口组合,这份代码把两者串起来了。

2. 项目定位与技术选型:为什么是 Service + WindowManager

2.1 悬浮球本质是一个系统级窗口

普通 View 活在 Activity 的窗口里,Activity 一销毁,View 就没了。悬浮球要“任何界面都可以使用”,意味着它的宿主不能是 Activity,必须是更底层的系统窗口。Android 里能往系统窗口层塞 View 的入口就是WindowManager,而WindowManager需要在一个不受界面切换影响的生命周期里持有——Service正好满足。

这份源码的核心思路很直接:Service 启动时通过WindowManager.addView()把悬浮球 View 挂到TYPE_APPLICATION_OVERLAY类型的窗口上,Service 销毁时removeView()。整个项目没有引入第三方库,纯原生 API,依赖只有 Android SDK 本身。常见做法是配合LifecycleService或手动管理,但这份代码用的是最朴素的Service+Handler,对新手更友好,读起来没有额外框架的干扰。

技术栈可以概括为:

层级用到的技术作用
窗口层WindowManager + TYPE_APPLICATION_OVERLAY把 View 挂到系统窗口
服务层Service + onStartCommand管理悬浮球生命周期
触摸层OnTouchListener + MotionEvent拖动与点击判定
权限层SYSTEM_ALERT_WINDOW悬浮窗权限申请
UI 层自定义 View / ImageView悬浮球外观与交互

2.2 为什么不用 Activity 或 AccessibilityService

有人会问:用AccessibilityService不是也能做悬浮球吗?可以,但那是杀鸡用牛刀。无障碍服务适合做自动化操作,悬浮球只是它的副产物,而且用户开启无障碍的意愿远低于悬浮窗权限。用 Activity 做宿主更不行,切后台就挂。所以 Service + WindowManager 是这个场景下最合理的组合,这份源码选型没有跑偏。

2.3 目录结构长什么样

拿到源码后,先看目录,心里有个地图:

app/src/main/java/com/example/floatball/ ├── FloatBallService.java // 核心服务,管理窗口添加/移除 ├── FloatBallView.java // 悬浮球自定义 View,处理触摸 ├── MainActivity.java // 入口,申请权限、启动服务 └── utils/ └── PermissionUtils.java // 悬浮窗权限检查与跳转 app/src/main/res/ ├── layout/activity_main.xml ├── drawable/ball_bg.xml // 悬浮球背景 └── values/colors.xml AndroidManifest.xml // 注册 Service 与权限

这个结构很干净,核心逻辑集中在FloatBallService和FloatBallView两个文件里。新手先读MainActivity看怎么启动,再读FloatBallService看窗口怎么加,最后读FloatBallView看触摸怎么处理。

3. 核心模块拆解:从权限申请到窗口挂载的完整链路

3.1 权限申请:没有它后面全白搭

SYSTEM_ALERT_WINDOW是特殊权限,不能只写在 Manifest 里就完事,必须引导用户去设置页手动开启。源码里PermissionUtils的做法是:

// PermissionUtils.java public static boolean canDrawOverlays(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // Android 6.0 及以上需要动态检查 return Settings.canDrawOverlays(context); } return true; // 6.0 以下默认有权限 } public static void requestOverlayPermission(Activity activity, int requestCode) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + activity.getPackageName())); activity.startActivityForResult(intent, requestCode); } }

逻辑说明:canDrawOverlays在 6.0 以上用Settings.canDrawOverlays判断,6.0 以下直接返回 true。requestOverlayPermission跳转到当前应用的悬浮窗权限设置页,用package:URI 限定到本应用,避免用户在其他应用列表里迷路。参数requestCode用于在onActivityResult里区分回调来源。

注意:部分国产 ROM 即使canDrawOverlays返回 true,实际添加窗口仍可能失败,所以addView一定要包 try-catch。

3.2 Service 启动与窗口添加

FloatBallService的onCreate里做两件事:创建悬浮球 View,拿到 WindowManager。onStartCommand里执行添加。核心代码:

// FloatBallService.java @Override public void onCreate() { super.onCreate(); windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); floatBallView = new FloatBallView(this); initLayoutParams(); } private void initLayoutParams() { layoutParams = new WindowManager.LayoutParams(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Android 8.0 起必须用这个类型 layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } else { layoutParams.type = WindowManager.LayoutParams.TYPE_PHONE; } layoutParams.format = PixelFormat.RGBA_8888; // 支持透明 layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; layoutParams.gravity = Gravity.TOP | Gravity.START; layoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT; layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT; layoutParams.x = 100; layoutParams.y = 300; } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (!floatBallView.isAttachedToWindow()) { try { windowManager.addView(floatBallView, layoutParams); } catch (Exception e) { // 权限被回收或 ROM 限制时会走到这里 stopSelf(); } } return START_STICKY; // 被杀后尝试重启 }

逻辑说明:initLayoutParams里最关键的是type,8.0 以上必须用TYPE_APPLICATION_OVERLAY,否则直接崩。FLAG_NOT_FOCUSABLE让悬浮球不抢焦点,不影响下层应用输入;FLAG_LAYOUT_NO_LIMITS允许拖出屏幕边界。onStartCommand里先判断isAttachedToWindow防止重复添加,try-catch 兜底 ROM 限制。返回START_STICKY让服务被系统杀掉后有机会重建。

参数怎么改:layoutParams.x和y是初始位置,想让它默认靠右就把x设大一点,或者用gravity = TOP | END。width/height用WRAP_CONTENT让 View 自己决定大小,如果你固定了悬浮球尺寸,也可以写死 dp 值。

3.3 触摸处理:拖动与点击的判定

悬浮球要能拖,还要能点。难点在于区分“用户是想拖还是想点”。源码在FloatBallView里用了一个阈值判断:

// FloatBallView.java private float downX, downY; private int lastX, lastY; private static final int CLICK_THRESHOLD = 10; // dp @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: downX = event.getRawX(); downY = event.getRawY(); lastX = layoutParams.x; lastY = layoutParams.y; return true; case MotionEvent.ACTION_MOVE: int dx = (int) (event.getRawX() - downX); int dy = (int) (event.getRawY() - downY); layoutParams.x = lastX + dx; layoutParams.y = lastY + dy; windowManager.updateViewLayout(this, layoutParams); return true; case MotionEvent.ACTION_UP: float moveX = Math.abs(event.getRawX() - downX); float moveY = Math.abs(event.getRawY() - downY); if (moveX < CLICK_THRESHOLD && moveY < CLICK_THRESHOLD) { performClick(); // 触发点击 } return true; } return super.onTouchEvent(event); }

逻辑说明:ACTION_DOWN记录按下时的原始坐标和窗口当前位置。ACTION_MOVE计算偏移量,更新layoutParams.x/y后调用updateViewLayout实时移动。ACTION_UP时判断移动距离是否小于阈值,小于就认为是点击。CLICK_THRESHOLD用 dp 转 px 更准确,源码里简化成了固定值,二次开发时建议改成dp2px(10)。

参数说明:event.getRawX()是相对屏幕的坐标,getX()是相对父 View 的,这里必须用getRawX。lastX/lastY保存的是上一次窗口位置,不是按下位置,这样连续拖动不会跳。

3.4 服务销毁与资源释放

悬浮球最怕内存泄漏和窗口残留。源码在onDestroy里做了清理:

@Override public void onDestroy() { super.onDestroy(); if (floatBallView != null && floatBallView.isAttachedToWindow()) { windowManager.removeView(floatBallView); } floatBallView = null; windowManager = null; }

逻辑说明:先判断 View 是否还挂在窗口上,再移除,避免IllegalArgumentException。置空引用帮助 GC。常见做法还会在这里注销广播、停止动画,这份源码因为功能单一,清理逻辑很轻。

4. 跑起来:从导入到看到悬浮球的每一步

4.1 环境准备与导入

这份源码是标准 Android 工程,用 Android Studio 直接Open项目根目录即可。Gradle 版本和 AGP 版本在build.gradle里,如果提示同步失败,优先检查本地 SDK 是否装了对应 API Level。常见做法是让 Studio 自动下载缺失组件,不要手动改版本号,除非你明确知道自己在做什么。

导入后先看AndroidManifest.xml,确认两处:

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <service android:name=".FloatBallService" />

权限和 Service 注册缺一不可。有些 ROM 还要求FOREGROUND_SERVICE,但这份源码没用到前台服务,所以不加也能跑,只是后台存活时间短。

4.2 启动流程与权限回调

MainActivity里通常是一个按钮,点击后先检查权限,没有就申请,有就启动 Service:

// MainActivity.java btnStart.setOnClickListener(v -> { if (PermissionUtils.canDrawOverlays(this)) { startService(new Intent(this, FloatBallService.class)); finish(); // 启动后退出,验证是否真的全局 } else { PermissionUtils.requestOverlayPermission(this, REQUEST_CODE); } }); @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_CODE) { if (PermissionUtils.canDrawOverlays(this)) { startService(new Intent(this, FloatBallService.class)); finish(); } } }

逻辑说明:finish()是故意加的,用来验证悬浮球是否真的独立于 Activity。如果退出后悬浮球还在,说明窗口挂载成功;如果消失,说明代码有问题。onActivityResult里重新检查权限,因为用户可能授权也可能拒绝。

4.3 验证悬浮球是否全局生效

跑起来后按这个顺序验证:

  1. 点击按钮,授权,看到悬浮球出现在屏幕上。
  2. 按 Home 键回到桌面,悬浮球应该还在。
  3. 打开另一个 App,悬浮球应该覆盖在它上面。
  4. 拖动悬浮球,松手后位置保持。
  5. 点击悬浮球,触发点击事件(源码里可能是 Toast 或日志)。

如果第 2 步就失败,说明窗口类型不对或权限没真正生效。如果第 3 步失败,检查是否被其他应用的悬浮窗遮挡,或者 ROM 有白名单限制。

4.4 停止服务与清理

源码里没有提供停止入口,二次开发时建议加一个通知栏按钮或悬浮球长按菜单,调用stopService(new Intent(this, FloatBallService.class))。停止后onDestroy会移除窗口,悬浮球消失。测试时可以用adb shell am stopservice强制停止,观察窗口是否正确清理。

5. 避坑与常见问题:那些让我加班到凌晨的细节

5.1 悬浮球在部分机型上不显示

现象:代码在模拟器上正常,真机上授权后悬浮球不出现。原因:国产 ROM 对TYPE_APPLICATION_OVERLAY有额外限制,或者canDrawOverlays返回 true 但实际未授权。解决:在addView外层加 try-catch,捕获WindowManager.BadTokenException和SecurityException,失败时引导用户去系统设置里手动开启“显示在其他应用上层”。

5.2 拖动时悬浮球闪烁或跳动

现象:手指移动时悬浮球位置跳变。原因:ACTION_MOVE里用了event.getX()而不是getRawX(),或者lastX/lastY在每次 MOVE 时被重置。解决:统一用getRawX/getRawY,lastX/lastY只在ACTION_DOWN时赋值,MOVE 时基于按下时的窗口位置加偏移。

5.3 服务被杀后悬浮球消失

现象:锁屏或内存紧张后悬浮球没了。原因:普通 Service 在后台容易被系统回收。解决:改成前台服务,startForeground加一个低优先级通知。注意 Android 8.0 以上前台服务也要用startForegroundService启动,并在 5 秒内调用startForeground。

5.4 点击事件不触发

现象:悬浮球能拖但点不动。原因:onTouchEvent返回了 false,或者阈值判断把点击当成了拖动。解决:确保ACTION_DOWN返回 true,ACTION_UP里用Math.abs计算移动距离,阈值建议 10dp 左右。如果悬浮球内部还有子 View,注意事件分发,子 View 可能消费了触摸事件。

5.5 退出应用后悬浮球还在但点击无响应

现象:Activity 销毁后悬浮球还在,但点击没反应。原因:点击回调里引用了 Activity 的 Context 或匿名内部类,导致内存泄漏或空指针。解决:回调里用getApplicationContext(),或者通过广播/接口把事件传出去,不要在 Service 里直接持有 Activity 引用。

6. 二次开发:把悬浮球改成你想要的样子

6.1 换外观:从圆形到任意布局

源码里的悬浮球是一个简单 View,你可以直接换成ImageView加自定义 drawable,或者用LayoutInflater加载一个复杂布局。关键点:WindowManager.LayoutParams的width/height要跟着改,如果用WRAP_CONTENT就不用动。常见做法是把悬浮球做成可展开的菜单,点击后弹出几个小按钮,这时候窗口尺寸要动态更新,调用updateViewLayout重新设置宽高。

6.2 加交互:长按、双击、贴边吸附

长按和双击可以在onTouchEvent里用GestureDetector处理,比手动判断更稳。贴边吸附的逻辑是:ACTION_UP时判断当前layoutParams.x离屏幕左边近还是右边近,然后用属性动画或ValueAnimator平滑移动过去。注意屏幕宽度用getResources().getDisplayMetrics().widthPixels获取,不要写死。

6.3 数据通信:悬浮球与 Activity 怎么传值

Service 和 Activity 通信常见三种方式:广播、Binder、LiveData。这份源码没涉及,但二次开发大概率要加。推荐用LocalBroadcastManager或LiveData,前者简单,后者生命周期安全。如果悬浮球要控制 Activity 里的播放器,可以定义一个接口,Activity 绑定 Service 时把接口传进去。

6.4 一个我踩过的坑:窗口类型与 targetSdkVersion

有次我把targetSdkVersion升到 30,悬浮球突然不显示了。排查半天发现是TYPE_PHONE在 8.0 以上被废弃,而我的代码里判断条件写反了。从那以后我每次改targetSdkVersion都强制走一遍悬浮球显示验证,确认TYPE_APPLICATION_OVERLAY分支生效。这个习惯帮我省了不少返工时间。

6.5 验证改造是否成功的最小清单

改完代码后,按这个清单跑一遍:

验证项预期结果失败时先看
授权后启动悬浮球出现addView 是否抛异常
按 Home悬浮球仍在窗口类型是否正确
打开其他 App悬浮球覆盖权限是否被 ROM 限制
拖动跟手不跳是否用 getRawX
点击触发回调阈值与返回值
停止服务悬浮球消失onDestroy 是否 removeView

这份源码最值钱的地方不是代码多复杂,而是它把“一个服务开启全局悬浮球”这条链路完整跑通了,没有多余封装,每一行都能对应到一个具体问题。你拿到后先跑通,再按上面的思路改外观、加交互、补通信,基本就能覆盖大部分悬浮球需求。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进