【Android开发实战】实现全局悬浮球(超详细讲解+完整代码+原理解析)
下面开始正文:
一、项目介绍
1. 背景与意义
全局悬浮球(Floating Ball)是一种常见的系统级快捷入口,应用场景包括但不限于:
-
辅助功能:如 AssistiveTouch,为行动不便用户提供触摸快捷键;
-
快捷操作:悬浮球内可自定义多功能菜单,快速启动应用内部核心功能;
-
悬浮提示:如悬浮客服、浮动小窗显示实时数据;
-
游戏辅助:悬浮取图、录屏、快捷按钮等;
实现全局悬浮球,需要突破 App 生命周期限制,将UI悬浮在任意界面之上。要点在于:
-
悬浮窗权限管理:Android 6.0+ 需要动态申请
SYSTEM_ALERT_WINDOW
权限; -
WindowManager 服务:将自定义 View 添加到系统 WindowManager;
-
Service 常驻:使用前台服务保持悬浮球存活,避免被系统回收;
-
触摸与拖拽:实现对悬浮球的按压、拖动、点击响应;
-
吸附边缘算法:在拖动结束后自动吸附到屏幕左/右边缘;
-
资源与性能优化:确保悬浮球不会频繁占用 CPU、内存或耗电;
-
多进程与适配:处理 Android 8.0+ 不同进程模型以及各家定制 ROM 限制。
二、相关知识详解
1. Android WindowManager 原理
-
WindowManager 是系统级服务,管理所有应用窗口;
-
使用
Context.getSystemService(WINDOW_SERVICE)
获取; -
addView(View view, WindowManager.LayoutParams params)
将自定义 View 挂在最顶层。
2. 悬浮窗权限(SYSTEM_ALERT_WINDOW)
-
在 Android 6.0+ 需打开“允许在其他应用上层显示”;
-
通过 Intent 跳转
Settings.ACTION_MANAGE_OVERLAY_PERMISSION
; -
在 Android 11+ 权限 UI 与路径可能变化,需兼容提示。
3. Service 与前台服务
-
将悬浮球逻辑放在
Service
中,保证在 Activity 销毁后仍存活; -
Android 8.0+ 新增前台服务限制,需调用
startForeground()
并提供通知;
4. WindowManager.LayoutParams 参数详解
-
type
:选择TYPE_APPLICATION_OVERLAY
(Android 8.0+)或TYPE_PHONE
(旧版); -
flags
:FLAG_NOT_FOCUSABLE
|FLAG_LAYOUT_IN_SCREEN
|FLAG_NOT_TOUCH_MODAL
; -
format
:一般使用PixelFormat.TRANSLUCENT
; -
gravity
:控制初始对齐,如Gravity.START|Gravity.TOP
; -
x
,y
:窗口在屏幕上的坐标。
5. View 拖拽与触摸事件
-
在悬浮球 View 中重写
onTouchEvent(MotionEvent ev)
; -
ACTION_DOWN
: 记录初始触点与窗口位置; -
ACTION_MOVE
: 计算增量并调用updateViewLayout()
更新布局; -
ACTION_UP
: 触发边缘吸附算法。
6. 粘性吸附算法原理
-
判断悬浮球中心 X 坐标与屏幕宽度中线的距离;
-
小于中线 → 吸附左侧(x=0);否则吸附右侧(x=屏幕宽度–悬浮球宽度);
-
Y 坐标需约束在
[0, screenHeight–球高度]
; -
使用
ValueAnimator
平滑过渡。
7. 电量与内存优化
-
避免频繁
postInvalidate()
; -
View 渲染简单,使用
ImageView
+透明 PNG 而非 Canvas 绘制; -
Service 调度轻量,拖拽时只更新一次/帧。
8. 权限兼容与适配
-
小米、华为等定制 ROM 对悬浮窗权限有额外管理页面;
-
检测
Settings.canDrawOverlays()
并引导到对应权限界面; -
Android 10+ 没有变化,但需处理
TYPE_APPLICATION_OVERLAY
。
三、项目实现思路
-
编写
FloatingService
-
继承
Service
; -
在
onCreate()
中初始化悬浮球视图,并调用addFloatingView()
; -
在
onDestroy()
中移除视图。
-
-
权限申请流程
-
在
MainActivity
中检测并申请权限; -
若权限授予,启动
FloatingService
; -
否则弹出对话框并跳转设置页。
-
-
悬浮球视图布局
-
floating_view.xml
:包含一个圆形ImageView
; -
可选扩展:长按展开菜单
LinearLayout
、RecyclerView
。
-
-
WindowManager 添加与更新
-
创建
WindowManager.LayoutParams params
并配置; -
wm.addView(floatingView, params)
; -
在拖拽时
params.x = newX; params.y = newY; wm.updateViewLayout(view, params);
-
-
拖拽与吸附
-
View 内处理触摸事件并向
Service
回传更新坐标; -
在滑动结束时调用
startAbsorbAnimation()
,计算目标 x、y 并用ValueAnimator
插值更新。
-
-
前台服务通知
-
Android 8.0+ 调用
startForeground(NOTIFY_ID, buildNotification())
; -
通知点击可跳转到 App 主界面或隐藏悬浮球。
-
-
生命周期管理
-
Service 保持悬浮球存活;
-
在屏幕旋转、分屏、应用升级等场景下,检测并重新添加视图。
-
-
扩展功能
-
点击悬浮球显示快捷菜单;
-
长按拖拽、双击隐藏;
-
定制动画效果:呼吸、旋转。
-
四、完整整合版代码
// ======================================================
// AndroidManifest.xml
// - 申请悬浮窗权限声明
// - 注册 FloatingService
// ======================================================
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application ...>
<service android:name=".FloatingService"
android:foregroundServiceType="mediaProjection|location"/>
</application>
// ======================================================
// res/layout/floating_view.xml
// - 定义悬浮球及可选菜单
// ======================================================
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/float_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<!-- 悬浮球 -->
<ImageView
android:id="@+id/float_ball"
android:layout_width="60dp"
android:layout_height="60dp"
android:src="@drawable/ic_float_ball"
android:background="@drawable/float_ball_bg"
android:contentDescription="悬浮球"/>
<!-- 扩展菜单:默认隐藏 -->
<LinearLayout
android:id="@+id/float_menu"
android:orientation="vertical"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<!-- 菜单按钮示例 -->
<ImageButton android:layout_width="40dp" android:layout_height="40dp"
android:src="@drawable/ic_action1" android:background="?attr/selectableItemBackgroundBorderless"/>
<ImageButton android:layout_width="40dp" android:layout_height="40dp"
android:src="@drawable/ic_action2" android:background="?attr/selectableItemBackgroundBorderless"/>
</LinearLayout>
</FrameLayout>
// ======================================================
// FloatingService.java
// - 前台 Service,管理悬浮球的添加、移除、拖拽、吸附
// ======================================================
package com.example.floating;
import android.app.*;
import android.content.*;
import android.graphics.PixelFormat;
import android.os.*;
import android.provider.Settings;
import android.view.*;
import android.view.animation.*;
import android.widget.*;
import androidx.core.app.NotificationCompat;
public class FloatingService extends Service {
private WindowManager wm;
private View floatView;
private WindowManager.LayoutParams params;
private int screenWidth, screenHeight;
private float downX, downY, viewX, viewY;
private Handler handler = new Handler(Looper.getMainLooper());
@Override public void onCreate() {
super.onCreate();
wm = (WindowManager) getSystemService(WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
screenWidth = dm.widthPixels;
screenHeight = dm.heightPixels;
inflateFloatView();
startForeground(1, buildNotification());
}
// 构建前台服务通知
private Notification buildNotification() {
NotificationChannel ch = new NotificationChannel("float", "FLOAT", NotificationManager.IMPORTANCE_LOW);
((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch);
return new NotificationCompat.Builder(this, "float")
.setContentTitle("悬浮球运行中")
.setSmallIcon(R.drawable.ic_float_ball)
.setOngoing(true)
.build();
}
// 加载并添加悬浮视图
private void inflateFloatView() {
floatView = LayoutInflater.from(this).inflate(R.layout.floating_view, null);
final ImageView ball = floatView.findViewById(R.id.float_ball);
final LinearLayout menu = floatView.findViewById(R.id.float_menu);
params = new WindowManager.LayoutParams();
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.height= WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; // Android 8.0+
params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
params.gravity = Gravity.START | Gravity.TOP;
params.x = 0; params.y = screenHeight / 3;
wm.addView(floatView, params);
ball.setOnTouchListener(new View.OnTouchListener() {
private long touchStart;
@Override public boolean onTouch(View v, MotionEvent e) {
switch (e.getAction()) {
case MotionEvent.ACTION_DOWN:
touchStart = System.currentTimeMillis();
downX = e.getRawX();
downY = e.getRawY();
viewX = params.x;
viewY = params.y;
return true;
case MotionEvent.ACTION_MOVE:
float deltaX = e.getRawX() - downX;
float deltaY = e.getRawY() - downY;
params.x = (int)(viewX + deltaX);
params.y = (int)(viewY + deltaY);
wm.updateViewLayout(floatView, params);
return true;
case MotionEvent.ACTION_UP:
long duration = System.currentTimeMillis() - touchStart;
if (duration < 200) {
// 点击:切换菜单显示
menu.setVisibility(menu.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE);
}
absorbEdge();
return true;
}
return false;
}
});
// 菜单按钮示例点击逻辑
menu.findViewById(R.id.ic_action1).setOnClickListener(v -> {
Toast.makeText(this, "Action1", Toast.LENGTH_SHORT).show();
});
menu.findViewById(R.id.ic_action2).setOnClickListener(v -> {
Toast.makeText(this, "Action2", Toast.LENGTH_SHORT).show();
});
}
// 边缘吸附动画
private void absorbEdge() {
final int startX = params.x;
final int endX = (params.x + floatView.getWidth()/2) <= screenWidth/2 ? 0 : screenWidth - floatView.getWidth();
ValueAnimator anim = ValueAnimator.ofInt(startX, endX);
anim.setDuration(300);
anim.setInterpolator(new DecelerateInterpolator());
anim.addUpdateListener(animation -> {
params.x = (int) animation.getAnimatedValue();
wm.updateViewLayout(floatView, params);
});
anim.start();
}
@Override public void onDestroy() {
super.onDestroy();
if (floatView != null) wm.removeView(floatView);
}
@Override public IBinder onBind(Intent intent) { return null; }
}
// ======================================================
// MainActivity.java
// - 检测并申请悬浮窗权限,启动 FloatingService
// ======================================================
package com.example.floating;
import android.content.*;
import android.net.Uri;
import android.os.*;
import android.provider.Settings;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
private static final int REQ_OVERLAY = 1000;
@Override protected void onCreate(@Nullable Bundle s) {
super.onCreate(s);
setContentView(R.layout.activity_main);
if (Settings.canDrawOverlays(this)) {
startService(new Intent(this, FloatingService.class));
} else {
new AlertDialog.Builder(this)
.setTitle("悬浮窗权限")
.setMessage("请授予悬浮窗权限,否则无法显示悬浮球")
.setPositiveButton("去授予", (d, w) -> {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, REQ_OVERLAY);
}).setCancelable(false).show();
}
}
@Override protected void onActivityResult(int req, int res, Intent data) {
super.onActivityResult(req, res, data);
if (req == REQ_OVERLAY && Settings.canDrawOverlays(this)) {
startService(new Intent(this, FloatingService.class));
} else {
finish(); // 权限未授予,退出或提示
}
}
}
// ======================================================
// res/drawable/float_ball_bg.xml
// - 圆形背景 shape
// ======================================================
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#CC000000"/>
</shape>
// ======================================================
// res/layout/activity_main.xml
// - 主界面布局示例
// ======================================================
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent"
android:gravity="center">
<Button
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="启动悬浮球"
android:onClick="onStartFloat"/>
</FrameLayout>
五、代码解读
-
权限申请
-
使用
Settings.canDrawOverlays()
判断权限; -
通过
Settings.ACTION_MANAGE_OVERLAY_PERMISSION
跳转系统设置页; -
在
onActivityResult()
中重新检查并启动 Service。
-
-
FloatingService
-
onCreate()
初始化WindowManager
、屏幕尺寸、View; -
前台服务:
startForeground()
保持存活; -
inflateFloatView()
加载floating_view.xml
,设置触摸监听;
-
-
触摸与拖拽
-
ACTION_DOWN
记录触点与原始布局位置; -
ACTION_MOVE
计算偏移并wm.updateViewLayout()
; -
ACTION_UP
判断点击 vs 拖动,并调用absorbEdge()
吸附;
-
-
吸附边缘
-
取视图中点与屏幕中线比较,决定吸附左侧或右侧;
-
使用
ValueAnimator
平滑过渡;
-
-
前台服务通知
-
避免 Android 8.0+ 被系统回收;
-
通知点击可扩展为“隐藏悬浮球”或跳转 App;
-
-
资源与样式
-
float_ball_bg.xml
使用纯色圆形 Shape; -
floating_view.xml
布局简洁,易于扩展;
-
六、项目总结与拓展
-
项目收获
-
深入理解
WindowManager
原理与悬浮窗权限; -
掌握
Service
与前台服务保活机制; -
熟练实现 View 拖拽、点击与吸附动画;
-
学会处理 Android 8.0+ 特殊类型
TYPE_APPLICATION_OVERLAY
;
-
-
性能与兼容性
-
避免在悬浮球中执行耗时操作;
-
仅在必要时刷新位置信息,降低 CPU 与电量消耗;
-
对小米、华为等定制系统进行权限适配,并提示用户;
-
-
功能拓展
-
多功能菜单:长按或点击弹出更多快捷操作;
-
滑出隐藏:在不操作时半隐藏到屏幕侧边;
-
放大镜:在悬浮球上集成屏幕放大功能;
-
悬浮工具箱:集成截屏、录屏、手电筒等多种工具;
-
多进程支持:在多个进程内共享悬浮球状态;
-
权限插件化:动态请求并兼容系统 ROM。
-
-
架构与发布
-
将悬浮球逻辑提炼为独立模块或 AAR,便于多个项目复用;
-
对外提供统一 API
FloatingManager.show()
/hide()
; -
引入 RxJava 或 LiveData 替代 Handler 进行异步事件分发;
-