一、从需求到实现:悬浮球与系统级窗口的实战思路

说实话,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 的三种典型用法到它们的配置差异:

WindowManager 悬浮窗三种实现对比 可拖拽悬浮球 • TYPE_APPLICATION_OVERLAY • FLAG_NOT_FOCUSABLE • 触摸事件处理 • updateViewLayout • 吸附边缘效果 应用场景: 快捷操作、悬浮菜单 自定义 Toast • 自定义布局 • 任意位置显示 • 可控显示时长 • Handler 定时移除 • 自定义动画 应用场景: 提示信息、通知提醒 系统级悬浮窗 • SYSTEM_ALERT_WINDOW • 动态权限申请 • Service 生命周期 • 触摸事件穿透 • 跨应用显示 应用场景: 手机管家、悬浮监控 核心三件套:addView + updateViewLayout + removeView

六、避坑指南与最佳实践

最后分享几个我踩过的坑:

  • 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 的各种配置,就能应对绝大多数场景了。

公众号:蓝海资料掘金营,微信deep3321