詳解如何實現鬥魚、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萬+碼農成長充電第一站,陪有夢想的你一起成長。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章