Toast是Android平臺上的經常使用技術。從用戶角度來看,Toast是用戶與App交互最基本的提示控件;從開發者角度來看,Toast是開發過程當中經常使用的調試手段之一。此外,Toast語法也很是簡單,僅需一行代碼。基於簡單易用的優勢,Toast在Android開發過程當中被普遍使用。html
可是,Toast是系統層面提供的,不依賴於前臺頁面,存在濫用的風險。爲了規避這些風險,Google在Android系統版本的迭代過程當中,不斷進行了優化和限制。這些限制不可避免的影響到了正常的業務邏輯,在迭代過程當中,咱們遇到過如下幾個問題:java
BadTokenException
異常,致使App崩潰。TYPE_TOAST
類型的Window,在Android 7.1.一、7.1.2發生token null is not valid
異常,致使App崩潰。在美團平臺的業務中,Toast被用做主流程交互的提示控件,好比在完成下單、評價、分享後進行各類提示。Toast被限制以後會給用戶帶來誤解。爲了解決正常的業務Toast被系統限制誤傷的問題,咱們與Toast展開了一系列的鬥爭。android
舉個案例:某個用戶投訴美團App在分享朋友圈後沒有任何提示,不知道是否分享成功。具體緣由是用戶在設置裏關閉了美團App的【顯示通知】開關,致使通知權限沒法獲取,這極大的影響了用戶體驗。然而,在Android 4.4(API19)如下系統中,這個開關的打開狀態,也就是通知權限是否開啓的狀態咱們是沒法判斷的,所以咱們也沒法感知Toast彈出與否,爲了解決這個問題,須要從Toast的源碼入手,最後源碼總結步驟以下:編程
Toast#show()
源碼中,Toast的展現並不是本身控制,而是經過AIDL使用INotificationManager獲取到NotificationManagerService(NMS)這個遠程服務。service.enqueueToast(pkg, tn, mDuration)
將當前Toast的顯示加入到通知隊列,並傳遞了一個tn對象,這個對象就是NMS用做回傳Toast的顯示狀態。WindowManager
將構造的Toast添加到當前的window中,須要注意的是這個window的type類型是TYPE_TOAST
。那麼爲何禁掉通知權限會致使Toast再也不彈出呢? 經過以上分析,Toast的展現是由NMS
服務控制的,NMS
服務會作一些權限、token等的校驗,當通知權限一旦關閉,Toast將再也不彈出。app
若是可以繞過NMS
服務的校驗那麼就能夠達到咱們的訴求,繞過的方法是按照Toast的源碼,實現咱們本身的MToast,並將NMS替換成本身的ToastManager,以下圖:ide
方案定了後,須要作的事情就是代碼替換。做爲平臺型App,美團App大量使用了Toast,人工替換確定會出現遺漏的地方,爲了能用更少的人力來解決這個問題,咱們採用了以下方案。oop
美團App在早期就因業務須要接入了AspectJ,AspectJ是Java中作AOP編程的利器,基本原理就是在代碼編譯期對切面的代碼進行修改,插入咱們預先寫好的邏輯或者直接替換當前方法的實現。美團App的作法就是借用AspectJ,從源頭攔截並替換Toast的調用實現。佈局
關鍵代碼以下:測試
@Aspect
public class ToastAspect {
@Pointcut("call(* android.widget.Toast+.show(..))")
public void toastShow() {
}
@Around("toastShow()")
public void toastShow(ProceedingJoinPoint point) {
Toast toast = (Toast) point.getTarget();
Context context = (Context) ReflectUtils.getValue(toast, "mContext");
if (Build.VERSION.SDK_INT >= 19 && NotificationManagerCompat.from(context).areNotificationsEnabled()) {
point.proceed(point.getArgs());
} else {
floatToastShow(toast, context);
}
}
private static void floatToastShow(Toast toast, Context context) {
...
new MToast(context)
.setDuration(mDuration)
.setView(mNextView)
.setGravity(mGravity, mX, mY)
.setMargin(mHorizontalMargin, mVerticalMargin)
.show();
}
}
複製代碼
其中MToast是TYPE_TOAST
類型的的Window,這樣即便禁掉通知權限,業務代碼也能夠不做任何修改,繼續彈出Toast。而底層已經被無感知的替換成本身的MToast了,以最小的成本達到了目標。優化
BadTokenException
美團App在線上常常會上報BadTokenException
Crash,並且集中在Android 5.0 - Android 7.1.2的機型上。具體Crash堆棧以下:
android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@6caa743 is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:607)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:341)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:106)
at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3242)`BadTokenException`
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2544)
at android.app.ActivityThread.access$900(ActivityThread.java:168)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1378)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:150)
at android.app.ActivityThread.main(ActivityThread.java:5665)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:822)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:712)
複製代碼
BadTokenException
緣由分析咱們知道在Android上,任何視圖的顯示都要依賴於一個視圖窗口Window,一樣Toast的顯示也須要一個窗口,前文已經分析了這個窗口的類型就是TYPE_TOAST,是一個系統窗口,這個窗口最終會被WindowManagerService(WMS)標記管理。可是咱們的普通應用程序怎麼能擁有添加系統窗口的權限呢?查看源碼後發現須要如下幾個步驟:
詳細的原理圖以下:
在Android 7.1.1的NMS源碼中,關鍵代碼以下:
void showNextToastLocked() {
ToastRecord record = mToastQueue.get(0);
while (record != null) {
try {
// 調用tn對象的show方法展現toast,並回傳token
record.callback.show(record.token);
// 超時處理
scheduleTimeoutLocked(record);
return;
} catch (RemoteException e) {
...
}
}
}
private void scheduleTimeoutLocked(ToastRecord r) {
mHandler.removeCallbacksAndMessages(r);
Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
// 根據toast顯示的時長,延遲觸發消息,最終調用下面的方法
mHandler.sendMessageDelayed(m, delay);
}
private void handleTimeout(ToastRecord record) {
synchronized (mToastQueue) {
int index = indexOfToastLocked(record.pkg, record.callback);
if (index >= 0) {
cancelToastLocked(index);
}
}
}
void cancelToastLocked(int index) {
ToastRecord record = mToastQueue.get(index);
try {
// 調用tn對象的hide方法隱藏toast
record.callback.hide();
} catch (RemoteException e) {
...
}
ToastRecord lastToast = mToastQueue.remove(index);
// 移除當前的toast的token,token就此失效
mWindowManagerInternal.removeWindowToken(lastToast.token, true, DEFAULT_DISPLAY);
...
}
複製代碼
經過以上分析showNextToastLocked()
被調用後,若是此時主線程因爲其它緣由被阻塞致使handleShow()
不能及時調用,從而觸發超時邏輯致使token失效。主線程阻塞結束後,繼續執行Toast的show方法時,發現token已經失效了,因而拋出BadTokenException
異常從而致使上述Crash。
可使用如下的代碼驗證此異常:
Toast.makeText(this, "測試Crash", Toast.LENGTH_SHORT).show();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
複製代碼
那麼如何解決這個異常呢?首先想到就是對Toast加上try-catch,可是發現不起做用,緣由是這個異常並不是在當前線程中當即被拋出的,而是添加到了消息隊列中,等待消息真正執行時纔會被拋出。Google在Android 8.0的代碼提交中修復了這個問題,把8.0的源碼和前一版本對比能夠發現,如同咱們的分析,Google在消息執行處將異常catch住了。那麼針對8.0以前的版本發生的Crash怎麼辦呢?美團平臺使用了一個相似代理反射的通用解決方案,結構以下圖:
基本原理:使用咱們本身實現的ToastHandler替換Toast內部的Handler,ToastHandler做用就是把異常catch住,這種修改思路和Android 8.0修復思路保持一致,只不過一個是在系統層面解決,一個是在用戶層面解決。
token null is not valid
在Android 7.1.一、7.1.2和去年8月發佈的Android 8.0系統中,咱們的方案出現了另外一個異常token null is not valid
,這個異常堆棧以下:
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:683)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:342)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:94)
複製代碼
token null is not valid
緣由分析這個異常其實並不是是Toast的異常,而是Google對WindowManage的一些限制致使的。Android從7.1.1版本開始,對WindowManager作了一些限制和修改,特別是TYPE_TOAST
類型的窗口,必需要傳遞一個token用於權限校驗才容許添加。Toast源碼在7.1.1及以上也有了變化,Toast的WindowManager.LayoutParams參數額外添加了一個token屬性,這個屬性的來源就已經在上文分析過了,它是在NMS中被初始化的,用於對添加的窗口類型進行校驗。當用戶禁掉通知權限時,因爲AspectJ的存在,最終會調用咱們封裝的MToast,可是MToast沒有通過NMS,所以沒法獲取到這個屬性,另外就算咱們按照NMS的方法本身生成一個token,這個token也是沒有添加TYPE_TOAST
權限的,最終仍是沒法避免這個異常的發生。
源碼中關鍵代碼以下:
// 方法簽名多了一個IBinder類型的token,它是在NMS中建立的
public void handleShow(IBinder windowToken) {
...
if (mView != mNextView) {
...
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
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;
// 這裏添加了token
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
...
try {
// 8.0版本的系統,將這裏的異常catch住了
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
複製代碼
通過調研,發現Google對WindowManager的限制,讓咱們不得不放棄使用TYPE_TOAST
類型的窗口替代Toast,也表明了咱們上述使用WindowManager方案的終結。
咱們的核心目標只是但願在用戶關閉通知消息開關的狀況下,能繼續看到通知,因此咱們使用了WindowManager添加自定義window的方式來替換Toast,可是在替換的過程當中遇到了一些Toast的Crash異常,爲了解決這些Crash,咱們提出了使用自定義ToastHandler的方式來catch住異常,確保app正常運行。在方案推廣上,爲了能用更少的人力,更高的效率完成替換,咱們使用了AspectJ的方案。最後,在Android 7.1.1版本開始,因爲Google對WindowManager的限制,致使這種使用自定義window的替換Toast的方式再也不可行,咱們便開始尋找替換Toast的其它可行方案。
爲了繼續能讓用戶在禁掉通知權限的狀況下,也能看到通知以及屏蔽上述Toast帶來的Crash,咱們通過調研、分析並嘗試瞭如下幾種方案。
以上幾種方案的共同點是爲了繞過通知權限的檢查,即便用戶禁掉了通知權限,咱們自定義的通知依然能夠不受影響的彈出來,可是也有很明顯的缺陷,以下圖:
通過對比,咱們也採用了Snackbar替換Toast的方案,緣由是Snackbar是Android自5.0系統推出MaterialDesign後官方推薦的控件,在交互友好性方面比Toast要好,例如:支持手勢操做,支持與CoordinatorLayout聯動等,Snackbar做爲提示控件目前在市面上也被普遍使用,而其它方案有明顯的缺陷以下:
首先,使用WindowManager添加懸浮窗的方式,雖然這種方式能和原生的Toast保持完美的一致性,可是須要的權限過高,坑也太多。TYPE_PHONE
的權限要比TYPE_TOAST
權限敏感太多,並且在Android 8.0系統上必須使用TYPE_APPLICATION_OVERLAY
這個type,而且要申請如下兩個權限,這兩個權限不只須要在清單文件中聲明,並且絕大部分手機默認是關閉狀態,須要咱們引導用戶開啓,若是用戶選擇不開啓,那麼Toast仍是不能彈出。同時還須要適配衆多定製化ROM的國產機型。繞過了通知權限的坑,又跳入了懸浮窗權限的坑,這是不可取的。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW"/>
複製代碼
其次,使用Dialog方式也有明顯的缺陷,Dialog、DialogFragment、PopupWindow都嚴重依賴於Activity,沒有Activity做爲上下文時,它們是沒法建立和顯示的,而且簡單的通知使用這種控件太重。此外,在UI展現和API一致性上,幾乎和Toast沒有什麼關係,須要額外作封裝的成本比較大。
咱們在使用Snackbar替換Toast時遇到了如下兩個問題:
首先,爲了知足自身業務的擴展性、靈活性,咱們參照系統Snackbar的源碼,進行了按需定製,好比多樣化的樣式擴展、進入進出的動畫擴展、支持自定義佈局的擴展等,接口更加豐富。一方面是爲了解決以上遇到的問題,另外一方面也是爲了在業務的迭代過程當中能快速開發和適配。如下是基本的類圖依賴關係:
針對Snackbar彈出的時候,被Dialog,PopupWindow等控件遮住的問題,緣由在於Snackbar依賴於View,當把Activity佈局的View傳給Snackbar作爲Snackbar展現依賴的父View時,後面再彈Dialog,PopupWindow等控件,Snackbar就會被控件遮擋。正確的作法是直接把PopupWindow和Dialog所依賴的View傳給Snackbar。那麼咱們定製化的Snackbar不只支持傳遞這個View,也支持直接傳遞PopupWindow和Dialog的實例,上圖中SnackbarBuilder的方法反應了這個改動。
比較複雜的問題是Snackbar不支持跨頁面展現,咱們在項目中有大量這樣的代碼:
Toast.makeText(this, "彈出消息", Toast.LENGTH_SHORT).show();
finish();
複製代碼
當直接把Toast替換成Snackbar後,這個消息會一閃而過,用戶來不及查看,由於Snackbar依賴的Activity被銷燬了,爲了解決這個問題,咱們一共探討了三種方案:
方案一: 使用startActivityForResult
替換全部跨頁面展現的通知,也就是在A頁面使用startActivityForResult
跳轉到B頁面,把本來在B頁面彈出Toast的邏輯,改寫到A頁面本身彈出Snackbar。
這種方案:優勢在於責任清晰明確,頁面被finish後應該展現什麼通知以及應該由誰觸發這個通知的展現,這個責任自己就在調用方;缺點在於代碼改動比較大。所以咱們捨棄了這種方案。
方案二: 使用Application.ActivityLifecycleCallbacks
全局監聽Activity的生命週期,當一個頁面關閉的時候,記錄下Snackbar剩餘須要展現的時間,在進入下一個Activity後,讓沒有展現完的Snackbar繼續展現。
這種方案:優勢在於代碼改動量小;缺點在於在頁面切換過程當中,若是Snackbar沒有展現結束,會出現一次閃爍。雖然在技術上這種方案很好,代碼的侵入性極低,可是這個閃爍對於產品來講沒法接受,所以這種方案也不作考慮。
方案三: 使用本地廣播進行跨頁面展現,這也是美團最終使用的解決方案,具體原理以下
這是方案一的自動化版本,爲了達到自動化的效果和對原有代碼的最小侵入性,咱們設計了一個輔助類,就是上圖中的SnackbarHelper
,原理圖以下:
SnackbarHelper提供統一的入口,接入成本低,只須要將原有使用context.startActivity()、context.startActivityForResult()、context.finish()的地方改爲SnackBarHelper下面的同名方法便可。這樣經過廣播的方法完成了Snackbar的跨頁面展現,業務方的代碼修改量僅僅是改一下調用方式,改動極小。
目前這套解決方案在美團業務中被普遍使用,能覆蓋到絕大部分場景。通知的展示形式基本與Toast沒有區別,不只解決了用戶在禁掉通知的狀況下沒法看到通知的困境,也下降了客訴率。
子堯,美團點評高級工程師,2017年加入美團點評,負責平臺搜索、平臺首頁等研發工做。
騰飛,美團點評資深工程師,2015年加入美團點評,平臺基礎業務組負責人,負責平臺業務的迭代。
對咱們團隊感興趣,能夠關注咱們的專欄。美團平臺客戶端技術團隊長期招聘技術專家,有興趣的同窗能夠發送簡歷到:fangjintao#meituan.com,詳細JD。