Toast通知欄權限填坑指南

本文章已受權鴻洋微信公衆號轉載:Toast不顯示了?

吐司彈不出來完美的解決方案:ToastUtils,接下來讓咱們來一步步開始分析這個問題是如何出現,解決的過程,以及解決的方法android

首先咱們先看一下大廠 APP 的彈吐司

疑問

  • 連吐司彈不出來的手機是個什麼梗?git

  • 是少部分機型問題仍是大多數機型的問題?github

  • 爲何關閉了通知欄權限彈不出來?微信

  • 爲何有的機型能夠彈有的卻不行?網絡

解答

  • 自從個人 ToastUtils 框架發佈了以後,被問最多的一個問題,你的Toast框架關閉通知欄權限還能彈出來嗎?我心想這 Toast 跟通知欄扯不上啥關係吧,可是既然有人這樣問了,也只能半信半疑了,因而我便拿了個人小米8還有紅米Note5進行了測試,發現並無該問題,因而我統一回復,這個是兼容問題,極少數機型纔可能出現的問題,爲保證框架穩定性,不給予兼容app

  • 因而還有人陸陸續續給我反饋了這個問題,反饋的人都是用華爲機型出現的問題,我便開始重視起來,恰好有同事用的是華爲 P9,我跟他借了一下手機,一借沒關係,一借一下午。估計同事的心裏是崩潰的,由於這個問題被 100% 復現了,真的關閉通知欄權限後吐司彈不出來了框架

  • 因而我翻遍了 Toast 的源碼,吐司底層是 WindowManager 實現的,可是這跟通知欄權限有什麼關係呢?就算有關係也是和 NotificationManager 有關係,到底和通知欄權限扯上啥關係了呢?通過查看系統源碼發現,吐司的建立是使用到了 WindowManager 去建立,可是顯示吐司的時候使用了 INotificationManager ,看類名就知道確定和 NotificationManager 有聯繫,這就是爲何關閉了通知欄權限後致使了吐司顯示不出來的問題ide

  • 如今通過測試,大部分小米機型不會由於通知欄權限被關閉而原生的Toast彈不出來,而華爲榮耀,三星等都會出現通知欄權限被關閉後致使原生Toast顯示不出來,這多是小米手機對這個吐司的顯示作了特殊處理,這個問題在Github上排名前幾的Toast框架都會出現,而且一些大廠的APP(除QQ微信和美團外)也會出現該問題oop

吐司彈不出來的後果

Toast是咱們平常開發中最經常使用的類,若是咱們的APP在通知欄推送的消息比較多,用戶就會把咱們的通知欄權限屏蔽了,可是這個會引發一個連帶反應,就是應用中全部使用到 Toast 的地方都會顯示不出來,完全成爲一個啞吧應用,例如如下情景:測試

  • 帳戶密碼輸入錯誤,吐司彈不出來

  • 用戶網絡支付失敗,吐司彈不出來

  • 網絡請求錯誤,吐司彈不出來

  • 雙擊退出應用,吐司彈不出來

  • 等等狀況,只要用到原生 Toast 都顯示不出來

其實這是一個系統的Bug,谷歌爲了讓應用的 Toast 可以顯示在其餘應用上面,因此使用了通知欄相關的 API,可是這個 API 隨着用戶屏蔽通知欄而變得不可用,系統錯誤地認爲你沒有通知欄權限,從而間接致使 Toast 有 show 請求時被系統所攔截

Toast 源碼解析

首先看一下 Toast 的構成

再看一下 Toast 內部的 API

裏面還有一個內部類,再看一下內部的 API

從這裏咱們不難推斷,Toast 只是一個外觀類,最終實現仍是由其內部類來實現,因爲這個內部類太長,這裏放一下這個內部類的源碼,簡單過一遍就好

private static class TN extends ITransientNotification.Stub {
    private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();

    private static final int SHOW = 0;
    private static final int HIDE = 1;
    private static final int CANCEL = 2;
    final Handler mHandler;

    int mGravity;
    int mX, mY;
    float mHorizontalMargin;
    float mVerticalMargin;


    View mView;
    View mNextView;
    int mDuration;

    WindowManager mWM;

    String mPackageName;

    static final long SHORT_DURATION_TIMEOUT = 4000;
    static final long LONG_DURATION_TIMEOUT = 7000;

    TN(String packageName, @Nullable Looper looper) {
        // XXX This should be changed to use a Dialog, with a Theme.Toast
        // defined that sets up the layout params appropriately.
        final WindowManager.LayoutParams params = mParams;
        params.height = WindowManager.LayoutParams.WRAP_CONTENT;
        params.width = WindowManager.LayoutParams.WRAP_CONTENT;
        params.format = PixelFormat.TRANSLUCENT;
        params.windowAnimations = com.android.internal.R.style.Animation_Toast;
        params.type = WindowManager.LayoutParams.TYPE_TOAST;
        params.setTitle("Toast");
        params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;

        mPackageName = packageName;

        if (looper == null) {
            // Use Looper.myLooper() if looper is not specified.
            looper = Looper.myLooper();
            if (looper == null) {
                throw new RuntimeException(
                        "Can't toast on a thread that has not called Looper.prepare()");
            }
        }
        mHandler = new Handler(looper, null) {
            @Override
            public void handleMessage(Message msg) {
                switch (msg.what) {
                    case SHOW: {
                        IBinder token = (IBinder) msg.obj;
                        handleShow(token);
                        break;
                    }
                    case HIDE: {
                        handleHide();
                        // Don't do this in handleHide() because it is also invoked by
                        // handleShow()
                        mNextView = null;
                        break;
                    }
                    case CANCEL: {
                        handleHide();
                        // Don't do this in handleHide() because it is also invoked by
                        // handleShow()
                        mNextView = null;
                        try {
                            getService().cancelToast(mPackageName, TN.this);
                        } catch (RemoteException e) {
                        }
                        break;
                    }
                }
            }
        };
    }

    /**
     * schedule handleShow into the right thread
     */
    @Override
    public void show(IBinder windowToken) {
        if (localLOGV) Log.v(TAG, "SHOW: " + this);
        mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
    }

    /**
     * schedule handleHide into the right thread
     */
    @Override
    public void hide() {
        if (localLOGV) Log.v(TAG, "HIDE: " + this);
        mHandler.obtainMessage(HIDE).sendToTarget();
    }

    public void cancel() {
        if (localLOGV) Log.v(TAG, "CANCEL: " + this);
        mHandler.obtainMessage(CANCEL).sendToTarget();
    }

    public void handleShow(IBinder windowToken) {
        if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                + " mNextView=" + mNextView);
        // If a cancel/hide is pending - no need to show - at this point
        // the window token is already invalid and no need to do any work.
        if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
            return;
        }
        if (mView != mNextView) {
            // remove the old view if necessary
            handleHide();
            mView = mNextView;
            Context context = mView.getContext().getApplicationContext();
            String packageName = mView.getContext().getOpPackageName();
            if (context == null) {
                context = mView.getContext();
            }
            mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
            // We can resolve the Gravity here by using the Locale for getting
            // the layout direction
            final Configuration config = mView.getContext().getResources().getConfiguration();
            final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
            mParams.gravity = gravity;
            if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
                mParams.horizontalWeight = 1.0f;
            }
            if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
                mParams.verticalWeight = 1.0f;
            }
            mParams.x = mX;
            mParams.y = mY;
            mParams.verticalMargin = mVerticalMargin;
            mParams.horizontalMargin = mHorizontalMargin;
            mParams.packageName = packageName;
            mParams.hideTimeoutMilliseconds = mDuration ==
                Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
            mParams.token = windowToken;
            if (mView.getParent() != null) {
                if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                mWM.removeView(mView);
            }
            if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
            // Since the notification manager service cancels the token right
            // after it notifies us to cancel the toast there is an inherent
            // race and we may attempt to add a window after the token has been
            // invalidated. Let us hedge against that.
            try {
                mWM.addView(mView, mParams);
                trySendAccessibilityEvent();
            } catch (WindowManager.BadTokenException e) {
                /* ignore */
            }
        }
    }

    private void trySendAccessibilityEvent() {
        AccessibilityManager accessibilityManager =
                AccessibilityManager.getInstance(mView.getContext());
        if (!accessibilityManager.isEnabled()) {
            return;
        }
        // treat toasts as notifications since they are used to
        // announce a transient piece of information to the user
        AccessibilityEvent event = AccessibilityEvent.obtain(
                AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
        event.setClassName(getClass().getName());
        event.setPackageName(mView.getContext().getPackageName());
        mView.dispatchPopulateAccessibilityEvent(event);
        accessibilityManager.sendAccessibilityEvent(event);
    }

    public void handleHide() {
        if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
        if (mView != null) {
            // note: checking parent() just to make sure the view has
            // been added...  i have seen cases where we get here when
            // the view isn't yet added, so let's try not to crash.
            if (mView.getParent() != null) {
                if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                mWM.removeViewImmediate(mView);
            }

            mView = null;
        }
    }
}
複製代碼

只須要稍微簡單看一下就看懂,Toast 底層就是用這個內部類去實現,請記住,這個內部類叫作 TN,字段名爲 mTN,接下來先讓咱們看一下 Toast 中 cancel 方法的源碼

cancel最終仍是調用了內部類 TN 中的同名方法,接下來再看 Toast 中 show 方法的源碼

仔細觀察的同窗就會發現了,這個 show 的方法可不是像 cancel 同樣只調用了 TN 內部類中的同名方法,還調用了 INotificationManager 這個 API,其實不難發現,這個 INotificationManager 是系統的 AIDL,不信的話咱們再看一下這個 INotificationManager

我相信學過 AIDL 的同窗會明白,這裏再也不講 AIDL 相關知識,如需瞭解請自行百度

重點講一下 INotificationManager,這個 AIDL 由系統實現的一個類,不一樣系統這個 AIDL 所對應的類也不相同,這就充分說明了爲何致使小米的機型關閉了通知欄權限還能夠顯示,而華爲就不行的緣由,具體緣由請再看源碼

由於這裏傳了應用的包名給系統通知欄,若是這個包名對應的APP的通知欄權限被關閉了,吐司天然也就彈不出來了

那麼該如何着手解決這個問題

先思考一個問題,Toast 顯示是使用了 INotificationManager,和通知欄有關係,而Toast 的建立是使用了 WindowManager,和通知欄沒有關係,那麼咱們可不能夠經過 WindowManager 的方式來建立相似於 Toast 同樣的東西呢,答案也是能夠的,只不過在過程當中會遇到很是棘手的問題,接下來讓咱們解決這些遇到的問題

首先建立一個 WindowManager 須要 一個 View 參數和 WindowManager.LayoutParams 參數,這裏說一下 WindowManager.LayoutParams 的建立,直接複製 Toast 部分代碼

WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
// 找不到 com.android.internal.R.style.Animation_Toast
// params.windowAnimations = com.android.internal.R.style.Animation_Toast;
params.windowAnimations = -1;
params.type = WindowManager.LayoutParams.TYPE_TOAST;
params.setTitle("Toast");
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
        | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
        | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
複製代碼

而後使用 WindowManager 調用 addView 顯示,而後報了錯

android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
複製代碼

其緣由在於咱們使用了 type,爲何不能加 TYPE_TOAST,由於通知權限在關閉後設置顯示的類型爲Toast會報錯,因此這裏咱們把這句代碼註釋掉,而後就能夠顯示出來了

params.type = WindowManager.LayoutParams.TYPE_TOAST;
複製代碼

WindowManager 沒有吐司的顯示效果

其緣由在於咱們複製了 Toast 的部分代碼,而其中的動畫代碼引用了系統 R 文件中資源,而我沒法直接在 Java 代碼中引用

params.windowAnimations = com.android.internal.R.style.Animation_Toast;
複製代碼

Java代碼不能引用這個Style不表明XML就不行,在這裏建立一個 Style 而且繼承原生 Toast 樣式,這裏咱們能夠自定義,也能夠直接使用系統的,爲了和系統的樣式統一,這裏就直接使用系統的

<style name="ToastAnimation" parent="@android:style/Animation.Toast">
    <!--<item name="android:windowEnterAnimation">@anim/toast_enter</item>-->
    <!--<item name="android:windowExitAnimation">@anim/toast_exit</item>-->
</style>
複製代碼

而後從新指定 params.windowAnimations 便可解決該問題

params.windowAnimations = R.style.ToastAnimation;
複製代碼

WindowManager 沒有自動消失的問題

首先 WindowManager 並不能像 Toast 顯示後自動消失,若是要像 Toast 同樣自動消失很容易,在 WindowManager 顯示後發送一個定時關閉的任務,那麼問題來了,這個顯示的時間如何定義?系統 Toast 顯示的時間是什麼樣子?首先咱們須要先看一下 Toast 給咱們提供的兩個常量值

從這張圖上咱們並無發現什麼有價值的東西,咱們繼續往下找,看看是什麼地方引用了這些常量

繼續經過查看源碼得知

可是經過測試,短吐司顯示的時長爲2-3秒,而長吐司顯示的時長是3-4秒,因此這兩個值並非吐司顯示時長的毫秒數,那麼咱們該如何得出正確的毫秒數呢?這個問題就留給你們去思考,這裏不作解答

只能使用當前 Activity 建立 WindowManager 的缺陷

發現一個問題,Activity 和 Application 一樣是 Context 的子類,若是使用 Activity 獲取的 WindowManager 對象能夠建立出來,可是若是使用 Application 獲取的 WindowManager 對象卻報了錯

android.view.WindowManager$BadTokenException: Unable to add window -- token null is not for an application
複製代碼

報錯已經說得很清楚了,建立 WindowManager 不能使用 Application 對象去建立,也就是說只能經過 Activity 對象去建立 WindowManager

那麼問題來了,每次彈這種 「Toast」 須要當前 Activity 對象,這個問題對於常年使用框架的同窗是致命的

這裏以我作的框架 ToastUtils 爲例子,顯示一個吐司是這樣子調用的

ToastUtils.show("我是吐司");
複製代碼

若是要解決在關閉通知欄權限後吐司還能再彈出來的問題,就須要改爲

ToastUtils.show(MainActivity.this, "我是吐司");
複製代碼

先說一下這個問題帶來的影響吧,我是框架的做者,對於我來講,只須要在 ToastUtils 中 show 方法多添加一個 Activity 參數便可,可是對於使用框架的人,在更新完框架後,整個項目全部使用到這個ToastUtils.show()方法都會報錯,須要多傳入一個Activity 參數,相信他們的心裏幾乎是崩潰的,那麼有沒有一種好的辦法解決這個問題,答案固然是有了,能夠用一個冷門的 API

Application.registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks callback);
複製代碼

這個 API 是在 安卓 4.0 以後纔有的,而如今大多數設備已經在 安卓 5.0 及以上,因此這個 API 仍是有前途的,接下看一下 ActivityLifecycleCallbacks 這個接口有什麼方法吧

public interface ActivityLifecycleCallbacks {
    void onActivityCreated(Activity activity, Bundle savedInstanceState);
    void onActivityStarted(Activity activity);
    void onActivityResumed(Activity activity);
    void onActivityPaused(Activity activity);
    void onActivityStopped(Activity activity);
    void onActivitySaveInstanceState(Activity activity, Bundle outState);
    void onActivityDestroyed(Activity activity);
}
複製代碼

看到這裏,相信各位已經知道真相了,這個方法用於監聽應用中 Activity 中的生命週期方法

那麼咱們就能夠經過這個 API 來獲取當前和用戶交互的 Activity 對象,從而完成讓當前 Activity 對象去建立 WindowManager

使用 WindowManager 實現 Toast 出現侷限性的問題

固然用 WindowManager 建立的 View 必然也會受 Activity 的限制,由於就只能顯示這個 Activity 上,若是在其餘界面上則會顯示不了,而系統原生的 Toast 則能夠出現別的界面上,那有沒有什麼解決辦法呢?

WindowManager 在沒有懸浮窗權限的時候就只能顯示依附於調用的 Activity,當有授予了懸浮窗權限以後,能夠經過改變type參數來更改 WindowManager 顯示範圍,可讓這個 WindowManager 顯示在其餘界面之上,這樣 Toast 就不會隨着 Activity 的不可見而變得不可見

// 判斷是否爲 Android 6.0 及以上系統而且有懸浮窗權限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(mToast.getView().getContext())) {
    // 解決使用 WindowManager 建立的 Toast 只能顯示在當前 Activity 的問題
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
    }else {
        params.type = WindowManager.LayoutParams.TYPE_PHONE;
    }
}
複製代碼

如何在原生 Toast 和 WindowManager 中取捨

這樣咱們比對一組數據:

類型 顯示範圍 須要參數 兼容性 效率 通知欄權限 懸浮窗權限
原生 Toast 全部界面 Context子類 通常 須要 不須要
WindowManager 當前Activity Activity子類 通常 不須要 不須要

通過對比,原生的 Toast 的優點仍是要大於 WindowManager 的,因此若是在有在通知欄權限的前提下,建議使用原生的 Toast,咱們能夠經過判斷通知欄權限是否被關閉,來判斷是來顯示原生 Toast 仍是 WindowManager,方法代碼以下:

/**
 * 檢查通知欄權限有沒有開啓
 */
public static boolean isNotificationEnabled(Context context){
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        return ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).areNotificationsEnabled();
    } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
        ApplicationInfo appInfo = context.getApplicationInfo();
        String pkg = context.getApplicationContext().getPackageName();
        int uid = appInfo.uid;

        try {
            Class<?> appOpsClass = Class.forName(AppOpsManager.class.getName());
            Method checkOpNoThrowMethod = appOpsClass.getMethod("checkOpNoThrow", Integer.TYPE, Integer.TYPE, String.class);
            Field opPostNotificationValue = appOpsClass.getDeclaredField("OP_POST_NOTIFICATION");
            int value = (Integer) opPostNotificationValue.get(Integer.class);
            return (Integer) checkOpNoThrowMethod.invoke(appOps, value, uid, pkg) == 0;
        } catch (NoSuchMethodException | NoSuchFieldException | InvocationTargetException | IllegalAccessException | RuntimeException | ClassNotFoundException ignored) {
            return true;
        }
    } else {
        return true;
    }
}
複製代碼

詳細的源碼地址請戳這裏

Android技術討論Q羣:78797078

相關文章
相關標籤/搜索