视频直播小窗口(悬浮窗)展示方案

一、场景

在日常使用app的过程中,基本上会遇见把窗口缩小的场景出现,比如B站看视频,微信直播,微信视频通话等,主动缩小或者返回都会触发悬浮窗体的显示,如下:

b站 微信视频号
1658150162590177_.gif 1658151024239872_.gif

那么实现这种效果一般的思路是怎样,可以继续往下看。

二、思考点

针对窗口缩小或者悬浮窗需要考虑几个重要的点:

  1. 悬浮窗体的比例以及层级,层级要在statusbar之下且在activity之上,这样才能保证其不会被其他业务界面覆盖;
  2. 悬浮框显示后,内部的视频内容如何无缝衔接继续显示;

三、思路

实现整个悬浮窗的核心在于WindowManager与window的交互。

WindowManager是app与window通信的一个接口。从语义上看WindowManager是用来管理window的一个接口,那么window又是什么?其实我们常见的Dialog、Popup、StatusBar等本质就是window,window是一个抽象类,相当于一个联盟,Dialog、Popup等view只有依附在window这个联盟才能发挥功能,而WindowManager就像是联盟的会长,负责与子view等会员通信,并且能够对他们进行增加、更新和删除。

实现悬浮窗就需要配置不同的参数于属性,关于WindowManager参数、属性的详细资料可以参看juejin.cn/post/684490…

从上面描述可以知道利用addView将View添加在window上,同样的,WindowManager.LayoutParams.type可以设置View的层级,防止被其他业务界面所覆盖。

四、实现

4.1、请求悬浮窗权限

关于悬浮窗的权限,

  • 当API<18时,系统默认是有悬浮窗的权限,不需要去处理;
  • 当API >= 23时,需要在AndroidManifest中申请权限,为了防止用户手动在设置中取消权限,需要在每次使用时check一下是否有悬浮窗权限存在;
  • 当API > 25时,系统直接禁止用户使用TYPE_TOAST创建悬浮窗。
  <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
/**
* 检查悬浮窗权限
* API <18,默认有悬浮窗权限,不需要处理。无法接收无法接收触摸和按键事件
* API >= 19 ,可以接收触摸和按键事件
* API >=23,需要在AndroidManifest中申请权限,为了防止用户手动在设置中取消权限,需要在每次使用时check一下是否有悬浮窗权限存在;
* API >25,系统直接禁止用户使用TYPE_TOAST创建悬浮窗。
*/
private fun requestPermission(context: Context?, op: Int): Boolean {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            // 6.0动态申请悬浮窗权限
            if (!Settings.canDrawOverlays(context)) {
                val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
                intent.data = Uri.parse("package:" + context!!.packageName)
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                context.startActivity(intent)
                return false
            }
            return true
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            val manager = context!!.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
            return try {
                val method = AppOpsManager::class.java.getDeclaredMethod(
                    "checkOp",
                    Int::class.javaPrimitiveType,
                    Int::class.javaPrimitiveType,
                    String::class.java
                )
                AppOpsManager.MODE_ALLOWED == method.invoke(
                    manager,
                    op,
                    Binder.getCallingUid(),
                    context.packageName
                ) as Int
            } catch (e: Exception) {
                false
            }
        }
        return true
    }

4.2、 窗体UI

悬浮窗UI一般就两个部分,一个部分用来装载视频流,另一部分是关闭窗口的按钮,

<androidx.constraintlayout.widget.ConstraintLayout >

    <RelativeLayout
        android:id="@+id/rl_display_container"
        android:layout_width="0dp"
        android:layout_height="0dp" />

    <Button
        android:id="@+id/btn_close"
        android:layout_width="25dp"
        android:layout_height="25dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

其中RelativeLayout作为一个装载视频流的容器,在进行窗口切换时,会将视频的View 如TextureView进行添加。

4.3、初始化悬浮窗

初始化悬浮窗指的是设置其大小以及相应的层级。

四、思路这一节中就已经说明,大小尺寸以及层级可以使用WindowManager.LayoutParamsxygravitytype等属性进行设置。

private fun initFloatWindow() {
    //屏幕宽度
    val screenWidth: Int = getScreenWidth()
    val rect = FloatWindowRect(screenWidth - 400, 0, 400, 600)
    mWindowManager = mContext?.applicationContext?.getSystemService(Context.WINDOW_SERVICE) as WindowManager
    mWindowParams = WindowManager.LayoutParams()
    mWindowParams?.let {
        //设置层级
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            it.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
        } else {
            it.type = WindowManager.LayoutParams.TYPE_PHONE
        }
        it.flags =
            WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
        it.gravity = Gravity.CENTER_VERTICAL
        it.format = PixelFormat.TRANSLUCENT
        it.x = rect.x
        it.y = rect.y
        it.width = rect.width
        it.height = rect.height
    }
}

窗体大小可以依据自身业务需要进行设定,同时可以看到将Flag属性设置为了FLAG_NOT_FOCUSABLEFLAG_NOT_TOUCH_MODAL

  • FLAG_NOT_FOCUSABLE:表示此窗口范围内的事件自己处理,范围外的事件依旧为原窗口处理;例如点击该窗口外的view,依然会有响应。另外只要设置了此Flag,都将会启用FLAG_NOT_TOUCH_MODAL,最后,设置了该Flag就表示window不会与输入方法交互,例如该window上有EditView,点击EditView是不会弹出软键盘的。

  • FLAG_NOT_TOUCH_MODAL:设置了该Flag,新window范围外的view也是可以响应touch事件。

不管是视频播放、直播还是视频通话也好,悬浮窗肯定是不能低于普通activity的层级,不然就会被覆盖,所以这里提到了一个属性:

  • TYPE_APPLICATION_OVERLAY

这个层级所代表的意思是覆盖于所有activity window之上,但低于关键系统window(如状态栏,IME等),同时系统还会调整使用该窗口类型的进程,减少被低内存杀死的几率,这种恰恰符合我们悬浮窗的场景(具体层级视自身业务而定)。

4.4、显示并加载视频

fun showFloatWindow(view: View): Boolean {
    
    if (!requestPermission(mContext, OP_SYSTEM_ALERT_WINDOW)) {
        Toast.makeText(mContext, "请手动打开悬浮窗口权限", Toast.LENGTH_SHORT).show()
        return false
    }
    try {
        // 设置悬浮窗口位置和大小
        val views = view as ViewGroup
        val layoutParams = views.getChildAt(1).layoutParams
        mWindowParams?.width = layoutParams.width
        mWindowParams?.height = layoutParams.height
        
        val parent = view.getParent() as ViewGroup
        parent.removeView(view)
        mLayoutDisplayContainer!!.addView(view)
        mWindowManager?.addView(mViewRoot, mWindowParams)
    } catch (e: Exception) {
        Toast.makeText(mContext, "悬浮播放失败", Toast.LENGTH_SHORT).show()
        return false
    }
    return true
}

当用户想要显示悬浮窗口时,直接将装载视频流的View add到4.2提到的RelativeLayout中,并且重新设置大小和位置。

4.5、悬浮窗随手指移动

悬浮窗随手指移动的逻辑直接交给View 的Touch事件,

private inner class FloatWindowOnTouchListener : OnTouchListener {
    private var startX = 0
    private var startY = 0
    private var x = 0
    private var y = 0
    override fun onTouch(view: View, event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                x = event.rawX.toInt()
                y = event.rawY.toInt()
                startX = x
                startY = y
            }
            MotionEvent.ACTION_MOVE -> {
                val nowX = event.rawX.toInt()
                val nowY = event.rawY.toInt()
                val movedX = nowX - x
                val movedY = nowY - y
                x = nowX
                y = nowY
                //手指移动,重新计算x,y,并更新
                mWindowParams?.x = mWindowParams?.x?.plus(movedX)
                mWindowParams?.y = mWindowParams?.y?.plus(movedY)
                mWindowManager?.updateViewLayout(view, mWindowParams)
            }
            MotionEvent.ACTION_UP -> if (Math.abs(x - startX) < 5 && Math.abs(y - startY) < 5) { 
            //手指没有滑动视为点击
               //在这里做关闭窗口的操作
            }
            else -> {
            }
        }
        return true
    }
}

在DOWN事件下,记录手指点击的位置,MOVE事件中,随手指滑动,记录移动的距离重新计算x,y值并直接更新,在手指UP事件下,判断手指移动的距离,如果只是点击的话,即关闭悬浮窗,展开大屏。

关于悬浮窗的展示,思路基本上如上所述,当然,其中的一些细节需要和自身业务相结合。

本文到这里就结束了,有问题可留言评论区,咱们下篇见~

推荐阅读:

Android P下WindowManager与LayoutParams的详解
探究ANR原理-是谁控制了ANR的触发时间

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

Supongo que te gusta

Origin juejin.im/post/7122269430725214245
Recomendado
Clasificación