zoukankan      html  css  js  c++  java
  • 详解如何实现斗鱼、B站等全局悬浮窗直播小窗口

    最近业务需求需要我们直播返回或者退出直播间时,开一个小窗口在全局继续直播视频,先看效果图。


    调研了一下当下主流直播平台,斗鱼、BiliBili等app,都是用WindowManger做的(这个你可以在应用权限列表看看有没有悬浮窗权限,然后把斗鱼的权限禁止,这时候回到斗鱼直播间退出时候就会让你授权了)即通过WindowManger add一个全局的view,可以申请权限悬浮在所有应用之上以此来实现全局悬浮窗

    ok,分析完实现原理我们就开始撸代码了

    实现悬浮窗难点

    1:权限申请:一个是6.0及以后要用户手动授权,因为悬浮窗权限属于高危权限,二是因为MIUI,底层修改了权限,所以在小米手机上需要特殊处理,还有就是8.0以后权限的定义类型变了下面有代码会详解这块

    2:对于悬浮窗touch 事件的监听,比如点击事件和touch事件,如果同时监听那么setOnclickListener就没有效果了,需要区别点击和touch,还有就是拖动小窗口移动位置,这里是指针对整个窗体即设置touch事件又设置点击事件会有冲突

    3:直播组件的初始化,即全局单例的直播窗口,可以是自己封装一个自定义View,这个因各自的直播SDK而定,我这用的sdk在插件里,所以实现起来比较麻烦,但是一般直播sdk(阿里云或者七牛)都可以用同一个直播组件对象,即在直播页面销毁或者返回时把对象传递到小窗口里,实现无缝衔接开启小窗口直播,不需要重新加载,这里用EventBus发个消息或者广播都可以实现

    一:权限申请

    首先要在清单文件即AndroidManifest文件声明 悬浮窗权限

    然后我们悬浮窗触发的时机是在直播页面返回的时候,那也就是说可以在onDestory()或者finsh()时候去做权限申请

    注:因为6.0以后是高危权限,所以代码是拿不到权限的,需要跳到权限申请列表让用户授权

    if (isLiveShow) {
        if (Build.VERSION.SDK_INT >= 23) {
            if (!Settings.canDrawOverlays(getContext())) {
                //没有悬浮窗权限,跳转申请
                Toast.makeText(getApplicationContext(), "请开启悬浮窗权限", Toast.LENGTH_LONG).show();
                Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
                startActivity(intent);
            } else {
                initLiveWindow();
            }
        } else {
            //6.0以下 只有MUI会修改权限
            if (MIUI.rom()) {
                if (PermissionUtils.hasPermission(getContext())) {
                    initLiveWindow();
                } else {
                    MIUI.req(getContext());
                }
            } else {
                initLiveWindow();
            }
        }
    }
    

    而低版本一般是不需要用户授权的除了MIUI,所以我们需要先判断是否是MIUI系统,然后判断MIUI版本,然后不同的版本对应不同的权限申请姿势,如果你不这么做,那么恭喜你在低版本(低于6.0)的小米手机上不是返回跳转权限崩溃,因为底层改了授权列表类或者是根本不会跳授权没有反应,

    //6.0以下 只有MUI会修改权限
    if (MIUI.rom()) {
        if (PermissionUtils.hasPermission(getContext())) {
            initLiveWindow();
        } else {
            MIUI.req(getContext());
        }
    } else {
        initLiveWindow();
    }
    

    先判断是否是MIUI系统

    public static boolean rom() {
        return Build.MANUFACTURER.equals("Xiaomi");
    }
    

    然后根据不同版本,不同的授权姿势

    /**
     * Description:
     * Created by PangHaHa on 18-7-25.
     * Copyright (c) 2018 PangHaHa All rights reserved.
     *
     *  /**
     * <p>
     * 需要清楚:一个MIUI版本对应小米各种机型,基于不同的安卓版本,但是权限设置页跟MIUI版本有关
     * 测试TYPE_TOAST类型:
     * 7.0:
     * 小米      5        MIUI8         -------------------- 失败
     * 小米   Note2       MIUI9         -------------------- 失败
     * 6.0.1
     * 小米   5                         -------------------- 失败
     * 小米   红米note3                  -------------------- 失败
     * 6.0:
     * 小米   5                         -------------------- 成功
     * 小米   红米4A      MIUI8         -------------------- 成功
     * 小米   红米Pro     MIUI7         -------------------- 成功
     * 小米   红米Note4   MIUI8         -------------------- 失败
     * <p>
     * 经过各种横向纵向测试对比,得出一个结论,就是小米对TYPE_TOAST的处理机制毫无规律可言!
     * 跟Android版本无关,跟MIUI版本无关,addView方法也不报错
     * 所以最后对小米6.0以上的适配方法是:不使用 TYPE_TOAST 类型,统一申请权限
     */
    
    public class MIUI {
    
        private static final String miui = "ro.miui.ui.version.name";
        private static final String miui5 = "V5";
        private static final String miui6 = "V6";
        private static final String miui7 = "V7";
        private static final String miui8 = "V8";
        private static final String miui9 = "V9";
    
    
    
        public static boolean rom() {
            return Build.MANUFACTURER.equals("Xiaomi");
        }
    
        private static String getProp() {
            return Rom.getProp(miui);
        }
    
    
        public static void req(final Context context) {
            switch (getProp()) {
                case miui5:
                    reqForMiui5(context);
                    break;
                case miui6:
                case miui7:
                    reqForMiui67(context);
                    break;
                case miui8:
                case miui9:
                    reqForMiui89(context);
                    break;
            }
    
        }
    
    
        private static void reqForMiui5(Context context) {
            String packageName = context.getPackageName();
            Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
            Uri uri = Uri.fromParts("package", packageName, null);
            intent.setData(uri);
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            if (isIntentAvailable(intent, context)) {
                context.startActivity(intent);
            }
        }
    
        private static void reqForMiui67(Context context) {
            Intent intent = new Intent("miui.intent.action.APP_PERM_EDITOR");
            intent.setClassName("com.miui.securitycenter",
                    "com.miui.permcenter.permissions.AppPermissionsEditorActivity");
            intent.putExtra("extra_pkgname", context.getPackageName());
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            if (isIntentAvailable(intent, context)) {
                context.startActivity(intent);
            }
        }
    
        private static void reqForMiui89(Context context) {
            Intent intent = new Intent("miui.intent.action.APP_PERM_EDITOR");
            intent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.PermissionsEditorActivity");
            intent.putExtra("extra_pkgname", context.getPackageName());
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            if (isIntentAvailable(intent, context)) {
                context.startActivity(intent);
            } else {
                intent = new Intent("miui.intent.action.APP_PERM_EDITOR");
                intent.setPackage("com.miui.securitycenter");
                intent.putExtra("extra_pkgname", context.getPackageName());
                intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                if (isIntentAvailable(intent, context)) {
                    context.startActivity(intent);
                }
            }
        }
    
    
        /**
         * 有些机型在添加TYPE-TOAST类型时会自动改为TYPE_SYSTEM_ALERT,通过此方法可以屏蔽修改
         * 但是...即使成功显示出悬浮窗,移动的话也会崩溃
         */
        private static void addViewToWindow(WindowManager wm, View view, WindowManager.LayoutParams params) {
            setMiUI_International(true);
            wm.addView(view, params);
            setMiUI_International(false);
        }
    
    
        private static void setMiUI_International(boolean flag) {
            try {
                Class BuildForMi = Class.forName("miui.os.Build");
                Field isInternational = BuildForMi.getDeclaredField("IS_INTERNATIONAL_BUILD");
                isInternational.setAccessible(true);
                isInternational.setBoolean(null, flag);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
    }
    

    以及利用Runtime 执行命令 getprop 来获取手机的版本型号,因为MIUI不同的版本对应的底层都不一样,毫无规律可言!

    public class Rom {
    
        static boolean isIntentAvailable(Intent intent, Context context) {
            return intent != null && context.getPackageManager().queryIntentActivities(
                    intent, PackageManager.MATCH_DEFAULT_ONLY).size() > 0;
        }
    
    
        static String getProp(String name) {
            BufferedReader input = null;
            try {
                Process p = Runtime.getRuntime().exec("getprop " + name);
                input = new BufferedReader(new InputStreamReader(p.getInputStream()), 1024);
                String line = input.readLine();
                input.close();
                return line;
            } catch (IOException ex) {
                return null;
            } finally {
                if (input != null) {
                    try {
                        input.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
    

    权限申请的工具类

    public class PermissionUtils {
    
        public static boolean hasPermission(Context context) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                return Settings.canDrawOverlays(context);
            } else {
                return hasPermissionBelowMarshmallow(context);
            }
        }
    
        public static boolean hasPermissionOnActivityResult(Context context) {
            if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) {
                return hasPermissionForO(context);
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                return Settings.canDrawOverlays(context);
            } else {
                return hasPermissionBelowMarshmallow(context);
            }
        }
    
        /**
         * 6.0以下判断是否有权限
         * 理论上6.0以上才需处理权限,但有的国内rom在6.0以下就添加了权限
         * 其实此方式也可以用于判断6.0以上版本,只不过有更简单的canDrawOverlays代替
         */
        @RequiresApi(api = Build.VERSION_CODES.KITKAT)
        static boolean hasPermissionBelowMarshmallow(Context context) {
            try {
                AppOpsManager manager = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
                Method dispatchMethod = AppOpsManager.class.getMethod("checkOp", int.class, int.class, String.class);
                //AppOpsManager.OP_SYSTEM_ALERT_WINDOW = 24
                return AppOpsManager.MODE_ALLOWED == (Integer) dispatchMethod.invoke(
                        manager, 24, Binder.getCallingUid(), context.getApplicationContext().getPackageName());
            } catch (Exception e) {
                return false;
            }
        }
    
    
        /**
         * 用于判断8.0时是否有权限,仅用于OnActivityResult
         * 针对8.0官方bug:在用户授予权限后Settings.canDrawOverlays或checkOp方法判断仍然返回false
         */
        @RequiresApi(api = Build.VERSION_CODES.M)
        private static boolean hasPermissionForO(Context context) {
            try {
                WindowManager mgr = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
                if (mgr == null) return false;
                View viewToAdd = new View(context);
                WindowManager.LayoutParams params = new WindowManager.LayoutParams(0, 0,
                        Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ?
                                WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY : WindowManager.LayoutParams.TYPE_SYSTEM_ALERT,
                        WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                        PixelFormat.TRANSPARENT);
                viewToAdd.setLayoutParams(params);
                mgr.addView(viewToAdd, params);
                mgr.removeView(viewToAdd);
                return true;
            } catch (Exception e) {
                e.printStackTrace();
            }
            return false;
        }
    
    }
    

    二:弹窗的初始化,以及touch事件的监听

    首先我们需要明白一点 windowManger的源码,只有三个方法

    package android.view;
    
    /** Interface to let you add and remove child views to an Activity. To get an instance
      * of this class, call {@link android.content.Context#getSystemService(java.lang.String) Context.getSystemService()}.
      */
    public interface ViewManager
    {
        /**
         * Assign the passed LayoutParams to the passed View and add the view to the window.
         * <p>Throws {@link android.view.WindowManager.BadTokenException} for certain programming
         * errors, such as adding a second view to a window without removing the first view.
         * <p>Throws {@link android.view.WindowManager.InvalidDisplayException} if the window is on a
         * secondary {@link Display} and the specified display can't be found
         * (see {@link android.app.Presentation}).
         * @param view The view to be added to this window.
         * @param params The LayoutParams to assign to view.
         */
        public void addView(View view, ViewGroup.LayoutParams params);
        public void updateViewLayout(View view, ViewGroup.LayoutParams params);
        public void removeView(View view);
    }
    

    看名字就知道,增加,更新,删除

    然后我们需要自定义一个View 通过addView 添加到windowManger 上,先上关键代码
    需要注意两点

    A、8.0以后权限定义变了 需要修改type

    //设置type.系统提示型窗口,一般都在应用程序窗口之上.
    if (Build.VERSION.SDK_INT >= 26) { //8.0新特性
        params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
    } else {
        params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
    }
    

    B、参考系和初始坐标的概念,参考系Gravity 即以哪点为原点而不是初始化弹窗相对于屏幕的位置!其中需要注意的是其Gravity属性:
    注意:Gravity不是说你添加到WindowManager中的View相对屏幕的几种放置,
    而是说你可以设置你的参考系 !
    例如:mWinParams.gravity= Gravity.LEFT | Gravity.TOP;
    意思是以屏幕左上角为参考系,那么屏幕左上角的坐标就是(0,0),
    这是你后面摆放View位置的唯一依据.当你设置为mWinParams.gravity = Gravity.CENTER;
    那么你的屏幕中心为参考系,坐标(0,0).一般我们用屏幕左上角为参考系.

    C、touch事件的处理,由于我们View先相应touch事件,之后才会传递到onClick点击事件,如果touch拦截了就不会传递到下一级了

    1,我们通过手指移动后的位置,添加偏移量,然后windowManger 调用 updateViewlayout 更新界面 达到实时拖动更改位置

    2,通过计算上一次触碰屏幕位置和这一次触碰屏幕的偏移量,x轴和y轴的偏移量都小于2像素,认定为点击事件,执行整个窗体的点击事件,否则执行整个窗体的touch事件

    //主动计算出当前View的宽高信息.
    toucherLayout.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
    
    //处理touch
    toucherLayout.setOnTouchListener(new View.OnTouchListener() {@Override public boolean onTouch(View view, MotionEvent event) {
    
            switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                isMoved = false;
                // 记录按下位置
                lastX = event.getRawX();
                lastY = event.getRawY();
    
                start_X = event.getRawX();
                start_Y = event.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                isMoved = true;
                // 记录移动后的位置
                float moveX = event.getRawX();
                float moveY = event.getRawY();
                // 获取当前窗口的布局属性, 添加偏移量, 并更新界面, 实现移动
                params.x += (int)(moveX - lastX);
                params.y += (int)(moveY - lastY);
                windowManager.updateViewLayout(toucherLayout, params);
    
                lastX = moveX;
                lastY = moveY;
                break;
            case MotionEvent.ACTION_UP:
    
                float fmoveX = event.getRawX();
                float fmoveY = event.getRawY();
    
                if (Math.abs(fmoveX - start_X) < offset && Math.abs(fmoveY - start_Y) < offset) {
                    isMoved = false;
                    remove(context);
                    leaveCast(context);
                    String PARAM_CIRCLE_ID = "param_circle_id";
                    Intent intent = new Intent();
                    intent.putExtra(PARAM_CIRCLE_ID, circle_id);
                    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                    intent.setComponent(new ComponentName(RePlugin.getHostContext().getPackageName(), "com.sina.licaishicircle.sections.circledetail.CircleActivity"));
                    context.startActivity(intent);
                } else {
                    isMoved = true;
                }
                break;
            }
            // 如果是移动事件, 则消费掉; 如果不是, 则由其他处理, 比如点击
            return isMoved;
        }
    

    三:全局单例直播以及直播窗口的构造复用

    因为项目用了360的Replugin 插件化管理方式,而且直播组件都是在插件中,需要反射获取直播弹窗工具类

    public class LiveWindowUtil {
    
        private static class Hold {
            public static LiveWindowUtil instance = new LiveWindowUtil();
        }
    
        public static LiveWindowUtil getInstance() {
            return Hold.instance;
        }
    
        public LiveWindowUtil() {
            //代码使用插件Fragment
            RePlugin.fetchContext("sina.com.cn.courseplugin");
        }
    
        private Object o;
        private Class clazz;
        public void init(Context context, Map map) {
            try {
                ClassLoader classLoader = RePlugin.fetchClassLoader("sina.com.cn.courseplugin");//获取插件的ClassLoader
                clazz = classLoader.loadClass("sina.com.cn.courseplugin.tools.LiveUtils");
                o = clazz.newInstance();
                Method method = clazz.getMethod("initLive", Context.class, Map.class);
                method.invoke(o, context, map);
    
            }catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }catch (NullPointerException e){
                e.printStackTrace();
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            }
        }
    
        public void remove(Context context) {
            Method method = null;
            try {
                if(clazz != null && o != null) {
                    method = clazz.getMethod("remove", Context.class);
                    method.invoke(o,context);
                }
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
    
        }
    }
    

    总结一下,主要还是需要拿到权限,然后传递直播组件复用到小窗口,监听悬浮窗的touch事件,权限的坑比较大一点除了MIUI可能别的品牌手机也会有低于6.0莫名其妙拿不到权限。

    原创作者:庞哈哈12138,原文链接:https://www.jianshu.com/p/e953f5b924e1
    在这里插入图片描述
    欢迎关注我的微信公众号「码农突围」,分享Python、Java、大数据、机器学习、人工智能等技术,关注码农技术提升•职场突围•思维跃迁,20万+码农成长充电第一站,陪有梦想的你一起成长。

  • 相关阅读:
    zabbix server 给agent 添加 CPU 监听笔记
    解决windows文件名过长无法删除的问题
    一个大的OpenAPI Specification yaml 分割成小的yaml文件
    win10 移除2345输入法
    树莓派登录ssh 很慢和开机启动tightvncserver
    docker gitlab-ce 容器root密码重置小记
    Arduino Nokia 5110 LCD屏幕使用小记
    Docker Compose文件下载慢的处理笔记
    Ubuntu 18.04 安装rtorrent笔记
    vagrant Which interface should the network bridge to?
  • 原文地址:https://www.cnblogs.com/hejunlin/p/12535508.html
Copyright © 2011-2022 走看看