1. 程式人生 > >Android 應用內懸浮控制元件實踐總結

Android 應用內懸浮控制元件實踐總結

2017/12/30 更新:

FloatWindow 庫已儘量去適配各國產機型,如果你想幫助此庫,評論告訴我列表中沒有的機型,尤其是適配出現問題的機型,謝謝,以下為目前測試通過的機型:

8.0:
一加 三代
華為 Mate9Pro
小米 6
7.1.1:
小米 6
小米 紅米5Plus
OPPO R9s
7.0:
小米 5 V8
小米 Note2 V9
華為 Mate9
華為 榮耀V9
三星 S7Edge
一加 3T
6.0.1
小米 5
小米 紅米note3
OPPO A57
6.0:
小米 5
小米 紅米4A V8
小米 紅米Pro V7
小米 紅米Note4 V8
華為 麥芒5
5.1.1:


VIVO X7Plus
OPPO R9Plusm A
小米 紅米3
5.1:
美圖 V4
5.0.2:
小米 紅米Note3 V8
華為 榮耀7
5.0.1:
三星 Note4
5.0:
三星 Note3
三星 GALAXYNote3
VIVO Y33
OPPO R7Plus
4.4.4:
小米 紅米note
小米 4
小米 紅米2A
OPPO R7s
VIVO Y23L
4.4.2:
華為 暢玩4C
華為 Mate7

原文:

本文已授權微信公眾號:鴻洋(hongyangAndroid)原創首發。

在工作中遇到一個需求,需要在整個應用的上層懸浮顯示控制元件,目標效果如下圖:

這裡寫圖片描述

首先想到的是申請懸浮窗許可權,OK~ 開啟搜尋引擎,映入眼簾的並不是如何申請,而是“Android 懸浮窗許可權各機型各系統適配大全、Android 繞過許可權顯示懸浮窗…”,為什麼懸浮窗許可權會有這麼多坑呢?懸浮窗可以在桌面顯示,被惡意軟體用來偷偷彈廣告怎麼辦?作為一個系統級別的特殊許可權,這是它應有的高傲 - -

正確引導使用者開啟懸浮窗許可權才是標準做法,若這就是定論的話這篇文章也沒必要寫了,我們繞過懸浮窗許可權直接去顯示,大多數是為了優化使用者體驗,並不是惡意的。有時我們只想在自己的應用內實現懸浮窗,然而 Andorid 並沒有提供這樣的方法,也只好退而求其此的去使用系統級別的懸浮窗許可權。

OK ,既然可以繞過許可權申請,再重新定義一下需求:

 儘量繞過申請許可權,實現在 app 指定介面顯示懸浮控制元件,控制元件的位置不需要改變

怎麼繞過懸浮窗許可權呢?網上大多數通過 WindowManager 新增一個 TYPE_TOAST 型別的控制元件,如下:

    WindowManager windowManager = (WindowManager) 
            applicationContext.getSystemService(Context.WINDOW_SERVICE);
    WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams();
    layoutParams.type = WindowManager.LayoutParams.TYPE_TOAST;
    windowManager.addView(view, layoutParams);

而系統在新增 TYPE_TOAST 型別控制元件時預設不需要許可權,從而可以繞過懸浮窗許可權。但是這種做法並不適配所有機型,比如我親測過的小米(MIUI8) 和 Nexus 7.1.1 機型上就會報錯 Permission Denial ,需要申請許可權,之前這種方式或許可行,但現在肯定不行。

放棄 TYPE_TOAST 方案,不能往窗口裡新增檢視,那隻能乖乖的申請許可權了嗎?這時你可能想到往所有 Activity 的固定位置新增檢視,模擬“懸浮”效果,比如要實現文章開頭的效果,只需要進入新 Activity 時初始化旋轉的角度,讓其在視覺上連續就行了。

但是要考慮一個問題,在切換 Activity 時舊 Activity 的懸浮控制元件是要銷燬的,新 Activity 的懸浮控制元件是要生成的,也就是說在切換 Activity 時這個懸浮控制元件是會短暫的消失一下,那把 Activity 切換效果設定為淡入淡出可以嗎,在視覺上是可以實現的,但是嚴格限制了 Activity 的切換效果,不可行。那還有什麼方法可以實現切換 Activity 時控制元件在視覺上連續嗎?如果你用過共享元素動畫的話,便有答案了。

懸浮控制元件在哪裡新增呢?可以在 BaseActivity 裡,也可以為 Application 註冊 Activity 生命週期回撥,下面通過後者實現,在 Application 中為每個 Activity 新增懸浮控制元件:

public class BaseApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();

        registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {

            @Override
            public void onActivityStarted(Activity activity) {
              if(findViewById(R.id.floating_view_id) != null) return;
              View view = LayoutInflater.from(activity).inflate(R.layout.floating_view, null);
              view.setId(R.id.floating_view_id);
              if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                  view.setTransitionName(activity.getString(R.string.transitionName));
              }
              WindowManager.LayoutParams params = new WindowManager.LayoutParams();
              params.gravity = Gravity.TOP | Gravity.LEFT;
              activity.addContentView(mPopView, mLayoutParams);
}

//省略...

切換 Activity 時啟用共享元素動畫:

   Intent intent = new Intent(this, Main2Activity.class);
   View view = findViewById(R.id.floating_view_id);
   if ( view != null) {
       ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(
               this,view, getString(R.string.transitionName));
       ContextCompat.startActivity(this, intent, options.toBundle());
   }else{
       startActivity(intent);
   }

這樣就解決了切換 Activity 時懸浮控制元件短暫消失一下這個問題,然後在新增懸浮控制元件時,初始化旋轉角度就可以實現文章開頭的效果了。但是這種方式存在很大的缺陷,首先就是它不相容 Andorid 5.0 以下,看看 4.4 那百分之十幾的小夥伴,嗯~ 缺陷很大,其次還有一個致命缺陷,不管把懸浮控制元件設為 INVISIBLE 還是透明,只要已經添加了此控制元件,在切換時它都會先顯示一下,這應該是共享元素動畫本身的一個 BUG .

OK~ 放棄共享元素方案, 真的繞不過申請許可權了嗎? 再考慮一下 TYPE_TOAST 方案, 為什麼它失效了呢? 應該是系統對此型別的控制元件加了限制, 對待 TYPE_TOAST 不再跳過檢查許可權步驟, 而是像 TYPE_PHONE 之類一視同仁, 那為什麼我們的 toast 卻可以跳過呢? toast 不就是 TYPE_TOAST 型別的檢視嗎? 不管如何, 反正 toast 是不需要許可權的, 那就嘗試從 toast 入手. OK~ ,現在的關鍵詞是 自定義 toast .

檢視 Toast 類原始碼, 有一個方法眼前一亮:

    /**
     * Set the view to show.
     * @see #getView
     */
    public void setView(View view) {
        mNextView = view;
    }

Toast 是可以自定義檢視的, 這為自定義 toast 提供了可能性, 但是顯示時長只能設定為 LENGTH_SHORT 或 LENGTH_LONG ,我們需要的是無限時長, 沒有方法實現, 除非反射之類的怪招了~ 嗯~ 下面奉上通過反射實現無限時長 toast 的完整程式碼 :


/**
 * 自定義 toast , 無限時長
 * 可設定顯示位置 尺寸
 */

class AlwaysShowToast  {


    private Toast toast;

    private Object mTN;
    private Method show;
    private Method hide;

    private int mWidth = WindowManager.LayoutParams.WRAP_CONTENT;
    private int mHeight = WindowManager.LayoutParams.WRAP_CONTENT;


    public FixedFloatToast(Context applicationContext) {
        toast = new Toast(applicationContext);
    }


    public void setView(View view, int width, int height) {
        mWidth = width;
        mHeight = height;
        setView(view);
    }


    public void setView(View view) {
        toast.setView(view);
        initTN();
    }


    public void setGravity(int gravity, int xOffset, int yOffset) {
        toast.setGravity(gravity, xOffset, yOffset);
    }


    public void show() {
        try {
            show.invoke(mTN);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    public void hide() {
        try {
            hide.invoke(mTN);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    /**
     * 利用反射設定 toast 引數
     */
    private void initTN() {
        try {
            Field tnField = toast.getClass().getDeclaredField("mTN");
            tnField.setAccessible(true);
            mTN = tnField.get(toast);
            show = mTN.getClass().getMethod("show");
            hide = mTN.getClass().getMethod("hide");

            Field tnParamsField = mTN.getClass().getDeclaredField("mParams");
            tnParamsField.setAccessible(true);
            WindowManager.LayoutParams params = (WindowManager.LayoutParams) tnParamsField.get(mTN);
            params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
                    | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
            params.width = mWidth;
            params.height = mHeight;
            Field tnNextViewField = mTN.getClass().getDeclaredField("mNextView");
            tnNextViewField.setAccessible(true);
            tnNextViewField.set(mTN, toast.getView());

        } catch (Exception e) {
            e.printStackTrace();
        }
    }


}

有了這個自定義 toast , 跳過許可權顯示懸浮窗就非常容易了, 理論上可以相容任意版本,任意機型, 因為這只是一個普通的 toast , 系統沒理由不允許一個 toast 顯示的~ 然而… 親測在 Nexus7.1.1 及以上不顯示 , 在 Android 4.4 以下無法接受觸控事件, 在小米部分機型上無法改變位置.

OK~ 對比一下這些方案 :

方案1: 申請許可權

   優點:實現簡單,只要正確引導使用者開啟許可權即可
   缺點:部分機型預設禁用; 需許可權不友好

方案2: 每個介面新增,共享元素過渡

   優點:不需許可權
   缺點:較複雜,只適用於5.0以上,且懸浮控制元件不可隱藏(共享元素會閃顯控制元件)

方案3: TYPE_TOAST

   優點:實現簡單
   缺點:小米(MIUI8)、7.1.1需要許可權,4.4以下無法接受點選事件

方案4:自定義 toast

  優點:大部分機型不需許可權,實現簡單
  缺點:Nexus7.1.1及以上不顯示,4.4以下無法接受點選事件,小米(MIUI8)及部分機型不可改變位置

結合我的需求, 我的懸浮控制元件並不需要改變位置, 所以最終選擇方案為:

最終方案 : 7.0 以下采用自定義 toast, 7.1 及以上引導使用者申請許可權

如果你的需求也適合此方案的話, 告訴你個好訊息, 我已經將此方案封裝為可直接呼叫的庫 : FixedFloatWindow , 即 fixed (位置固定的) float(懸浮) Window (窗), 可以很方便的使用 :

    FixedFloatWindow fixedFloatWindow = new FixedFloatWindow(getApplicationContext());
    fixedFloatWindow.setView(view);
    fixedFloatWindow.setGravity(Gravity.RIGHT | Gravity.TOP, 100, 150);
    fixedFloatWindow.show();
//   fixedFloatWindow.hide();
//   fixedFloatWindow.dismiss();

最後還有一個問題要解決, 我們要實現的是應用內懸浮控制元件 , 此方案應用退到後臺後仍然可以在桌面顯示 , 怎麼控制呢? 我們可以記錄當前 start 的 Activity 數量, 每當有 Activity stop 時, 便將此數量減 1 , 當此數量為 0 時表示應用退到後臺 , 這時隱藏懸浮窗即可 , 類似於這樣:

    @Override
    public void onActivityStarted(Activity activity) {
        mActivityNum++;
        if (isNeedShow(activity)) {
            show();
        }else{
            hide();
        }
    }

    @Override
    public void onActivityStopped(Activity activity) {
        mActivityNum--;
        if (mActivityNum == 0) {
            hide();
        }
    }

參考文章: