同學(xué)景图,你的系統(tǒng)Toast可能需要修復(fù)一下

歡迎star/issue,項目地址:https://github.com/Dovar66/DToast

先看看使用系統(tǒng)Toast存在的問題:

1.當(dāng)通知權(quán)限被關(guān)閉時在華為等手機上Toast不顯示;

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

3.Toast的BadTokenException問題;

當(dāng)發(fā)現(xiàn)系統(tǒng)Toast存在問題時干花,不少同學(xué)都會采用自定義的TYPE_TOAST彈窗來實現(xiàn)相同效果妄帘。雖然大部分情況下效果都是OK的,但其實TYPE_TOAST彈窗依然存在兼容問題:

4.Android8.0之后的token null is not valid問題(實測部分機型問題)池凄;

5.Android7.1之后抡驼,不允許同時展示兩個TYPE_TOAST彈窗(實測部分機型問題)。

那么肿仑,DToast使用的解決方案是:

1.通知權(quán)限未被關(guān)閉時致盟,使用SystemToast(修復(fù)了問題2和問題3的系統(tǒng)Toast);
2.通知權(quán)限被關(guān)閉時,使用DovaToast(自定義的TYPE_TOAST彈窗);
3.當(dāng)使用DovaToast出現(xiàn)token null is not valid時尤慰,嘗試使用ActivityToast(自定義的TYPE_APPLICATION_ATTACHED_DIALOG彈窗馏锡,只有當(dāng)傳入Context為Activity時,才會啟用ActivityToast).

相信不少同學(xué)舊項目中封裝的ToastUtil都是直接使用的ApplicationContext作為上下文伟端,然后在需要彈窗的時候直接就是ToastUtil.show(str)杯道,這樣的使用方式對于我們來說是最方便的啦。

當(dāng)然责蝠,使用DToast你也依然可以沿用這種封裝方式蕉饼,但這種方式在下面這個場景中可能會無法成功展示出彈窗(該場景下原生Toast也一樣無法彈出)虐杯,不過請放心不會導(dǎo)致應(yīng)用崩潰,而且這個場景出現(xiàn)的概率較小昧港,有以下三個必要條件:1.通知欄權(quán)限被關(guān)閉(通知欄權(quán)限默認(rèn)都是打開的) 2.非MIUI手機 3.Android8.0以上的部分手機(我最近測試中的幾部8.0+設(shè)備都不存在該問題)擎椰。

不過,如果想要保證在所有場景下都能正常展示彈窗创肥,還是建議在DToast.make(context)時傳入Activity作為上下文达舒,這樣在該場景下DToast會啟用ActivityToast展示出彈窗。

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

問題一:關(guān)閉通知權(quán)限時Toast不顯示

看下方Toast源碼中的show()方法叹侄,通過AIDL獲取到INotificationManager巩搏,并將接下來的顯示流程控制權(quán)
交給NotificationManagerService。
NMS中會對Toast進行權(quán)限校驗趾代,當(dāng)通知權(quán)限校驗不通過時贯底,Toast將不做展示。
當(dāng)然不同ROM中NMS可能會有不同撒强,比如MIUI就對這部分內(nèi)容進行了修改禽捆,所以小米手機關(guān)閉通知權(quán)限不會導(dǎo)致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實現(xiàn)全局彈窗功能,不使用系統(tǒng)Toast,也沒有使用NMS服務(wù)牙躺,因此不受通知權(quán)限限制。

問題二:系統(tǒng)Toast的隊列機制在不同手機上可能會不相同

 我找了四臺設(shè)備孽拷,創(chuàng)建兩個Gravity不同的Toast并調(diào)用show()方法,結(jié)果出現(xiàn)了四種展示效果:

        * 榮耀5C-android7.0(只看到展示第一個Toast)
        * 小米8-MIUI10(只看到展示第二個Toast乓搬,即新的Toast.show會中止當(dāng)前Toast的展示)
        * 紅米6pro-MIUI9(兩個Toast同時展示)
        * 榮耀5C-android6.0(第一個TOAST展示完成后,第二個才開始展示)

造成這個問題的原因應(yīng)該是各大廠商ROM中NMS維護Toast隊列的邏輯有差異代虾。
同樣的进肯,DToast內(nèi)部也維護著自己的隊列邏輯,保證在所有手機上使用DToast的效果相同棉磨。

 DToast中多個彈窗連續(xù)出現(xiàn)時:

        1.相同優(yōu)先級時江掩,會終止上一個,直接展示后一個;
        2.不同優(yōu)先級時环形,如果后一個的優(yōu)先級更高則會終止上一個策泣,直接展示后一個。

問題三:系統(tǒng)Toast的BadTokenException問題

  • Toast有個內(nèi)部類 TN(extends ITransientNotification.Stub)抬吟,調(diào)用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通過調(diào)用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參數(shù)新增了一個token屬性,用于對添加的窗口進行校驗擎析。

image
  • 當(dāng)param.token為空時簿盅,WindowManagerImpl會為其設(shè)置 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;
              }
          }
      }
    
  • 當(dāng)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線程發(fā)生阻塞愉烙,導(dǎo)致TN.show()沒有及時執(zhí)行讨盒,當(dāng)NotificationManager的檢測超時后便會刪除WMS中的該token解取,即造成token失效步责。

如何解決?

Google在API26中修復(fù)了這個問題禀苦,即增加了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是通過反射完成這個動作,具體看下方實現(xiàn):

  //捕獲8.0之前Toast的BadTokenException振乏,Google在Android 8.0的代碼提交中修復(fù)了這個問題
     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執(zhí)行
         }
     }

問題四: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出現(xiàn)該問題時引入ActivityToast裆装,在DovaToast無法正常展示時創(chuàng)建一個依附于Activity的彈窗展示出來,不過ActivityToast只會展示在當(dāng)前Activity,不具有跨頁面功能哨免。
如果說有更好的方案茎活,那肯定是去獲取懸浮窗權(quán)限然后改用TYPE_PHONE等類型,但懸浮窗權(quán)限往往不容易獲取琢唾,目前來看恐怕除了微信其他APP都不能保證拿得到用戶的懸浮窗權(quán)限载荔。

問題五:Android7.1之后,不允許同時展示兩個TYPE_TOAST彈窗

DToast的彈窗策略就是同一時間最多只展示一個彈窗采桃,邏輯上就避免了此問題身辨。因此僅捕獲該異常酬蹋。

TODO LIST:

  • 增加適配應(yīng)用已獲取到懸浮窗權(quán)限的情況
  • 考慮是否需要支持同時展示多個彈窗

其他建議

  • 新項目做應(yīng)用架構(gòu)的時候可以考慮把整個應(yīng)用(除閃屏頁等特殊界面外)做成只有一個Activity蚀腿,其他全是Fragment呻畸,這樣就不存在懸浮窗的問題啦儒老。
  • 如果能夠接受Toast不跨界面的話比原,建議使用SnackBar
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末降狠,一起剝皮案震驚了整個濱河市西篓,隨后出現(xiàn)的幾起案子锣光,更是在濱河造成了極大的恐慌踪危,老刑警劉巖蔬浙,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異贞远,居然都是意外死亡畴博,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進店門蓝仲,熙熙樓的掌柜王于貴愁眉苦臉地迎上來俱病,“玉大人,你說我怎么就攤上這事袱结×料叮” “怎么了?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵垢夹,是天一觀的道長溢吻。 經(jīng)常有香客問我,道長果元,這世上最難降的妖魔是什么促王? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮而晒,結(jié)果婚禮上蝇狼,老公的妹妹穿的比我還像新娘。我一直安慰自己欣硼,他們只是感情好题翰,可當(dāng)我...
    茶點故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布恶阴。 她就那樣靜靜地躺著,像睡著了一般豹障。 火紅的嫁衣襯著肌膚如雪冯事。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天血公,我揣著相機與錄音昵仅,去河邊找鬼。 笑死累魔,一個胖子當(dāng)著我的面吹牛摔笤,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播垦写,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼吕世,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了梯投?” 一聲冷哼從身側(cè)響起命辖,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎分蓖,沒想到半個月后尔艇,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡么鹤,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年终娃,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蒸甜。...
    茶點故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡棠耕,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出迅皇,到底是詐尸還是另有隱情昧辽,我是刑警寧澤衙熔,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布登颓,位于F島的核電站,受9級特大地震影響红氯,放射性物質(zhì)發(fā)生泄漏框咙。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一痢甘、第九天 我趴在偏房一處隱蔽的房頂上張望喇嘱。 院中可真熱鬧,春花似錦塞栅、人聲如沸者铜。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽作烟。三九已至愉粤,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間拿撩,已是汗流浹背衣厘。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留压恒,地道東北人影暴。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像探赫,于是被迫代替她去往敵國和親型宙。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,877評論 2 345

推薦閱讀更多精彩內(nèi)容