同窗,你的系統Toast可能須要修復一下

歡迎star/issue,項目地址:github.com/Dovar66/DTo…java

先看看使用系統Toast存在的問題:android

1.當通知權限被關閉時在華爲等手機上Toast不顯示;

2.Toast的隊列機制在不一樣手機上可能會不相同;

3.Toast的BadTokenException問題;
複製代碼

當發現系統Toast存在問題時,很多同窗都會採用自定義的TYPE_TOAST彈窗來實現相同效果。雖然大部分狀況下效果都是 OK的,但其實TYPE_TOAST彈窗依然存在兼容問題:git

4.Android8.0以後的token null is not valid問題(實測部分機型問題);

5.Android7.1以後,不容許同時展現兩個TYPE_TOAST彈窗(實測部分機型問題)。
複製代碼

那麼,DToast使用的解決方案是:github

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>=26
4.Android8.0以上的部分手機。
複製代碼

因此,若是你的應用targetSdkVersion>=26,又想要保證在全部場景下都能正常展現彈窗,那麼請在DToast.make(context)時傳入Activity做爲上下文,這樣在該場景下DToast會啓用ActivityToast展現出彈窗。而targetSdkVersion小於26的同窗能夠放心使用ApplicationContext建立DToast。app

想了解爲何須要區別對待targetSdkVersion26+?點擊查看API26作了什麼   ide

而若是你還不瞭解targetSdkVersion 點擊這裏查看post

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

問題一:關閉通知權限時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的彈窗策略就是同一時間最多隻展現一個彈窗,邏輯上就避免了此問題。所以僅捕獲該異常。
複製代碼

其餘建議

  • 新項目作應用架構的時候能夠考慮把整個應用(除閃屏頁等特殊界面外)作成只有一個Activity,其餘全是Fragment,這樣就不存在懸浮窗的問題啦。
  • 若是可以接受Toast不跨界面的話,建議使用SnackBar
相關文章
相關標籤/搜索