【Window系列】——Toast原始碼解析
前言
Toast元件應該是接觸Android中使用率非常高的一個原生控制元件,其使用的便捷性一直是開發者選用的原因,短短的一行程式碼就可以實現支援跨頁面的提示功能。但是隨著Google對於Android系統自身安全性的限制,導致Toast元件目前在高版本上也出現了許多問題,例如當關閉應用的通知欄許可權,全域性的Toast就無法展示了。本期部落格就先從原始碼角度分析Toast的實現原理,只有瞭解了Toast的實現原理,才能想辦法解決問題。
原始碼解析
我們使用Toast一般的使用方式如下:
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
所以我們來分別看一下兩個方法。
/** * Make a standard toast that just contains a text view. * * @param contextThe context to use.Usually your {@link android.app.Application} *or {@link android.app.Activity} object. * @param textThe text to show.Can be formatted text. * @param duration How long to display the message.Either {@link #LENGTH_SHORT} or *{@link #LENGTH_LONG} * */ public static Toast makeText(Context context, CharSequence text, @Duration int duration) { return makeText(context, null, text, duration); } public static Toast makeText(@NonNull Context context, @Nullable Looper looper, @NonNull CharSequence text, @Duration int duration) { Toast result = new Toast(context, looper); LayoutInflater inflate = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null); TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message); tv.setText(text); result.mNextView = v; result.mDuration = duration; return result; }
這裡有兩個注意點:
1.可以看到這裡註釋寫到了,延時duration
只能是變數LENGTH_SHORT
或LENGTH_LONG
具體原因後面原始碼分析到再看。
2.我們每次使用Toast都會new一個新的Toast物件,而這個佈局就是一個transient_notification.xml
檔案
現在首先來看一下Toast的建構函式
public Toast(@NonNull Context context, @Nullable Looper looper) { mContext = context; mTN = new TN(context.getPackageName(), looper); mTN.mY = context.getResources().getDimensionPixelSize( com.android.internal.R.dimen.toast_y_offset); mTN.mGravity = context.getResources().getInteger( com.android.internal.R.integer.config_toastDefaultGravity); }
可以看到這裡建立了一個TN
物件,這個TN
後面會貫穿整個Toast的使用全過程,所以我們先看一下這是個什麼物件。
private static class TN extends ITransientNotification.Stub { TN(String packageName, @Nullable Looper looper) { // XXX This should be changed to use a Dialog, with a Theme.Toast // defined that sets up the layout params appropriately. final WindowManager.LayoutParams params = mParams; params.height = WindowManager.LayoutParams.WRAP_CONTENT; params.width = WindowManager.LayoutParams.WRAP_CONTENT; params.format = PixelFormat.TRANSLUCENT; params.windowAnimations = com.android.internal.R.style.Animation_Toast; //type為TYPE_TOAST型別 params.type = WindowManager.LayoutParams.TYPE_TOAST; params.setTitle("Toast"); params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; mPackageName = packageName; if (looper == null) { // Use Looper.myLooper() if looper is not specified. //獲取Looper物件 looper = Looper.myLooper(); //如果自執行緒,沒有建立Looper物件,則拋異常 if (looper == null) { throw new RuntimeException( "Can't toast on a thread that has not called Looper.prepare()"); } } //建立Handler物件 mHandler = new Handler(looper, null) { @Override public void handleMessage(Message msg) { switch (msg.what) { case SHOW: { IBinder token = (IBinder) msg.obj; handleShow(token); break; } case HIDE: { handleHide(); // Don't do this in handleHide() because it is also invoked by // handleShow() mNextView = null; break; } case CANCEL: { handleHide(); // Don't do this in handleHide() because it is also invoked by // handleShow() mNextView = null; try { getService().cancelToast(mPackageName, TN.this); } catch (RemoteException e) { } break; } } } }; } }
首先可以看到這個TN物件繼承
了ITransientNotification.Stub
,看到這個名字,如果瞭解過AIDL機制的話,或者瞭解過Binder機制的,應該對這個名字很熟悉,這個不就是AIDL的實現類,所以可以看出Toast機制的底層實現肯定用到了Binder機制。可以看到這裡面有兩個方法被@Override
標記,show()
方法和hide()
方法,這不是正好和我們的顯示和隱藏對應嗎。
這裡我註釋著重寫了幾個點
1.首先可以看到這裡建立了WindowManager.LayoutParams
物件,並且設定了一系列熟悉,其中比較重要的一個是,這裡設定了一個type
屬性為TYPE_TOAST
,這個標記了這個Window的型別,而關閉通知欄許可權導致Toast無法展示也是和這個屬性有關,不影響本次原理分析,所以暫不分析。
2.獲取Looper物件,如果屬性Handler機制的話,應該看到這個方法很熟悉,Looper.myLooper()
這個方法底層利用ThreadLocal獲取Looper物件,而一般我們使用Toast都是在主執行緒使用,主執行緒的main方法,已經自動完成了Looper.prepare()方法和Looper.loop()方法,
所以已經自動完成了Looper的建立。這裡可以看到,如果沒有獲取到Looper物件,則會丟擲異常。所以這裡我們也可以對應分析一個問題:
自執行緒使用Toast物件會怎麼樣?
如果熟悉Handler機制的話,應該立馬能得出答案,當然是崩潰了,猶豫創建出來的自執行緒沒有建立Looper物件,所以這裡無法獲取到Looper物件,那麼就會拋異常,導致崩潰。
那麼自執行緒如何使用Toast呢?
還是Handler機制,既然沒有Looper機制,那麼就建立咯
new Thread(){ public void run(){ Looper.prepare();//給當前執行緒初始化Looper Toast.makeText(getApplicationContext(),"自執行緒Toast",0).show();//Toast初始化的時候會new Handler();無參構造預設獲取當前執行緒的Looper,如果沒有prepare過,則丟擲題主描述的異常。上一句程式碼初始化過了,就不會出錯。 Looper.loop();//這句執行,Toast排隊show所依賴的Handler發出的訊息就有人處理了,Toast就可以吐出來了。但是,這個Thread也阻塞這裡了,因為loop()是個for (;;) ... } }.start();
3.後面就建立了Handler物件,所以如果是常規情況,那麼在Handler中執行的應該是主執行緒的方法。
看完了建構函式,現在我們就來看一下Toast的show()
方法
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 } } static private INotificationManager getService() { if (sService != null) { return sService; } sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification")); return sService; }
果然和上面分析的一樣,這裡首先利用Binder獲取了NotificationManagerService
的代理,然後呼叫了它的enqueueToast()
方法,注意這裡將剛才建立的TN
物件傳了過去,果然是利用了Binder,雙向通訊。
private final IBinder mService = new INotificationManager.Stub() { // Toasts // ============================================================================ @Override public void enqueueToast(String pkg, ITransientNotification callback, int duration) { if (DBG) { Slog.i(TAG, "enqueueToast pkg=" + pkg + " callback=" + callback + " duration=" + duration); } if (pkg == null || callback == null) { Slog.e(TAG, "Not doing toast. pkg=" + pkg + " callback=" + callback); return ; } final boolean isSystemToast = isCallerSystemOrPhone() || ("android".equals(pkg)); final boolean isPackageSuspended = isPackageSuspendedForUser(pkg, Binder.getCallingUid()); if (ENABLE_BLOCKED_TOASTS && !isSystemToast && (!areNotificationsEnabledForPackage(pkg, Binder.getCallingUid()) || isPackageSuspended)) { Slog.e(TAG, "Suppressing toast from package " + pkg + (isPackageSuspended ? " due to package suspended by administrator." : " by user request.")); return; } synchronized (mToastQueue) { int callingPid = Binder.getCallingPid(); long callingId = Binder.clearCallingIdentity(); try { ToastRecord record; int index; // All packages aside from the android package can enqueue one toast at a time //是否是系統應用 if (!isSystemToast) { index = indexOfToastPackageLocked(pkg); } else { index = indexOfToastLocked(pkg, callback); } // If the package already has a toast, we update its toast // in the queue, we don't move it to the end of the queue. if (index >= 0) { //如果當前佇列裡已經有Toast,直接更新 record = mToastQueue.get(index); record.update(duration); try { record.callback.hide(); } catch (RemoteException e) { } record.update(callback); } else { //沒有,則建立新的ToastRecord Binder token = new Binder(); //生成一個Toast視窗,並且傳遞token等引數 mWindowManagerInternal.addWindowToken(token, TYPE_TOAST, DEFAULT_DISPLAY); //生產一個ToastRecord record = new ToastRecord(callingPid, pkg, callback, duration, token); //將Toast加入佇列 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) { //如果當前Toast為隊頭,則顯示Toast showNextToastLocked(); } } finally { Binder.restoreCallingIdentity(callingId); } } }
可以看到,果然利用了Binder,這裡首先用isSystemToast
判斷了是否是系統應用
final boolean isSystemToast = isCallerSystemOrPhone() || ("android".equals(pkg));
可以看到這裡,兩個判斷條件一個是通過程序Id判斷是否是系統程序,一個是通過包名判斷是否"android"
,所以後面會的部落格會介紹一種通過偽造包名的方式,以系統Toast的方式彈Toast。
後面在定位Toast在佇列中的位置的時候,如果佇列中已經存在Toast的話,走的就是更新流程,而如果是一個新的Toast,則會首先建立一個Binder物件
,然後生成一個ToastRecord
物件,並加入佇列,這裡注意建立的Token
物件會被儲存在ToastRecord
物件中。
接下來這個函式很重要:
void keepProcessAliveIfNeededLocked(int pid) { int toastCount = 0; // toasts from this pid ArrayList<ToastRecord> list = mToastQueue; int N = list.size(); for (int i=0; i<N; i++) { ToastRecord r = list.get(i); if (r.pid == pid) { toastCount++; } } try { mAm.setProcessImportant(mForegroundToken, pid, toastCount > 0, "toast"); } catch (RemoteException e) { // Shouldn't happen. } }
這裡將當前彈Toast的程序設定為了前臺程序,熟悉Toast的應該都知道,Toast的特殊性在於它支援跨頁面顯示,甚至當應用關閉的時候,Toast仍然能夠展示,就是這個函式發揮的作用,這裡利用AMS,還是通過Binder,呼叫了setProcessImportant
,將Toast所在的程序設定為了前臺程序,保證了程序的存活,所以當頁面銷燬了,Toast還是可以正常顯示。
if (index == 0) { //如果當前Toast為隊頭,則顯示Toast showNextToastLocked(); } void showNextToastLocked() { //取出佇列頭的Toast ToastRecord record = mToastQueue.get(0); //居然是個迴圈 while (record != null) { if (DBG) Slog.d(TAG, "Show pkg=" + record.pkg + " callback=" + record.callback); try { //呼叫callback的show方法,傳入剛才建立的Token物件 record.callback.show(record.token); //延時移除Toast scheduleDurationReachedLocked(record); return; } catch (RemoteException e) { Slog.w(TAG, "Object died trying to show notification " + record.callback + " in package " + record.pkg); // remove it from the list and let the process die int index = mToastQueue.indexOf(record); if (index >= 0) { //移除Toast mToastQueue.remove(index); } //喚醒程序 keepProcessAliveIfNeededLocked(record.pid); if (mToastQueue.size() > 0) { //再次獲取 record = mToastQueue.get(0); } else { record = null; } } } }
最後如果是Toast為佇列頭,那麼此時就會執行showNextToastLocked()
方法,可以看到這裡首先嚐試獲取佇列頭的Toast,後面居然是一個while
迴圈,這塊我感覺Google有點過度嚴謹了,可以看到如果沒有取到ToastRecord
,這裡就移除後,再次執行喚醒程序,然後再次嘗試獲取,直到獲取到,但是這樣為了一個Toast的展示,甚至可能導致這個迴圈一直再執行,感覺有些不值當了,這只是我個人的看法,歡迎大家討論。
當取到ToastRecord
後,會執行其callback
的show
方法,當看到這個方法名的時候,感覺很熟悉,那麼這個callback
是什麼物件呢,看一下ToastRecord
的構造的地方。
public void enqueueToast(String pkg, ITransientNotification callback, int duration) { record = new ToastRecord(callingPid, pkg, callback, duration, token); } }
還是剛才那個函式,可看到,callback就是入參的物件,那麼再看一下Toast
的show()
方法
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 } }
這樣整個流程就通了,這個callback
就是最初的TN
物件,還是利用Binder的雙向通訊,所以這裡就會回到TN
物件的show()
方法,這裡要注意,再呼叫show
方法的時候,會把剛才建立的Token
物件,傳入。
@Override public void show(IBinder windowToken) { if (localLOGV) Log.v(TAG, "SHOW: " + this); mHandler.obtainMessage(SHOW, windowToken).sendToTarget(); }
這裡有回到了最早分析的Handler物件,這個Handler物件常規使用的話是在主執行緒建立的。
mHandler = new Handler(looper, null) { @Override public void handleMessage(Message msg) { switch (msg.what) { case SHOW: { IBinder token = (IBinder) msg.obj; handleShow(token); break; } case HIDE: { handleHide(); // Don't do this in handleHide() because it is also invoked by // handleShow() mNextView = null; break; } case CANCEL: { handleHide(); // Don't do this in handleHide() because it is also invoked by // handleShow() mNextView = null; try { getService().cancelToast(mPackageName, TN.this); } catch (RemoteException e) { } break; } } } };
可以看到又呼叫了handleShow
方法。
public void handleShow(IBinder windowToken) { if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView + " mNextView=" + mNextView); // If a cancel/hide is pending - no need to show - at this point // the window token is already invalid and no need to do any work. if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) { return; } if (mView != mNextView) { // remove the old view if necessary handleHide(); mView = mNextView; Context context = mView.getContext().getApplicationContext(); String packageName = mView.getContext().getOpPackageName(); if (context == null) { context = mView.getContext(); } mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE); // We can resolve the Gravity here by using the Locale for getting // the layout direction final Configuration config = mView.getContext().getResources().getConfiguration(); final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection()); mParams.gravity = gravity; if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) { mParams.horizontalWeight = 1.0f; } if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) { mParams.verticalWeight = 1.0f; } 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); } if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this); // 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 { //利用WindowManager將View加入 mWM.addView(mView, mParams); trySendAccessibilityEvent(); } catch (WindowManager.BadTokenException e) { /* ignore */ } } }
這裡的程式碼就比較簡單了,將基礎的屬性設定到了LayoutParams,這裡比較重要的是將token設定到了LayoutParams
中(關於這個屬性後面可能會有一篇部落格專門講解一下這個屬性值和許可權的關係,本篇部落格主要分析Toast的展示原理,就不拓展分析了),並且利用WindowManager
的addView
的上,這樣最終Toast
就顯示出來了。
剩下了就是怎麼移除這個Toast了,回到NMS,再show
後,使用scheduleDurationReachedLocked(record);
方法,就是移除操作。
private void scheduleDurationReachedLocked(ToastRecord r) { mHandler.removeCallbacksAndMessages(r); Message m = Message.obtain(mHandler, MESSAGE_DURATION_REACHED, r); //顯示耗時只有兩種 long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY; //通過Handler傳送訊息執行 mHandler.sendMessageDelayed(m, delay); }
這裡第一個注意的點,可以看到,這裡delay
變數只有兩種可能,LONG_DELAY
和SHORT_DELAY
。這也就解釋了為什麼我們平時使用Toast
元件,不支援自定義顯示時長,只能有LONG
和SHORT
兩種時長。
然後通過Handler傳送一個延時訊息,用於隱藏Toast元件。
@Override public void handleMessage(Message msg) { switch (msg.what) { case MESSAGE_DURATION_REACHED: handleDurationReached((ToastRecord)msg.obj); break; ... } } private void handleDurationReached(ToastRecord record) { if (DBG) Slog.d(TAG, "Timeout pkg=" + record.pkg + " callback=" + record.callback); synchronized (mToastQueue) { //定位訊息位置 int index = indexOfToastLocked(record.pkg, record.callback); if (index >= 0) { //取消訊息 cancelToastLocked(index); } } }
這裡的邏輯很簡單,就是利用Handler的訊息機制,取出顯示的訊息的位置,然後進行取消操作。
@GuardedBy("mToastQueue") void cancelToastLocked(int index) { //取出訊息 ToastRecord record = mToastQueue.get(index); try { //執行隱藏邏輯 record.callback.hide(); } catch (RemoteException e) { Slog.w(TAG, "Object died trying to hide notification " + record.callback + " in package " + record.pkg); // don't worry about this, we're about to remove it from // the list anyway } //移除操作 ToastRecord lastToast = mToastQueue.remove(index); mWindowManagerInternal.removeWindowToken(lastToast.token, false /* removeWindows */, DEFAULT_DISPLAY); // We passed 'false' for 'removeWindows' so that the client has time to stop // rendering (as hide above is a one-way message), otherwise we could crash // a client which was actively using a surface made from the token. However // we need to schedule a timeout to make sure the token is eventually killed // one way or another. scheduleKillTokenTimeout(lastToast.token); keepProcessAliveIfNeededLocked(record.pid); if (mToastQueue.size() > 0) { // Show the next one. If the callback fails, this will remove // it from the list, so don't assume that the list hasn't changed // after this point. //顯示下一個 showNextToastLocked(); } }
知道了show
的邏輯後,這個的原理就很相似了,這裡首先取出ToastRecord
變數,其實我感覺這裡Google可以優化一下,剛才先是定位,然後這裡又取出,相當於兩次遍歷,其實可以合併為一次遍歷就可以。
-
然後利用Binder執行
hide
方法。 - 將給Toast 生成的視窗Token從WMS 服務中刪除
-
判斷是否還有訊息,如果存在,則繼續顯示Toast
這裡再看一下hide
方法。同樣也是利用Handler,最終執行handleHide()
方法。
public void handleHide() { if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView); if (mView != null) { // note: checking parent() just to make sure the view has // been added...i have seen cases where we get here when // the view isn't yet added, so let's try not to crash. if (mView.getParent() != null) { if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this); mWM.removeViewImmediate(mView); } // Now that we've removed the view it's safe for the server to release // the resources. try { getService().finishToken(mPackageName, this); } catch (RemoteException e) { } mView = null; } }
這裡還是利用WMS將View移除,這裡有個地方挺有意思,這裡先判斷了一下view的parent
不為null,這裡的註釋寫的很口語化,Google的工程師也挺有意思。
// note: checking parent() just to make sure the view has // been added...i have seen cases where we get here when // the view isn't yet added, so let's try not to crash.
至此,整個流程就分析完畢了。
總結
這裡來回顧總結一下Toast
的展示原理
-
首先通過構建Toast物件,內部建立了
TN
物件,這個物件是一個Binder物件。 -
show
方法的實質是呼叫NMS的代理,執行enqueueToast
方法,並且傳入TN
物件用於雙向通訊。 -
NMS中,將Toast的顯示構建成了一個
ToastRecord
物件,並且有一個佇列用於儲存。 -
NMS將
ToastRecord
加入佇列後,最終利用TN
物件,執行show
方法 -
TN物件的
show
方法,最後是利用Handler
傳送訊息,最後執行新增,就是利用WindowManager將Toast的View加入Window。 -
NMS中執行完後,內部也會利用Handler傳送延時訊息,只有兩種
LONG
和SHORT
,訊息收到後,同樣也是通過TN
物件,執行hide
方法 - 同樣的流程,TN利用Handler傳送訊息,最終執行,同樣利用WindowManager,移除View。
- NMS執行完移除操作後,會判斷佇列中是否還有訊息,如果有繼續執行展示Toast的邏輯。
本篇部落格主要是針對Toast
元件的展示原理進行講解,後面有時間會繼續分析Toast相關的問題,和Window相關的問題。