本文章已授權鴻洋微信公眾號轉載:Toast不顯示了?
吐司彈不出來完美的解決方案:Toaster,接下來讓我們來一步步開始分析這個問題是如何出現(xiàn),解決的過程,以及解決的方法
首先我們先看一下大廠 APP 的彈吐司

疑問
連吐司彈不出來的手機是個什么梗?
是少部分機型問題還是大多數(shù)機型的問題?
為什么關閉了通知欄權限彈不出來?
為什么有的機型可以彈有的卻不行?
解答
自從我的 Toaster 框架發(fā)布了之后,被問最多的一個問題,你的Toast框架關閉通知欄權限還能彈出來嗎?我心想這 Toast 跟通知欄扯不上啥關系吧,但是既然有人這樣問了,也只能半信半疑了,于是我便拿了我的小米8還有紅米Note5進行了測試,發(fā)現(xiàn)并沒有該問題,于是我統(tǒng)一回復,這個是兼容問題,極少數(shù)機型才可能出現(xiàn)的問題,為保證框架穩(wěn)定性,不給予兼容
于是還有人陸陸續(xù)續(xù)給我反饋了這個問題,反饋的人都是用華為機型出現(xiàn)的問題,我便開始重視起來,剛好有同事用的是華為 P9,我跟他借了一下手機,一借不要緊,一借一下午。估計同事的內心是崩潰的,因為這個問題被 100% 復現(xiàn)了,真的關閉通知欄權限后吐司彈不出來了
于是我翻遍了 Toast 的源碼,吐司底層是 WindowManager 實現(xiàn)的,但是這跟通知欄權限有什么關系呢?就算有關系也是和 NotificationManager 有關系,到底和通知欄權限扯上啥關系了呢?經過查看系統(tǒng)源碼發(fā)現(xiàn),吐司的創(chuàng)建是使用到了 WindowManager 去創(chuàng)建,但是顯示吐司的時候使用了 INotificationManager ,看類名就知道肯定和 NotificationManager 有聯(lián)系,這就是為什么關閉了通知欄權限后導致了吐司顯示不出來的問題
現(xiàn)在經過測試,大部分小米機型不會因為通知欄權限被關閉而原生的Toast彈不出來,而華為榮耀,三星等都會出現(xiàn)通知欄權限被關閉后導致原生Toast顯示不出來,這可能是小米手機對這個吐司的顯示做了特殊處理,這個問題在Github上排名前幾的Toast框架都會出現(xiàn),并且一些大廠的APP(除QQ微信和美團外)也會出現(xiàn)該問題
吐司彈不出來的后果
Toast是我們日常開發(fā)中最常用的類,如果我們的APP在通知欄推送的消息比較多,用戶就會把我們的通知欄權限屏蔽了,但是這個會引起一個連帶反應,就是應用中所有使用到 Toast 的地方都會顯示不出來,徹底成為一個啞巴應用,例如以下情景:
賬戶密碼輸入錯誤,吐司彈不出來
用戶網(wǎng)絡支付失敗,吐司彈不出來
網(wǎng)絡請求錯誤,吐司彈不出來
雙擊退出應用,吐司彈不出來
等等情況,只要用到原生 Toast 都顯示不出來
其實這是一個系統(tǒng)的Bug,谷歌為了讓應用的 Toast 能夠顯示在其他應用上面,所以使用了通知欄相關的 API,但是這個 API 隨著用戶屏蔽通知欄而變得不可用,系統(tǒng)錯誤地認為你沒有通知欄權限,從而間接導致 Toast 有 show 請求時被系統(tǒng)所攔截
Toast 源碼解析
首先看一下 Toast 的構成

再看一下 Toast 內部的 API

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

從這里我們不難推斷,Toast 只是一個外觀類,最終實現(xiàn)還是由其內部類來實現(xiàn),由于這個內部類太長,這里放一下這個內部類的源碼,簡單過一遍就好
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 底層就是用這個內部類去實現(xiàn),請記住,這個內部類叫做 TN,字段名為 mTN,接下來先讓我們看一下 Toast 中 cancel 方法的源碼

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

仔細觀察的同學就會發(fā)現(xiàn)了,這個 show 的方法可不是像 cancel 一樣只調用了 TN 內部類中的同名方法,還調用了 INotificationManager 這個 API,其實不難發(fā)現(xiàn),這個 INotificationManager 是系統(tǒng)的 AIDL,不信的話我們再看一下這個 INotificationManager

我相信學過 AIDL 的同學會明白,這里不再講 AIDL 相關知識,如需了解請自行百度
重點講一下 INotificationManager,這個 AIDL 由系統(tǒng)實現(xiàn)的一個類,不同系統(tǒng)這個 AIDL 所對應的類也不相同,這就充分說明了為什么導致小米的機型關閉了通知欄權限還可以顯示,而華為就不行的原因,具體原因請再看源碼

因為這里傳了應用的包名給系統(tǒng)通知欄,如果這個包名對應的APP的通知欄權限被關閉了,吐司自然也就彈不出來了
那么該如何著手解決這個問題
先思考一個問題,Toast 顯示是使用了 INotificationManager,和通知欄有關系,而Toast 的創(chuàng)建是使用了 WindowManager,和通知欄沒有關系,那么我們可不可以通過 WindowManager 的方式來創(chuàng)建類似于 Toast 一樣的東西呢,答案也是可以的,只不過在過程中會遇到非常棘手的問題,接下來讓我們解決這些遇到的問題
首先創(chuàng)建一個 WindowManager 需要 一個 View 參數(shù)和 WindowManager.LayoutParams 參數(shù),這里說一下 WindowManager.LayoutParams 的創(chuàng)建,直接復制 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 的部分代碼,而其中的動畫代碼引用了系統(tǒng) R 文件中資源,而我無法直接在 Java 代碼中引用
params.windowAnimations = com.android.internal.R.style.Animation_Toast;
Java代碼不能引用這個Style不代表XML就不行,在這里創(chuàng)建一個 Style 并且繼承原生 Toast 樣式,這里我們可以自定義,也可以直接使用系統(tǒng)的,為了和系統(tǒng)的樣式統(tǒng)一,這里就直接使用系統(tǒng)的
<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 顯示后發(fā)送一個定時關閉的任務,那么問題來了,這個顯示的時間如何定義?系統(tǒng) Toast 顯示的時間是什么樣子?首先我們需要先看一下 Toast 給我們提供的兩個常量值

從這張圖上我們并沒有發(fā)現(xiàn)什么有價值的東西,我們繼續(xù)往下找,看看是什么地方引用了這些常量

繼續(xù)通過查看源碼得知

但是通過測試,短吐司顯示的時長為2-3秒,而長吐司顯示的時長是3-4秒,所以這兩個值并不是吐司顯示時長的毫秒數(shù),那么我們該如何得出正確的毫秒數(shù)呢?這個問題就留給大家去思考,這里不做解答
只能使用當前 Activity 創(chuàng)建 WindowManager 的缺陷
發(fā)現(xiàn)一個問題,Activity 和 Application 同樣是 Context 的子類,如果使用 Activity 獲取的 WindowManager 對象可以創(chuàng)建出來,但是如果使用 Application 獲取的 WindowManager 對象卻報了錯
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not for an application
報錯已經說得很清楚了,創(chuàng)建 WindowManager 不能使用 Application 對象去創(chuàng)建,也就是說只能通過 Activity 對象去創(chuàng)建 WindowManager
那么問題來了,每次彈這種 “Toast” 需要當前 Activity 對象,這個問題對于常年使用框架的同學是致命的
這里以我做的框架 Toaster 為例子,顯示一個吐司是這樣子調用的
Toaster.show("我是吐司");
如果要解決在關閉通知欄權限后吐司還能再彈出來的問題,就需要改成
Toaster.show(MainActivity.this, "我是吐司");
先說一下這個問題帶來的影響吧,我是框架的作者,對于我來說,只需要在 Toaster 中 show 方法多添加一個 Activity 參數(shù)即可,但是對于使用框架的人,在更新完框架后,整個項目所有使用到這個Toaster.show()方法都會報錯,需要多傳入一個Activity 參數(shù),相信他們的內心幾乎是崩潰的,那么有沒有一種好的辦法解決這個問題,答案當然是有了,可以用一個冷門的 API
Application.registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks callback);
這個 API 是在 安卓 4.0 之后才有的,而現(xiàn)在大多數(shù)設備已經在 安卓 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);
}
看到這里,相信各位已經知道真相了,這個方法用于監(jiān)聽應用中 Activity 中的生命周期方法
那么我們就可以通過這個 API 來獲取當前和用戶交互的 Activity 對象,從而完成讓當前 Activity 對象去創(chuàng)建 WindowManager
使用 WindowManager 實現(xiàn) Toast 出現(xiàn)局限性的問題
當然用 WindowManager 創(chuàng)建的 View 必然也會受 Activity 的限制,因為就只能顯示這個 Activity 上,如果在其他界面上則會顯示不了,而系統(tǒng)原生的 Toast 則可以出現(xiàn)別的界面上,那有沒有什么解決辦法呢?
WindowManager 在沒有懸浮窗權限的時候就只能顯示依附于調用的 Activity,當有授予了懸浮窗權限之后,可以通過改變type參數(shù)來更改 WindowManager 顯示范圍,可以讓這個 WindowManager 顯示在其他界面之上,這樣 Toast 就不會隨著 Activity 的不可見而變得不可見
// 判斷是否為 Android 6.0 及以上系統(tǒng)并且有懸浮窗權限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(mToast.getView().getContext())) {
// 解決使用 WindowManager 創(chuàng)建的 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 中取舍
這樣我們比對一組數(shù)據(jù):
| 類型 | 顯示范圍 | 需要參數(shù) | 兼容性 | 效率 | 通知欄權限 | 懸浮窗權限 |
|---|---|---|---|---|---|---|
| 原生 Toast | 所有界面 | Context子類 | 高 | 一般 | 需要 | 不需要 |
| WindowManager | 當前Activity | Activity子類 | 一般 | 高 | 不需要 | 不需要 |
經過對比,原生的 Toast 的優(yōu)勢還是要大于 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;
}
}
