Toast 不顯示了?
本文作者
作者: Android輪子哥
連結:
https://www.jianshu.com/p/1d64a5ccbc7c
本文由作者授權釋出。
1
概述吐司彈不出來解決方案:ToastUtils
https://github.com/getActivity/ToastUtils
接下來讓我們來一步步開始分析這個問題是如何出現,解決的過程,以及解決的方法
首先我們先看一下大廠 APP 的彈吐司
疑問
-
連吐司彈不出來的手機是個什麼梗?
-
是少部分機型問題還是大多數機型的問題?
-
為什麼關閉了通知欄許可權彈不出來?
-
為什麼有的機型可以彈有的卻不行?
解答
1. 自從我的 ToastUtils 框架釋出了之後,被問最多的一個問題,你的吐司關閉通知欄許可權還能彈出來嗎?於是我便拿了我的小米8還有紅米Note5進行了測試,發現並沒有該問題,於是我統一回復,這個是相容問題,極少數機型才可能出現的問題,為保證框架穩定性,不給予相容
2. 於是還有人陸陸續續給我反饋了這個問題,反饋的人都是用華為機型出現的問題,我便開始重視起來,剛好有同事用的是華為 P9,我跟他借了一下手機,一借不要緊,一借一下午。估計同事的內心是崩潰的,因為這個問題被 100% 復現了,真的關閉通知欄許可權後吐司彈不出來了
3. 於是我翻遍了 Toast 的原始碼,吐司底層是 WindowManager 實現的,但是這跟通知欄許可權有什麼關係呢?
就算有關係也是和 NotificationManager 有關係,到底和 WindowManager 扯上啥關係了呢?經過檢視系統原始碼發現,吐司的建立是使用到了 WindowManager 去建立,但是顯示吐司的時候使用了 INotificationManager ,看類名就知道肯定和 NotificationManager 有聯絡,這就是為什麼關閉了通知欄許可權後導致了吐司顯示不出來的問題。
4. 現在經過測試,大部分小米機型不會因為通知欄許可權被關閉而原生的Toast彈不出來,而華為榮耀,三星等都會出現通知欄許可權被關閉後導致原生Toast顯示不出來,這可能是小米手機對這個吐司的顯示做了特殊處理,這個問題在Github上排名前幾的Toast框架都會出現,並且一些大廠的APP(除QQ微信和美團外)也會出現該問題,尚未有人著手完美解決這個問題。
吐司彈不出來的後果
Toast是我們日常開發中最常用的類,如果我們的APP在通知欄推送的訊息比較多,使用者就會把我們的通知欄許可權遮蔽了,但是這個會引起一個連帶反應,就是應用中所有使用到 Toast 的地方都會顯示不出來,徹底成為一個啞巴應用,例如以下情景:
-
賬戶密碼輸入錯誤,吐司彈不出來
-
使用者網路支付失敗,吐司彈不出來
-
網路請求錯誤,吐司彈不出來
-
雙擊退出應用,吐司彈不出來
-
等等情況,只要用到原生 Toast 都顯示不出來
其實這是一個系統的Bug,谷歌為了讓應用的 Toast 能夠顯示在其他應用上面,所以使用了通知欄相關的 API,但是這個 API 隨著使用者遮蔽通知欄而變得不可用,系統錯誤地認為你沒有通知欄許可權,從而間接導致 Toast 有 show 請求時被系統所攔截。
2
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; 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) { final WindowManager.LayoutParams params = mParams; params.type = WindowManager.LayoutParams.TYPE_TOAST; mPackageName = packageName; 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(); mNextView = null; break; } case CANCEL: { handleHide(); mNextView = null; try { getService().cancelToast(mPackageName, TN.this); } catch (RemoteException e) { } break; } } } }; } @Override public void show(IBinder windowToken) { mHandler.obtainMessage(SHOW, windowToken).sendToTarget(); } @Override public void hide() { mHandler.obtainMessage(HIDE).sendToTarget(); } public void cancel() { mHandler.obtainMessage(CANCEL).sendToTarget(); } public void handleShow(IBinder windowToken) { if (mView != mNextView) { 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); mParams.token = windowToken; mWM.addView(mView, mParams); trySendAccessibilityEvent(); } } public void handleHide() { if (mView != null) { if (mView.getParent() != null) { 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的通知欄許可權被關閉了,吐司自然也就彈不出來了。
3
那麼該如何著手解決這個問題先思考一個問題,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; } }
詳細的原始碼地址請戳這裡
https://github.com/getActivity/ToastUtils
本文作者的技術討論Q群:78797078
推薦閱讀 :
36000a0c135e8ebd52962c8b4bb&chksm=80b7b3e3b7c03af5f89208cfee8c0d7d504f6f1898c80a2ab5b0967177fd88f78039f808c2f1&scene=21#wechat_redirect" target="_blank" rel="nofollow,noindex"> 我所理解的Android元件化之通訊機制
掃一掃 關注我的公眾號
如果你想要跟大家分享你的文章,歡迎投稿~
┏(^0^)┛明天見!