背景
Toast是Android平臺上的常用技術。從使用者角度來看,Toast是使用者與App互動最基本的提示控制元件;從開發者角度來看,Toast是開發過程中常用的除錯手段之一。此外,Toast語法也非常簡單,僅需一行程式碼。基於簡單易用的優點,Toast在Android開發過程中被廣泛使用。
但是,Toast是系統層面提供的,不依賴於前臺頁面,存在濫用的風險。為了規避這些風險,Google在Android系統版本的迭代過程中,不斷進行了優化和限制。這些限制不可避免的影響到了正常的業務邏輯,在迭代過程中,我們遇到過以下幾個問題:
- 設定中關閉某個App的【顯示通知】開關,Toast不再彈出,極大的影響了使用者體驗。
- Toast在Android 7.1.2(API25)以下會發生
BadTokenException
異常,導致App崩潰。 - 自定義
TYPE_TOAST
型別的Window,在Android 7.1.1、7.1.2發生token null is not valid
異常,導致App崩潰。
與Toast鬥爭
在美團平臺的業務中,Toast被用作主流程互動的提示控制元件,比如在完成下單、評價、分享後進行各種提示。Toast被限制之後會給使用者帶來誤解。為了解決正常的業務Toast被系統限制誤傷的問題,我們與Toast展開了一系列的鬥爭。
鬥爭一:Toast不彈出
舉個案例:某個使用者投訴美團App在分享朋友圈後沒有任何提示,不知道是否分享成功。具體原因是使用者在設定裡關閉了美團App的【顯示通知】開關,導致通知許可權無法獲取,這極大的影響了使用者體驗。然而,在Android 4.4(API19)以下系統中,這個開關的開啟狀態,也就是通知許可權是否開啟的狀態我們是無法判斷的,因此我們也無法感知Toast彈出與否,為了解決這個問題,需要從Toast的原始碼入手,最後原始碼總結步驟如下:
- 在
Toast#show()
原始碼中,Toast的展示並非自己控制,而是通過AIDL使用INotificationManager獲取到NotificationManagerService(NMS)這個遠端服務。 - 呼叫
service.enqueueToast(pkg, tn, mDuration)
將當前Toast的顯示加入到通知佇列,並傳遞了一個tn物件,這個物件就是NMS用作回傳Toast的顯示狀態。 - 在tn的回撥方法中,使用
WindowManager
將構造的Toast新增到當前的window中,需要注意的是這個window的type型別是TYPE_TOAST
。
Toast不彈出原因分析
那麼為什麼禁掉通知許可權會導致Toast不再彈出呢?
通過以上分析,Toast的展示是由NMS
服務控制的,NMS
服務會做一些許可權、token等的校驗,當通知許可權一旦關閉,Toast將不再彈出。
可行性方案調研
如果能夠繞過NMS
服務的校驗那麼就可以達到我們的訴求,繞過的方法是按照Toast的原始碼,實現我們自己的MToast,並將NMS替換成自己的ToastManager,如下圖:
方案定了後,需要做的事情就是程式碼替換。作為平臺型App,美團App大量使用了Toast,人工替換肯定會出現遺漏的地方,為了能用更少的人力來解決這個問題,我們採用瞭如下方案。
解決方案
美團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)標記管理。但是我們的普通應用程式怎麼能擁有新增系統視窗的許可權呢?檢視原始碼後發現需要以下幾個步驟:
- 當顯示一個Toast時,NMS會生成一個token,而NMS本身就是一個系統級的服務,所以由它生成的token必然擁有許可權新增系統視窗。
- NMS通過ITransientNotification也就是tn物件,將生成的token回傳到我們自己的應用程式程式中。
- 應用程式呼叫handleShow方法,去向WindowManager新增視窗。
- WindowManager檢查當前視窗的token是否有效,如果有效,則新增視窗展示Toast;如果無效,則丟擲上述異常,Crash發生。
詳細的原理圖如下:
在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.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的可行方案
為了繼續能讓使用者在禁掉通知許可權的情況下,也能看到通知以及遮蔽上述Toast帶來的Crash,我們經過調研、分析並嘗試了以下幾種方案。
- 在7.1.1以上系統中繼續使用WindowManager方式,只不過需要把type改為TYPE_PHONE等懸浮窗許可權。
- 使用Dialog、DialogFragment、PopupWindow等彈窗控制元件來實現一個通知。
- 按照Snackbar的實現方式,找到一個可以新增布局的父佈局,採用addView的方式新增通知。
以上幾種方案的共同點是為了繞過通知許可權的檢查,即使使用者禁掉了通知許可權,我們自定義的通知依然可以不受影響的彈出來,但是也有很明顯的缺陷,如下圖:
經過對比,我們也採用了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彈出的時候,被Dialog,PopupWindow等控制元件遮住。
- Snackbar無法進行跨頁面展示,這是Snackbar實現原理決定的。
解決方案
首先,為了滿足自身業務的擴充套件性、靈活性,我們參照系統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沒有展示結束,會出現一次閃爍。雖然在技術上這種方案很好,程式碼的侵入性極低,但是這個閃爍對於產品來說無法接受,因此這種方案也不做考慮。
方案三: 使用本地廣播進行跨頁面展示,這也是美團最終使用的解決方案,具體原理如下
- 在A頁面跳轉B頁面前,使用當前傳入的Context註冊一個廣播。
- 在B頁面finish之前,傳送A在跳轉前註冊的廣播,並把需要展示的訊息使用Intent返回。
- 在廣播中獲取A頁面的例項,使用Snackbar展示B頁面回傳的訊息,並把當前廣播unRegister反註冊掉。
這是方案一的自動化版本,為了達到自動化的效果和對原有程式碼的最小侵入性,我們設計了一個輔助類,就是上圖中的SnackbarHelper
,原理圖如下:
SnackbarHelper提供統一的入口,接入成本低,只需要將原有使用context.startActivity()、context.startActivityForResult()、context.finish()的地方改成SnackBarHelper下面的同名方法即可。這樣通過廣播的方法完成了Snackbar的跨頁面展示,業務方的程式碼修改量僅僅是改一下呼叫方式,改動極小。
結語
目前這套解決方案在美團業務中被廣泛使用,能覆蓋到絕大部分場景。通知的展現形式基本與Toast沒有區別,不僅解決了使用者在禁掉通知的情況下無法看到通知的困境,也降低了客訴率。
作者簡介
子堯,美團點評高階工程師,2017年加入美團點評,負責平臺搜尋、平臺首頁等研發工作。
騰飛,美團點評資深工程師,2015年加入美團點評,平臺基礎業務組負責人,負責平臺業務的迭代。
招聘
對我們團隊感興趣,可以關注我們的專欄。美團平臺客戶端技術團隊長期招聘技術專家,有興趣的同學可以傳送簡歷到:fangjintao#meituan.com,詳細JD。