最近業務需求須要咱們直播返回或者退出直播間時,開一個小窗口在全局繼續直播視頻,先看效果圖。 java
調研了一下當下主流直播平臺,鬥魚、BiliBili等app,都是用WindowManger作的(這個你能夠在應用權限列表看看有沒有懸浮窗權限,而後把鬥魚的權限禁止,這時候回到鬥魚直播間退出時候就會讓你受權了)即經過WindowManger add一個全局的view,能夠申請權限懸浮在全部應用之上以此來實現全局懸浮窗android
ok,分析完實現原理咱們就開始擼代碼了api
實現懸浮窗難點微信
1:權限申請:一個是6.0及之後要用戶手動受權,由於懸浮窗權限屬於高危權限,二是由於MIUI,底層修改了權限,因此在小米手機上須要特殊處理,還有就是8.0之後權限的定義類型變了下面有代碼會詳解這塊app
2:對於懸浮窗touch 事件的監聽,好比點擊事件和touch事件,若是同時監聽那麼setOnclickListener就沒有效果了,須要區別點擊和touch,還有就是拖動小窗口移動位置,這裏是指針對整個窗體即設置touch事件又設置點擊事件會有衝突機器學習
3:直播組件的初始化,即全局單例的直播窗口,能夠是本身封裝一個自定義View,這個因各自的直播SDK而定,我這用的sdk在插件裏,因此實現起來比較麻煩,可是通常直播sdk(阿里雲或者七牛)均可以用同一個直播組件對象,即在直播頁面銷燬或者返回時把對象傳遞到小窗口裏,實現無縫銜接開啓小窗口直播,不須要從新加載,這裏用EventBus發個消息或者廣播均可以實現ide
一:權限申請工具
首先要在清單文件即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萬+碼農成長充電第一站,陪有夢想的你一塊兒成長。