阅读 2603

项目碎片:Android 悬浮窗

背景简介

最近有一个 App 项目的想法雏形,正在做多方面的尝试和研究,过程中发现了一些不太熟悉的知识点,还有 Android 版本更新引起的小变化。于是想在开发的过程中把零散的小知识点记录下来,最终 App 发布的时候,就能看到是多少细小琐碎的知识点一点点堆积成可用的业务功能了。虽然每个知识点都不是很难,但这个过程展示了从学习到实战应用最后的一步,或许可以帮助到初学者。

悬浮窗

悬浮窗是独立于 Activity 和 Fragment 构建的 UI 体系之外的一种向用户展示信息的方式,其功能独特性在于可以提供跨页面甚至跨应用的一致的数据展示,或者提供某些通用快捷操作。早期的手机管家软件里的内存占用显示和快速清理就是一种典型应用,但逐渐被时代淘汰了。现在除了视频小窗播放之外,大部分 App 都不会使用悬浮窗了。

对于开发者来说,悬浮窗仍然是一种不错的调试工具,通过在测试包内添加悬浮窗可以实现不依赖 adb 的 debug,在某些场景下特别好用。

悬浮窗有一个专属的权限控制,用户开启权限后才能显示。

在 AndroidManifest.xml 中注册:

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
复制代码

判断是否已获得权限(API level 23+):

    private fun checkOverlayPermission(context: Context): Boolean{
    	return Build.VERSION.SDK_INT >= 23 && Settings.canDrawOverlays(context)
    }
复制代码

申请权限和处理回调:

    private fun requestOverlayPermission() {
        val intent = Intent(
            Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
            Uri.parse("package:$packageName")
        ) // 按需选择是否需要 FLAG_NEW_TASK
        startActivityForResult(intent, 101)
    }
    
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == 101 && resultCode == RESULT_OK) {
            // checkOverlayPermission() // 可以重新检测避免 resultCode 返回值出错
        }
    }
复制代码

获取到权限之后就可以通过 WindowManager 显示悬浮窗了。悬浮窗本质上只是一个 View,为了随时控制管理悬浮窗,我们可以将整个悬浮窗纳入一个 Service 中,即可避免依赖具体 Activity,又能让悬浮窗 View 拥有合适的生命周期(Service#onCreate 到 Service#onDestroy)。

显示:参数和坐标控制

WindowManager 的 API 不算复杂,配合 API 和 View 的坐标体系知识,就能让悬浮窗显示在合适的位置了。WindowManager 的可控参数都在 WindowManager.LayoutParams 中。

// 一个参考,可以不看
	windowLayoutParams = WindowManager.LayoutParams().apply {
            type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
            } else {
                WindowManager.LayoutParams.TYPE_PHONE
            }
            flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
                    WindowManager.LayoutParams.FLAG_FULLSCREEN
            format = PixelFormat.RGBA_8888
            x = 0
            y = 0
            gravity = Gravity.START or Gravity.TOP
            width = WindowManager.LayoutParams.WRAP_CONTENT
            height = WindowManager.LayoutParams.WRAP_CONTENT
        }
复制代码

type 是一个关键参数,WIndowManager 并不是专门为悬浮窗设计的,设置 type 为 TYPE_APPLICATION_OVERLAY 才是合理的悬浮窗配置。

flags 是对 View 行为的一种配置,可选值很多,有些参数在设置了对应 flag 之后才会生效,比如设置了 FLAG_DIM_BEHIND,dimAmount 才有效。

FLAG 变量名含义
FLAG_DIM_BEHIND添加类似 Dialog 的背景变暗效果,修改 dimAmount 调整变暗的程度
FLAG_NOT_FOCUSABLE不能获取焦点,触屏模式下影响不大
FLAG_NOT_TOUCHABLE不能获取触摸事件,触屏无法交互
FLAG_LAYOUT_NO_LIMITS不限制 View 的范围,可以超出屏幕可见区域

注意,看代码提示的话会注意到还有其他 FLAG_ 命名的常量,并不是所有 FLAG 都适用于悬浮窗,比如 FLAG_FULLSCREEN 在悬浮窗的场景下就是无效的,可以在 WindowManager.LayoutParams.flags 字段的注释中看到全部的 FLAG 类型和含义,大部分都是针对 Activity 的功能。

接下来是对 View 显示的设置,format 要设置成 PixelFormat.RGBA_8888 才能显示透明通道,悬浮窗通常需要有半透明状态,避免遮挡下面的应用。gravity 决定 View 的对齐方式,也就是当前的坐标系原点,设置为 Gravity.START or Gravity.TOP 时原点在左上角,与屏幕坐标系基本一致,x,y 是 View 左上角的点在 gravity 坐标系的坐标。width 和 height 是 View 的尺寸。

切换横竖屏的时候,基于 gravity 的坐标系原点不变,但 x 轴和 y 轴的长度交换了,悬浮窗的坐标不改变的话在新坐标系中可能超出屏幕范围,在 onConfigurationChanged 中需要作出相应的处理。旋转屏幕时,可以通过计算坐标在xy轴上的百分比,保持相对位置大概不变。

【处理前↓】

【处理后↓】

(屏幕旋转的时候录屏似乎不太正确,只关注切换前后的结果就好了)

操作:点击、拖动、展开、吸附

操作的基础是移动悬浮窗 View 的位置,调整位置的方式是通过 WindowManager#updateViewLayout 修改 WindowManager.LayoutParams。

    private fun moveViewTo(x: Float, y: Float){
//        Log.e("asdfg", "move float view to $x, $y")
        windowLayoutParams.apply {
            this.x = x.toInt()
            this.y = y.toInt()
        }
        mWindowManager.updateViewLayout(mFloatView, windowLayoutParams)
    }
复制代码

要实现随手指拖动,必须使用 onTouchListener,点击和自动吸附边缘也可以一起实现了。

拖动的时候,点击位置是 View 中的一点,但设置 View 的位置用的是左上角的点,需要计算两点之间的差距,时刻保持相对距离。

    private var mTouchX = 0f
    private var mTouchY = 0f
    //…
    floatView.setOnTouchListener { v, event ->
            when(event.actionMasked){
                MotionEvent.ACTION_DOWN -> {
                    mTouchX = event.x
                    mTouchY = event.y
                    return@setOnTouchListener true
                }
                MotionEvent.ACTION_MOVE -> {
                    moveViewTo(event.rawX - mTouchX, event.rawY - mTouchY - GlobalStatus.statusBarHeight) 
                    // rawY 包含了状态栏高度,不去掉的话第一次 move 会多移动一个状态栏高度的距离
                }
                MotionEvent.ACTION_UP -> {
                    // ……
                }
                return@setOnTouchListener false
            }

复制代码

在 ACTION_UP 的时候处理点击和自动吸附效果。点击的判断需要跟拖动尽可能分开,拖动了无法看出来的距离或者根本没动的时候才应该判断为点击,一般不会希望拖动的时候触发了点击事件的。

    private var mMoveStartX = 0f
    private var mMoveStartY = 0f
    //……
    
    when(event.actionMasked){
        MotionEvent.ACTION_DOWN -> {
            mMoveStartX = event.rawX
            mMoveStartY = event.rawY
            return@setOnTouchListener true
        }
        MotionEvent.ACTION_UP -> {
            if (abs(event.rawY - mMoveStartX + event.rawX - mMoveStartY) < 2){
                v.performClick() // 点击事件依然通过 setOnClickListener 设置
            }
        }
    }
复制代码

自动吸附也是一次 View 的移动,根据需求可以考虑直接一步到位和做位移动画两种方式。示例代码采取了直接移动的方式,动画可以用 ValueAnimator 配合 WindowManager 实现,需要注意动画过程中 View 被触摸拦住的时候,要第一时间取消动画,避免 View 左右横跳。

// 这里采取吸附到左右边缘的策略,y轴不做限制。

//……
MotionEvent.ACTION_UP -> {
    val edgeX = if (GlobalStatus.screenWidth / 2 > event.rawX) {
        0f
    } else {
        GlobalStatus.screenWidth - mFloatView!!.width.toFloat()
    }
    moveViewTo(edgeX, event.rawY - mTouchY - GlobalStatus.statusBarHeight)
}
//……
复制代码

【效果展示↓】

数据更新

悬浮窗可能需要来自 Service 之外的数据更新,更新悬浮窗显示的数据其实就是其他组件与 Service 之间的数据传递,一般来说使用 startService 反复触发 Service#onStartCommand,在 Intent 中传递数据即可。

知识扩展

为什么没给悬浮窗 View 设置触摸事件,被挡住的 App 的 View 也不会响应触摸事件?

事件分发机制中,事件是从 Window 开始的。使用 WindowManager 添加的悬浮窗 View 与下面显示的 Activity 并不在同一个 Window 中,未设置 FLAG_NOT_TOUCHABLE 的时候,触摸事件直接被派给了悬浮窗的 Window,即使不消耗掉触摸事件,这个事件也不会再交给下面的 Activity 的 Window 了。

下一步

接入 AccessibilityService,在悬浮窗显示当前 Activity 的名字(为调试用,非 App 功能)。关于 AccessibilityService 的使用说明之前有写过,如果发现新的问题会重新编辑以前的文章。传送门:Android小记 —— AccessibilityService

接入 MediaProjection,按一定规则读取屏幕内显示的信息(图片形式)。

发现文章有错误或者任何疑问,一定要评论告诉我啊,感谢每一个读者。那么,ko~ko~da~yo~

文章分类
Android
文章标签