本文章已授權(quán)鴻洋微信公眾號轉(zhuǎn)載:Toast不顯示了嫂便?
吐司彈不出來完美的解決方案:Toaster躁锡,接下來讓我們來一步步開始分析這個問題是如何出現(xiàn)希停,解決的過程对省,以及解決的方法
首先我們先看一下大廠 APP 的彈吐司
疑問
連吐司彈不出來的手機是個什么梗?
是少部分機型問題還是大多數(shù)機型的問題众辨?
為什么關(guān)閉了通知欄權(quán)限彈不出來豪嚎?
為什么有的機型可以彈有的卻不行?
解答
自從我的 Toaster 框架發(fā)布了之后厢塘,被問最多的一個問題茶没,你的Toast框架關(guān)閉通知欄權(quán)限還能彈出來嗎?我心想這 Toast 跟通知欄扯不上啥關(guān)系吧晚碾,但是既然有人這樣問了礁叔,也只能半信半疑了,于是我便拿了我的小米8還有紅米Note5進行了測試迄薄,發(fā)現(xiàn)并沒有該問題琅关,于是我統(tǒng)一回復(fù),這個是兼容問題讥蔽,極少數(shù)機型才可能出現(xiàn)的問題涣易,為保證框架穩(wěn)定性,不給予兼容
于是還有人陸陸續(xù)續(xù)給我反饋了這個問題冶伞,反饋的人都是用華為機型出現(xiàn)的問題新症,我便開始重視起來,剛好有同事用的是華為 P9响禽,我跟他借了一下手機徒爹,一借不要緊,一借一下午芋类。估計同事的內(nèi)心是崩潰的隆嗅,因為這個問題被 100% 復(fù)現(xiàn)了,真的關(guān)閉通知欄權(quán)限后吐司彈不出來了
于是我翻遍了 Toast 的源碼侯繁,吐司底層是 WindowManager 實現(xiàn)的胖喳,但是這跟通知欄權(quán)限有什么關(guān)系呢?就算有關(guān)系也是和 NotificationManager 有關(guān)系贮竟,到底和通知欄權(quán)限扯上啥關(guān)系了呢丽焊?經(jīng)過查看系統(tǒng)源碼發(fā)現(xiàn)较剃,吐司的創(chuàng)建是使用到了 WindowManager 去創(chuàng)建,但是顯示吐司的時候使用了 INotificationManager 技健,看類名就知道肯定和 NotificationManager 有聯(lián)系写穴,這就是為什么關(guān)閉了通知欄權(quán)限后導(dǎo)致了吐司顯示不出來的問題
現(xiàn)在經(jīng)過測試,大部分小米機型不會因為通知欄權(quán)限被關(guān)閉而原生的Toast彈不出來雌贱,而華為榮耀啊送,三星等都會出現(xiàn)通知欄權(quán)限被關(guān)閉后導(dǎo)致原生Toast顯示不出來,這可能是小米手機對這個吐司的顯示做了特殊處理帽芽,這個問題在Github上排名前幾的Toast框架都會出現(xiàn)删掀,并且一些大廠的APP(除QQ微信和美團外)也會出現(xiàn)該問題
吐司彈不出來的后果
Toast是我們?nèi)粘i_發(fā)中最常用的類翔冀,如果我們的APP在通知欄推送的消息比較多导街,用戶就會把我們的通知欄權(quán)限屏蔽了,但是這個會引起一個連帶反應(yīng)纤子,就是應(yīng)用中所有使用到 Toast 的地方都會顯示不出來搬瑰,徹底成為一個啞巴應(yīng)用,例如以下情景:
賬戶密碼輸入錯誤控硼,吐司彈不出來
用戶網(wǎng)絡(luò)支付失敗泽论,吐司彈不出來
網(wǎng)絡(luò)請求錯誤,吐司彈不出來
雙擊退出應(yīng)用卡乾,吐司彈不出來
等等情況翼悴,只要用到原生 Toast 都顯示不出來
其實這是一個系統(tǒng)的Bug,谷歌為了讓應(yīng)用的 Toast 能夠顯示在其他應(yīng)用上面幔妨,所以使用了通知欄相關(guān)的 API鹦赎,但是這個 API 隨著用戶屏蔽通知欄而變得不可用,系統(tǒng)錯誤地認(rèn)為你沒有通知欄權(quán)限误堡,從而間接導(dǎo)致 Toast 有 show 請求時被系統(tǒng)所攔截
Toast 源碼解析
首先看一下 Toast 的構(gòu)成
再看一下 Toast 內(nèi)部的 API
里面還有一個內(nèi)部類古话,再看一下內(nèi)部的 API
從這里我們不難推斷,Toast 只是一個外觀類锁施,最終實現(xiàn)還是由其內(nèi)部類來實現(xiàn)陪踩,由于這個內(nèi)部類太長,這里放一下這個內(nèi)部類的源碼悉抵,簡單過一遍就好
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 底層就是用這個內(nèi)部類去實現(xiàn),請記住姥饰,這個內(nèi)部類叫做 TN婚温,字段名為 mTN,接下來先讓我們看一下 Toast 中 cancel 方法的源碼
cancel最終還是調(diào)用了內(nèi)部類 TN 中的同名方法媳否,接下來再看 Toast 中 show 方法的源碼
仔細(xì)觀察的同學(xué)就會發(fā)現(xiàn)了栅螟,這個 show 的方法可不是像 cancel 一樣只調(diào)用了 TN 內(nèi)部類中的同名方法荆秦,還調(diào)用了 INotificationManager 這個 API,其實不難發(fā)現(xiàn)力图,這個 INotificationManager 是系統(tǒng)的 AIDL步绸,不信的話我們再看一下這個 INotificationManager
我相信學(xué)過 AIDL 的同學(xué)會明白,這里不再講 AIDL 相關(guān)知識吃媒,如需了解請自行百度
重點講一下 INotificationManager瓤介,這個 AIDL 由系統(tǒng)實現(xiàn)的一個類,不同系統(tǒng)這個 AIDL 所對應(yīng)的類也不相同赘那,這就充分說明了為什么導(dǎo)致小米的機型關(guān)閉了通知欄權(quán)限還可以顯示刑桑,而華為就不行的原因,具體原因請再看源碼
因為這里傳了應(yīng)用的包名給系統(tǒng)通知欄募舟,如果這個包名對應(yīng)的APP的通知欄權(quán)限被關(guān)閉了祠斧,吐司自然也就彈不出來了
那么該如何著手解決這個問題
先思考一個問題,Toast 顯示是使用了 INotificationManager拱礁,和通知欄有關(guān)系琢锋,而Toast 的創(chuàng)建是使用了 WindowManager,和通知欄沒有關(guān)系呢灶,那么我們可不可以通過 WindowManager 的方式來創(chuàng)建類似于 Toast 一樣的東西呢吴超,答案也是可以的,只不過在過程中會遇到非常棘手的問題鸯乃,接下來讓我們解決這些遇到的問題
首先創(chuàng)建一個 WindowManager 需要 一個 View 參數(shù)和 WindowManager.LayoutParams 參數(shù)鲸阻,這里說一下 WindowManager.LayoutParams 的創(chuàng)建,直接復(fù)制 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 調(diào)用 addView 顯示缨睡,然后報了錯
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
其原因在于我們使用了 type鸟悴,為什么不能加 TYPE_TOAST,因為通知權(quán)限在關(guān)閉后設(shè)置顯示的類型為Toast會報錯宏蛉,所以這里我們把這句代碼注釋掉遣臼,然后就可以顯示出來了
params.type = WindowManager.LayoutParams.TYPE_TOAST;
WindowManager 沒有吐司的顯示效果
其原因在于我們復(fù)制了 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ā)送一個定時關(guān)閉的任務(wù),那么問題來了,這個顯示的時間如何定義幽纷?系統(tǒng) Toast 顯示的時間是什么樣子式塌?首先我們需要先看一下 Toast 給我們提供的兩個常量值
從這張圖上我們并沒有發(fā)現(xiàn)什么有價值的東西,我們繼續(xù)往下找友浸,看看是什么地方引用了這些常量
繼續(xù)通過查看源碼得知
但是通過測試峰尝,短吐司顯示的時長為2-3秒彤灶,而長吐司顯示的時長是3-4秒俱恶,所以這兩個值并不是吐司顯示時長的毫秒數(shù),那么我們該如何得出正確的毫秒數(shù)呢逊抡?這個問題就留給大家去思考伦意,這里不做解答
只能使用當(dāng)前 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
報錯已經(jīng)說得很清楚了熏矿,創(chuàng)建 WindowManager 不能使用 Application 對象去創(chuàng)建,也就是說只能通過 Activity 對象去創(chuàng)建 WindowManager
那么問題來了缆八,每次彈這種 “Toast” 需要當(dāng)前 Activity 對象曲掰,這個問題對于常年使用框架的同學(xué)是致命的
這里以我做的框架 Toaster 為例子疾捍,顯示一個吐司是這樣子調(diào)用的
Toaster.show("我是吐司");
如果要解決在關(guān)閉通知欄權(quán)限后吐司還能再彈出來的問題奈辰,就需要改成
Toaster.show(MainActivity.this, "我是吐司");
先說一下這個問題帶來的影響吧,我是框架的作者乱豆,對于我來說奖恰,只需要在 Toaster 中 show 方法多添加一個 Activity 參數(shù)即可,但是對于使用框架的人宛裕,在更新完框架后瑟啃,整個項目所有使用到這個Toaster.show()方法都會報錯,需要多傳入一個Activity 參數(shù)揩尸,相信他們的內(nèi)心幾乎是崩潰的蛹屿,那么有沒有一種好的辦法解決這個問題,答案當(dāng)然是有了岩榆,可以用一個冷門的 API
Application.registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks callback);
這個 API 是在 安卓 4.0 之后才有的错负,而現(xiàn)在大多數(shù)設(shè)備已經(jīng)在 安卓 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);
}
看到這里犹撒,相信各位已經(jīng)知道真相了,這個方法用于監(jiān)聽?wèi)?yīng)用中 Activity 中的生命周期方法
那么我們就可以通過這個 API 來獲取當(dāng)前和用戶交互的 Activity 對象粒褒,從而完成讓當(dāng)前 Activity 對象去創(chuàng)建 WindowManager
使用 WindowManager 實現(xiàn) Toast 出現(xiàn)局限性的問題
當(dāng)然用 WindowManager 創(chuàng)建的 View 必然也會受 Activity 的限制识颊,因為就只能顯示這個 Activity 上,如果在其他界面上則會顯示不了奕坟,而系統(tǒng)原生的 Toast 則可以出現(xiàn)別的界面上祥款,那有沒有什么解決辦法呢清笨?
WindowManager 在沒有懸浮窗權(quán)限的時候就只能顯示依附于調(diào)用的 Activity,當(dāng)有授予了懸浮窗權(quán)限之后刃跛,可以通過改變type參數(shù)來更改 WindowManager 顯示范圍函筋,可以讓這個 WindowManager 顯示在其他界面之上,這樣 Toast 就不會隨著 Activity 的不可見而變得不可見
// 判斷是否為 Android 6.0 及以上系統(tǒng)并且有懸浮窗權(quán)限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(mToast.getView().getContext())) {
// 解決使用 WindowManager 創(chuàng)建的 Toast 只能顯示在當(dāng)前 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ù) | 兼容性 | 效率 | 通知欄權(quán)限 | 懸浮窗權(quán)限 |
---|---|---|---|---|---|---|
原生 Toast | 所有界面 | Context子類 | 高 | 一般 | 需要 | 不需要 |
WindowManager | 當(dāng)前Activity | Activity子類 | 一般 | 高 | 不需要 | 不需要 |
經(jīng)過對比奠伪,原生的 Toast 的優(yōu)勢還是要大于 WindowManager 的跌帐,所以如果在有在通知欄權(quán)限的前提下,建議使用原生的 Toast绊率,我們可以通過判斷通知欄權(quán)限是否被關(guān)閉谨敛,來判斷是來顯示原生 Toast 還是 WindowManager,方法代碼如下:
/**
* 檢查通知欄權(quán)限有沒有開啟
*/
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;
}
}