同學,你的系統Toast可能需要修復一下

Dovar_66發表於1970-01-01

歡迎star/issue,專案地址:github.com/Dovar66/DTo…

先看看使用系統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.你的應用設定的targetSdkVersion>
=264.Android8.0以上的部分手機。複製程式碼

所以,如果你的應用targetSdkVersion>
=26,又想要保證在所有場景下都能正常展示彈窗,那麼請在DToast.make(context)時傳入Activity作為上下文,這樣在該場景下DToast會啟用ActivityToast展示出彈窗。而targetSdkVersion小於26的同學可以放心使用ApplicationContext建立DToast。

想了解為什麼需要區別對待targetSdkVersion26+?點選檢視API26做了什麼 
 
 

而如果你還不瞭解targetSdkVersion 
點選這裡檢視

接下來再詳細分析下上面提到的五個問題:

問題一:關閉通知許可權時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屬性,用於對新增的視窗進行校驗。

同學,你的系統Toast可能需要修復一下
  • 當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的彈窗策略就是同一時間最多隻展示一個彈窗,邏輯上就避免了此問題。因此僅捕獲該異常。複製程式碼

其他建議

  • 新專案做應用架構的時候可以考慮把整個應用(除閃屏頁等特殊介面外)做成只有一個Activity,其他全是Fragment,這樣就不存在懸浮窗的問題啦。
  • 如果能夠接受Toast不跨介面的話,建議使用SnackBar

來源:https://juejin.im/post/5c05f011e51d451b802586c6#comment

相關文章