先看看使用系統Toast存在的問題:
1.當通知權限被關閉時在華為等手機上Toast不顯示;
2.Toast的隊列機制在不同手機上可能會不相同;
3.Toast的BadTokenException問題;
當發現系統Toast存在問題時,不少同學都會采用自定義的TYPE_TOAST彈窗來實作相同效果。雖然大部分情況下效果都是 OK的,但其實TYPE_TOAST彈窗依然存在相容問題:
4.Android8.0之後的token null is not valid問題(實測部分機型問題);
5.Android7.1之後,不允許同時展示兩個TYPE_TOAST彈窗(實測部分機型問題)。
那麼,DToast使用的解決方案是:
1.通知權限未被關閉時,使用SystemToast(修複了問題2和問題3的系統Toast);
2.通知權限被關閉時,使用DovaToast(自定義的TYPE_TOAST彈窗);
3.當使用DovaToast出現token null is not valid時,嘗試使用ActivityToast(自定義的TYPE_APPLICATION_ATTACHED_DIALOG
彈窗,隻有當傳入Context為Activity時,才會啟用ActivityToast).
相信不少同學舊項目中封裝的ToastUtil都是直接使用的ApplicationContext作為上下文,然後在需要彈窗的時候直接就是ToastUtil.show(str) ,這樣的使用方式對于我們來說是最友善的啦。
當然,使用DToast你也依然可以沿用這種封裝方式,但這種方式在下面這個場景中可能會無法成功展示出彈窗(該場景下原生Toast也一樣無法彈出), 不過請放心不會導緻應用崩潰,而且這個場景出現的機率較小,有以下三個必要條件:1.通知欄權限被關閉(通知欄權限預設都是打開的) 2.非MIUI手機 3.Android8.0以上的部分手機(我最近測試中的幾部8.0+裝置都不存在該問題)。
不過,如果想要保證在所有場景下都能正常展示彈窗,還是建議在DToast.make(context)時傳入Activity作為上下文,這樣在該場景下DToast會啟用ActivityToast展示出彈窗。
接下來再詳細分析下上面提到的五個問題:
問題一:關閉通知權限時Toast不顯示
看下方Toast源碼中的show()方法,通過AIDL擷取到INotificationManager,并将接下來的顯示流程控制權
交給NotificationManagerService。
NMS中會對Toast進行權限校驗,當通知權限校驗不通過時,Toast将不做展示。
當然不同ROM中NMS可能會有不同,比如MIUI就對這部分内容進行了修改,是以小米手機關閉通知權限不會導緻Toast不顯示。
/**
* Show the view for the specified duration.
*/
public void show() {
if (mNextView == null) {
throw new RuntimeException("setView must have been called");
}
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
try {
service.enqueueToast(pkg, tn, mDuration);
} catch (RemoteException e) {
// Empty
}
}
如何解決這個問題?隻要能夠繞過NotificationManagerService即可。
DovaToast通過使用TYPE_TOAST實作全局彈窗功能,不使用系統Toast,也沒有使用NMS服務,是以不受通知權限限制。
問題二:系統Toast的隊列機制在不同手機上可能會不相同
我找了四台裝置,建立兩個Gravity不同的Toast并調用show()方法,結果出現了四種展示效果:
* 榮耀5C-android7.0(隻看到展示第一個Toast)
* 小米8-MIUI10(隻看到展示第二個Toast,即新的Toast.show會中止目前Toast的展示)
* 紅米6pro-MIUI9(兩個Toast同時展示)
* 榮耀5C-android6.0(第一個TOAST展示完成後,第二個才開始展示)
造成這個問題的原因應該是各大廠商ROM中NMS維護Toast隊列的邏輯有差異。 同樣的,DToast内部也維護着自己的隊列邏輯,保證在所有手機上使用DToast的效果相同。
DToast中多個彈窗連續出現時:
1.相同優先級時,會終止上一個,直接展示後一個;
2.不同優先級時,如果後一個的優先級更高則會終止上一個,直接展示後一個。
問題三:系統Toast的BadTokenException問題
- Toast有個内部類 TN(extends ITransientNotification.Stub),調用Toast.show()時會将TN傳遞給NMS;
public void show() { if (mNextView == null) { throw new RuntimeException("setView must have been called"); } INotificationManager service = getService(); String pkg = mContext.getOpPackageName(); TN tn = mTN; tn.mNextView = mNextView; try { service.enqueueToast(pkg, tn, mDuration); } catch (RemoteException e) { // Empty } }
-
在NMS中會生成一個windowToken,并将windowToken給到WindowManagerService,WMS會暫時儲存該token并用于之後的校驗;
NotificationManagerService.java #enqueueToast源碼:
synchronized (mToastQueue) { int callingPid = Binder.getCallingPid(); long callingId = Binder.clearCallingIdentity(); try { ToastRecord record; int index = indexOfToastLocked(pkg, callback); // If it's already in the queue, we update it in place, we don't // move it to the end of the queue. if (index >= 0) { record = mToastQueue.get(index); record.update(duration); } else { // Limit the number of toasts that any given package except the android // package can enqueue. Prevents DOS attacks and deals with leaks. if (!isSystemToast) { int count = 0; final int N = mToastQueue.size(); for (int i=0; i<N; i++) { final ToastRecord r = mToastQueue.get(i); if (r.pkg.equals(pkg)) { count++; if (count >= MAX_PACKAGE_NOTIFICATIONS) { Slog.e(TAG, "Package has already posted " + count + " toasts. Not showing more. Package=" + pkg); return; } } } } Binder token = new Binder();//生成一個token mWindowManagerInternal.addWindowToken(token, TYPE_TOAST, DEFAULT_DISPLAY); record = new ToastRecord(callingPid, pkg, callback, duration, token); mToastQueue.add(record); index = mToastQueue.size() - 1; keepProcessAliveIfNeededLocked(callingPid); } // If it's at index 0, it's the current toast. It doesn't matter if it's // new or just been updated. Call back and tell it to show itself. // If the callback fails, this will remove it from the list, so don't // assume that it's valid after this. if (index == 0) { showNextToastLocked(); } } finally { Binder.restoreCallingIdentity(callingId); } }
- 然後NMS通過調用TN.show(windowToken)回傳token給TN;
/** * schedule handleShow into the right thread */ @Override public void show(IBinder windowToken) { if (localLOGV) Log.v(TAG, "SHOW: " + this); mHandler.obtainMessage(SHOW, windowToken).sendToTarget(); }
-
TN使用該token嘗試向WindowManager中添加Toast視圖(mParams.token = windowToken);
在API25的源碼中,Toast的WindowManager.LayoutParams參數新增了一個token屬性,用于對添加的視窗進行校驗。
- 當param.token為空時,WindowManagerImpl會為其設定 DefaultToken;
@Override public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) { applyDefaultToken(params); mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow); } private void applyDefaultToken(@NonNull ViewGroup.LayoutParams params) { // Only use the default token if we don't have a parent window. if (mDefaultToken != null && mParentWindow == null) { if (!(params instanceof WindowManager.LayoutParams)) { throw new IllegalArgumentException("Params must be WindowManager.LayoutParams"); } // Only use the default token if we don't already have a token. final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params; if (wparams.token == null) { wparams.token = mDefaultToken; } } }
- 當WindowManager收到addView請求後會檢查 mParams.token 是否有效,若有效則添加視窗展示,否則抛出BadTokenException異常.
switch (res) { case WindowManagerGlobal.ADD_BAD_APP_TOKEN: case WindowManagerGlobal.ADD_BAD_SUBWINDOW_TOKEN: throw new WindowManager.BadTokenException( "Unable to add window -- token " + attrs.token + " is not valid; is your activity running?"); case WindowManagerGlobal.ADD_NOT_APP_TOKEN: throw new WindowManager.BadTokenException( "Unable to add window -- token " + attrs.token + " is not for an application"); case WindowManagerGlobal.ADD_APP_EXITING: throw new WindowManager.BadTokenException( "Unable to add window -- app for token " + attrs.token + " is exiting"); case WindowManagerGlobal.ADD_DUPLICATE_ADD: throw new WindowManager.BadTokenException( "Unable to add window -- window " + mWindow + " has already been added"); case WindowManagerGlobal.ADD_STARTING_NOT_NEEDED: // Silently ignore -- we would have just removed it // right away, anyway. return; case WindowManagerGlobal.ADD_MULTIPLE_SINGLETON: throw new WindowManager.BadTokenException("Unable to add window " + mWindow + " -- another window of type " + mWindowAttributes.type + " already exists"); case WindowManagerGlobal.ADD_PERMISSION_DENIED: throw new WindowManager.BadTokenException("Unable to add window " + mWindow + " -- permission denied for window type " + mWindowAttributes.type); case WindowManagerGlobal.ADD_INVALID_DISPLAY: throw new WindowManager.InvalidDisplayException("Unable to add window " + mWindow + " -- the specified display can not be found"); case WindowManagerGlobal.ADD_INVALID_TYPE: throw new WindowManager.InvalidDisplayException("Unable to add window " + mWindow + " -- the specified window type " + mWindowAttributes.type + " is not valid"); }
什麼情況下windowToken會失效?
UI線程發生阻塞,導緻TN.show()沒有及時執行,當NotificationManager的檢測逾時後便會删除WMS中的該token,即造成token失效。
如何解決?
Google在API26中修複了這個問題,即增加了try-catch:
// 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 */
}
是以對于8.0之前的我們也需要做相同的處理。DToast是通過反射完成這個動作,具體看下方實作:
//捕獲8.0之前Toast的BadTokenException,Google在Android 8.0的代碼送出中修複了這個問題
private void hook(Toast toast) {
try {
Field sField_TN = Toast.class.getDeclaredField("mTN");
sField_TN.setAccessible(true);
Field sField_TN_Handler = sField_TN.getType().getDeclaredField("mHandler");
sField_TN_Handler.setAccessible(true);
Object tn = sField_TN.get(toast);
Handler preHandler = (Handler) sField_TN_Handler.get(tn);
sField_TN_Handler.set(tn, new SafelyHandlerWrapper(preHandler));
} catch (Exception e) {
e.printStackTrace();
}
}
public class SafelyHandlerWrapper extends Handler {
private Handler impl;
public SafelyHandlerWrapper(Handler impl) {
this.impl = impl;
}
@Override
public void dispatchMessage(Message msg) {
try {
impl.dispatchMessage(msg);
} catch (Exception e) {
}
}
@Override
public void handleMessage(Message msg) {
impl.handleMessage(msg);//需要委托給原Handler執行
}
}
問題四:Android8.0之後的token null is not valid問題
Android8.0後對WindowManager做了限制和修改,特别是TYPE_TOAST類型的視窗,必須要傳遞一個token用于校驗。
API25:(PhoneWindowManager.java源碼)
public int checkAddPermission(WindowManager.LayoutParams attrs, int[] outAppOp) {
int type = attrs.type;
outAppOp[0] = AppOpsManager.OP_NONE;
if (!((type >= FIRST_APPLICATION_WINDOW && type <= LAST_APPLICATION_WINDOW)
|| (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW)
|| (type >= FIRST_SYSTEM_WINDOW && type <= LAST_SYSTEM_WINDOW))) {
return WindowManagerGlobal.ADD_INVALID_TYPE;
}
if (type < FIRST_SYSTEM_WINDOW || type > LAST_SYSTEM_WINDOW) {
// Window manager will make sure these are okay.
return WindowManagerGlobal.ADD_OKAY;
}
String permission = null;
switch (type) {
case TYPE_TOAST:
// XXX right now the app process has complete control over
// this... should introduce a token to let the system
// monitor/control what they are doing.
outAppOp[0] = AppOpsManager.OP_TOAST_WINDOW;
break;
case TYPE_DREAM:
case TYPE_INPUT_METHOD:
case TYPE_WALLPAPER:
case TYPE_PRIVATE_PRESENTATION:
case TYPE_VOICE_INTERACTION:
case TYPE_ACCESSIBILITY_OVERLAY:
case TYPE_QS_DIALOG:
// The window manager will check these.
break;
case TYPE_PHONE:
case TYPE_PRIORITY_PHONE:
case TYPE_SYSTEM_ALERT:
case TYPE_SYSTEM_ERROR:
case TYPE_SYSTEM_OVERLAY:
permission = android.Manifest.permission.SYSTEM_ALERT_WINDOW;
outAppOp[0] = AppOpsManager.OP_SYSTEM_ALERT_WINDOW;
break;
default:
permission = android.Manifest.permission.INTERNAL_SYSTEM_WINDOW;
}
if (permission != null) {
...
}
return WindowManagerGlobal.ADD_OKAY;
}
API26:(PhoneWindowManager.java源碼)
public int checkAddPermission(WindowManager.LayoutParams attrs, int[] outAppOp) {
int type = attrs.type;
outAppOp[0] = AppOpsManager.OP_NONE;
if (!((type >= FIRST_APPLICATION_WINDOW && type <= LAST_APPLICATION_WINDOW)
|| (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW)
|| (type >= FIRST_SYSTEM_WINDOW && type <= LAST_SYSTEM_WINDOW))) {
return WindowManagerGlobal.ADD_INVALID_TYPE;
}
if (type < FIRST_SYSTEM_WINDOW || type > LAST_SYSTEM_WINDOW) {
// Window manager will make sure these are okay.
return ADD_OKAY;
}
if (!isSystemAlertWindowType(type)) {
switch (type) {
case TYPE_TOAST:
// Only apps that target older than O SDK can add window without a token, after
// that we require a token so apps cannot add toasts directly as the token is
// added by the notification system.
// Window manager does the checking for this.
outAppOp[0] = OP_TOAST_WINDOW;
return ADD_OKAY;
case TYPE_DREAM:
case TYPE_INPUT_METHOD:
case TYPE_WALLPAPER:
case TYPE_PRESENTATION:
case TYPE_PRIVATE_PRESENTATION:
case TYPE_VOICE_INTERACTION:
case TYPE_ACCESSIBILITY_OVERLAY:
case TYPE_QS_DIALOG:
// The window manager will check these.
return ADD_OKAY;
}
return mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
== PERMISSION_GRANTED ? ADD_OKAY : ADD_PERMISSION_DENIED;
}
}
為了解決問題一,DovaToast不得不選擇繞過NotificationManagerService的控制,但由于windowToken是NMS生成的, 繞過NMS就無法擷取到有效的windowToken,于是作為TYPE_TOAST的DovaToast就可能陷入第四個問題。是以,DToast選擇在DovaToast出現 該問題時引入ActivityToast,在DovaToast無法正常展示時建立一個依附于Activity的彈窗展示出來,不過ActivityToast隻會展示在目前Activity,不具有跨頁面功能。 如果說有更好的方案,那肯定是去擷取懸浮窗權限然後改用TYPE_PHONE等類型,但懸浮窗權限往往不容易擷取,目前來看恐怕除了微信其他APP都不能保證拿得到使用者的懸浮窗權限。
問題五:Android7.1之後,不允許同時展示兩個TYPE_TOAST彈窗
DToast的彈窗政策就是同一時間最多隻展示一個彈窗,邏輯上就避免了此問題。是以僅捕獲該異常。
TODO LIST:
- 增加适配應用已擷取到懸浮窗權限的情況
- 考慮是否需要支援同時展示多個彈窗
其他建議
- 新項目做應用架構的時候可以考慮把整個應用(除閃屏頁等特殊界面外)做成隻有一個Activity,其他全是Fragment,這樣就不存在懸浮窗的問題啦。
- 如果能夠接受Toast不跨界面的話,建議使用SnackBar
最後附上自己封裝好的moudle庫,希望對每個人有用!----》https://download.csdn.net/download/small_and_smallworld/10831796