27、Launcher的Overlay(悬浮层):GameMode与浮动窗口、分屏模式的触发、悬浮小部件

各位好,今天我们来聊聊Launcher里一个很有意思的话题——Overlay,也就是悬浮层。

说实话,我刚开始做Launcher开发时,对Overlay的理解很浅。觉得不就是个浮在桌面上的View嘛,有什么难的?后来踩了不少坑才明白,Overlay的设计直接关系到整个桌面的交互流畅度和系统稳定性。你想想看,用户每天在桌面上划来划去,如果悬浮层处理不好,轻则卡顿,重则ANR,那体验可就全毁了。

Overlay的本质:一个特殊的Window

在Android系统里,Overlay本质上是一个WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY类型的窗口。它悬浮在所有Activity之上,但又不同于系统级的Toast或Dialog。

我个人习惯把Overlay分为三类:

  • 系统级Overlay:比如状态栏、导航栏,由SystemUI管理
  • 应用级Overlay:比如Launcher的GameMode悬浮窗、分屏触发把手
  • 小部件Overlay:比如桌面上的时钟小部件、天气小部件

我们今天重点讲的是第二类和第三类,也就是Launcher自己管理的Overlay。

核心要点:Launcher的Overlay不是随便new一个View就往上丢的。它需要遵循Launcher自身的窗口层级管理,否则会出现触摸事件冲突、焦点抢占等问题。

GameMode与浮动窗口

先说说GameMode。这个功能在手机厂商的定制ROM里很常见,比如游戏时自动屏蔽手势、显示帧率、开启免打扰等。

在Launcher层面,GameMode的Overlay其实是一个半透明的悬浮面板。它通常包含几个元素:

  • 游戏帧率显示
  • 免打扰开关
  • 性能模式切换
  • 快捷截图/录屏按钮

我在项目中遇到过一个问题:游戏启动时,Launcher的GameMode Overlay和游戏自身的UI发生了触摸事件冲突。用户点击游戏内的按钮,结果触发了Overlay上的关闭按钮。排查下来发现是Overlay的WindowManager.LayoutParams设置了FLAG_NOT_TOUCH_MODAL,但没有正确处理事件传递。

正确的做法是这样的:

// 创建GameMode悬浮窗
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
    WindowManager.LayoutParams.WRAP_CONTENT,
    WindowManager.LayoutParams.WRAP_CONTENT,
    WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
        | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
        | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
    PixelFormat.TRANSLUCENT
);

// 关键:设置触摸事件监听
mOverlayView.setOnTouchListener((v, event) -> {
    if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
        // 触摸事件发生在Overlay之外,交给下层处理
        return false;
    }
    // 处理Overlay内部的触摸
    return handleOverlayTouch(event);
});

避坑指南:我曾经在GameMode Overlay上忘记设置FLAG_WATCH_OUTSIDE_TOUCH,结果导致用户点击游戏界面时,Overlay收不到任何事件回调,也就无法自动隐藏。嗯,这个坑我记了好几年。

分屏模式的触发

分屏模式是Android多任务的核心功能之一。在Launcher中,分屏的触发通常有两种方式:

  1. 长按最近任务卡片:弹出分屏选项
  2. 拖拽应用图标到屏幕顶部:直接进入分屏

这里涉及到一个关键的Overlay——分屏触发把手。当用户长按任务卡片时,Launcher会在屏幕顶部显示一个半透明的拖拽区域,这就是一个Overlay。

它的实现逻辑其实不复杂:

// 显示分屏触发把手
private void showSplitScreenHandle() {
    View handleView = LayoutInflater.from(mContext)
        .inflate(R.layout.split_screen_handle, null);
    
    WindowManager.LayoutParams params = new WindowManager.LayoutParams(
        WindowManager.LayoutParams.MATCH_PARENT,
        dpToPx(48), // 把手高度48dp
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
            | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
        PixelFormat.TRANSLUCENT
    );
    // 把手显示在屏幕顶部
    params.gravity = Gravity.TOP;
    params.y = 0;
    
    mWindowManager.addView(handleView, params);
}

但这里有个细节:把手Overlay不能挡住状态栏。我见过有些ROM的实现,把手直接盖住了状态栏,用户想下拉通知栏都点不到。说白了,这就是Overlay的层级没算对。

注意:分屏触发把手的Overlay必须设置FLAG_LAYOUT_IN_SCREEN,但不要设置FLAG_LAYOUT_NO_LIMITS,否则会超出屏幕边界。另外,把手的高度建议不超过48dp,这是Material Design的推荐值。

悬浮小部件

悬浮小部件是Launcher Overlay里最灵活的一种。它不像GameMode那样固定位置,也不像分屏把手那样临时出现。悬浮小部件可以随意拖动,甚至可以跨桌面停留

实现悬浮小部件的核心是处理拖拽事件位置持久化。我给大家看一个简化版的实现:

public class FloatingWidget extends FrameLayout {
    
    private int mLastX, mLastY;
    private boolean isDragging;
    
    public FloatingWidget(Context context) {
        super(context);
        init();
    }
    
    private void init() {
        // 设置触摸监听
        setOnTouchListener((v, event) -> {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    mLastX = (int) event.getRawX();
                    mLastY = (int) event.getRawY();
                    isDragging = false;
                    break;
                    
                case MotionEvent.ACTION_MOVE:
                    int dx = (int) event.getRawX() - mLastX;
                    int dy = (int) event.getRawY() - mLastY;
                    
                    // 更新Overlay位置
                    WindowManager.LayoutParams params = 
                        (WindowManager.LayoutParams) getLayoutParams();
                    params.x += dx;
                    params.y += dy;
                    mWindowManager.updateViewLayout(this, params);
                    
                    mLastX = (int) event.getRawX();
                    mLastY = (int) event.getRawY();
                    isDragging = true;
                    break;
                    
                case MotionEvent.ACTION_UP:
                    if (isDragging) {
                        // 保存位置到SharedPreferences
                        savePosition(getLayoutParams().x, getLayoutParams().y);
                    } else {
                        // 执行点击操作
                        performClick();
                    }
                    break;
            }
            return true;
        });
    }
}

这里有个容易忽略的点:拖拽和点击的区分。用户可能只是想点一下小部件,结果因为手指轻微移动就被判定为拖拽了。我建议设置一个阈值,比如移动超过10像素才认为是拖拽,否则视为点击。

Overlay的生命周期管理

Overlay不是创建了就完事了。它需要跟随Launcher的生命周期进行管理。比如:

  • Launcher进入后台时,Overlay应该自动隐藏
  • Launcher回到前台时,Overlay恢复显示
  • 配置变化(如横竖屏切换)时,Overlay需要重新计算位置

我见过最糟糕的实现是:Overlay创建后就不管了,用户切换了桌面主题,Overlay还停留在原来的位置,结果和新的桌面布局重叠在一起。嗯,这种问题在用户反馈里特别常见。

最佳实践:在Launcher的onPause()中移除Overlay,在onResume()中重新创建。如果Overlay状态需要保持,可以序列化到SharedPreferences中。

知识体系总览

下面这张图是我自己整理的Launcher Overlay知识结构,方便大家理解各个模块之间的关系:

Launcher Overlay 知识体系 Overlay 管理层 GameMode 悬浮窗 分屏触发把手 悬浮小部件 帧率显示 免打扰开关 长按触发 拖拽触发 拖拽移动 位置持久化 生命周期管理 · 触摸事件分发 · 窗口层级控制

从这张图可以看出,Overlay管理的核心就是三个维度:窗口类型触摸事件生命周期。不管你是做GameMode、分屏还是小部件,最终都要回到这三个基础问题上来。

总结

Launcher的Overlay说难不难,说简单也不简单。关键是要理解它在整个窗口体系中的位置,以及它和用户触摸事件的交互方式。我个人建议,如果你刚开始接触这块,可以先从悬浮小部件入手,因为它逻辑最独立,不容易影响到其他模块。等熟悉了Overlay的基本套路,再去做GameMode和分屏触发,会顺手很多。

好了,今天就聊到这里。如果你在实际开发中遇到了Overlay相关的问题,欢迎随时交流。


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