一、从需求到实现:悬浮球与系统级窗口的实战思路
说实话,WindowManager 这个服务,平时我们开发普通应用时接触得不多。但一旦涉及到悬浮球、自定义 Toast、或者系统级悬浮窗这类需求,你就绕不开它了。
我个人习惯把这类需求分成三个层次:
- 应用内悬浮:比如视频播放器的小窗,只在当前 Activity 内显示
- 跨页面悬浮:比如悬浮球,能在不同应用之间保持显示
- 系统级悬浮:比如手机管家的悬浮窗,需要 SYSTEM_ALERT_WINDOW 权限
今天我们就从最简单的开始,一步步把这三个场景都跑通。嗯,这里要注意,每个场景的 WindowManager 参数配置都不一样,搞错了就会闪退或者显示不出来。
核心知识点:WindowManager 的 addView、updateViewLayout、removeView 三个方法,配合 WindowManager.LayoutParams 的各种 flag 和 type,就能实现几乎所有悬浮效果。
二、可拖拽悬浮球的完整实现
2.1 基础布局与 WindowManager 初始化
先写一个简单的悬浮球布局,就是一个圆形的 View。我建议用 ImageView 或者自定义 View,这样后续可以加动画效果。
// 悬浮球布局文件:layout_floating_ball.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="60dp"
android:layout_height="60dp"
android:background="@drawable/shape_ball">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:src="@drawable/ic_launcher"/>
</FrameLayout>
然后获取 WindowManager 实例。这里有个坑:必须用 Application 的 Context,不能用 Activity 的,否则悬浮球会在 Activity 销毁时被移除。
WindowManager windowManager = (WindowManager) getApplicationContext()
.getSystemService(Context.WINDOW_SERVICE);
2.2 配置 LayoutParams
这是最关键的一步。不同的 type 决定了悬浮窗的层级和行为。我项目中常用的配置如下:
WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
params.gravity = Gravity.TOP | Gravity.START;
params.x = 0;
params.y = 200;
小提示:TYPE_APPLICATION_OVERLAY 是 Android 8.0 之后推荐的悬浮窗类型,兼容性最好。老项目可能会用 TYPE_PHONE 或 TYPE_SYSTEM_ALERT,但那些已经被废弃了。
2.3 实现拖拽逻辑
拖拽的核心就是监听触摸事件,动态更新 params.x 和 params.y。我习惯用 GestureDetector 配合 OnTouchListener 来做。
floatingView.setOnTouchListener(new View.OnTouchListener() {
private int initialX;
private int initialY;
private float initialTouchX;
private float initialTouchY;
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
initialX = params.x;
initialY = params.y;
initialTouchX = event.getRawX();
initialTouchY = event.getRawY();
return true;
case MotionEvent.ACTION_MOVE:
params.x = initialX + (int) (event.getRawX() - initialTouchX);
params.y = initialY + (int) (event.getRawY() - initialTouchY);
windowManager.updateViewLayout(floatingView, params);
return true;
case MotionEvent.ACTION_UP:
// 可以在这里做吸附边缘的效果
return true;
}
return false;
}
});
这里有个细节:为什么用 getRawX 而不是 getX? 因为 getX 是相对于 View 自身的坐标,而我们需要的是屏幕绝对坐标。我曾经在这个问题上卡了半小时,最后发现是坐标参考系搞混了。
2.4 添加与移除悬浮球
// 添加悬浮球
windowManager.addView(floatingView, params);
// 移除悬浮球
windowManager.removeView(floatingView);
注意:removeView 必须在主线程调用,而且必须确保 View 已经被 add 过。否则会抛出 IllegalStateException。我建议用 try-catch 包一下,或者维护一个布尔变量记录状态。
三、自定义 Toast 样式
系统自带的 Toast 样式太丑了,而且位置固定。其实我们可以用 WindowManager 完全自定义一个 Toast 效果。
3.1 传统 Toast 的局限性
| 对比项 | 系统 Toast | 自定义 WindowManager Toast |
|---|---|---|
| 样式 | 固定黑白风格 | 完全自定义布局 |
| 位置 | 底部或居中 | 任意位置 |
| 显示时长 | LENGTH_SHORT / LENGTH_LONG | 任意时长 |
| 动画 | 无 | 可自定义动画 |
3.2 实现自定义 Toast
说白了,就是把 Toast 的布局通过 WindowManager 显示出来,然后定时移除。我项目中封装了一个工具类:
public class CustomToast {
private WindowManager windowManager;
private View toastView;
private WindowManager.LayoutParams params;
private Handler handler = new Handler();
public void show(Context context, String message) {
windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
LayoutInflater inflater = LayoutInflater.from(context);
toastView = inflater.inflate(R.layout.layout_custom_toast, null);
TextView textView = toastView.findViewById(R.id.toast_text);
textView.setText(message);
params = new WindowManager.LayoutParams();
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
params.gravity = Gravity.CENTER;
params.y = 100;
windowManager.addView(toastView, params);
// 2秒后自动消失
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (toastView != null) {
windowManager.removeView(toastView);
toastView = null;
}
}
}, 2000);
}
}
经验之谈:自定义 Toast 的 type 建议和悬浮球保持一致,都用 TYPE_APPLICATION_OVERLAY。如果你用 TYPE_TOAST,在某些厂商 ROM 上可能会被系统拦截。
四、系统级悬浮窗的完整开发流程
4.1 权限申请
系统级悬浮窗需要 SYSTEM_ALERT_WINDOW 权限。从 Android 6.0 开始,这个权限需要动态申请。
// AndroidManifest.xml 中声明
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
// 代码中检查并跳转设置页
if (!Settings.canDrawOverlays(this)) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, REQUEST_CODE_OVERLAY);
}
你想想看,如果用户拒绝了权限,你的悬浮窗就显示不出来。所以一定要做好权限判断和引导。
4.2 完整的悬浮窗生命周期管理
我建议把悬浮窗的创建、显示、销毁都封装在一个 Service 里。这样即使 Activity 被回收,悬浮窗依然能正常工作。
public class FloatingWindowService extends Service {
private WindowManager windowManager;
private View floatingView;
private WindowManager.LayoutParams params;
@Override
public void onCreate() {
super.onCreate();
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
initFloatingWindow();
}
private void initFloatingWindow() {
floatingView = LayoutInflater.from(this)
.inflate(R.layout.layout_floating_window, null);
params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT);
params.gravity = Gravity.TOP | Gravity.START;
params.x = 0;
params.y = 100;
windowManager.addView(floatingView, params);
}
@Override
public void onDestroy() {
super.onDestroy();
if (floatingView != null) {
windowManager.removeView(floatingView);
}
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
4.3 悬浮窗的触摸事件穿透
有时候我们需要让悬浮窗不拦截触摸事件,比如做一个透明的监控浮层。这时候就要设置 FLAG_NOT_TOUCHABLE。
params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
嗯,这个场景我在做性能监控 SDK 时用过。用户能看到悬浮窗显示 FPS 数据,但点击操作会穿透到下面的应用。
五、知识体系与核心逻辑
下面这张图总结了本章的核心内容,从 WindowManager 的三种典型用法到它们的配置差异:
六、避坑指南与最佳实践
最后分享几个我踩过的坑:
- Context 泄漏:WindowManager 持有的 View 会强引用 Context,如果用 Activity 的 Context,Activity 销毁后 View 还在,就会泄漏。一定要用 Application Context。
- 权限被撤销:用户可能在设置里手动关闭悬浮窗权限。我建议在 onResume 里检查权限状态,如果被撤销就移除悬浮窗。
- 屏幕旋转适配:横竖屏切换时,params.x 和 params.y 需要重新计算。我一般会监听 Configuration 变化,然后重新调整位置。
- Android 11 的包可见性:如果你的悬浮窗需要获取前台应用包名,别忘了在 AndroidManifest 里声明
QUERY_ALL_PACKAGES权限。
曾经踩过的坑:有一次我在 Service 里 addView 后,没有在 onDestroy 里 removeView,结果 Service 被系统杀死后,悬浮窗变成了一个无法操作的"幽灵"View,最后只能重启手机。从那以后,我每次都在 onDestroy 里做清理,而且还会监听 Application 的生命周期回调做双重保障。
好了,这一章的内容就到这里。悬浮窗的实现其实不复杂,但细节很多。你只要把 WindowManager 的三个核心方法吃透,再配合 LayoutParams 的各种配置,就能应对绝大多数场景了。