[Android] Toast問題深度剖析(二)
題記
Toast
作為 Android
系統中最常用的類之一,由於其方便的api設計和簡潔的互動體驗,被我們所廣泛採用。但是,伴隨著我們開發的深入,Toast
的問題也逐漸暴露出來。
本系列文章將分成兩篇:
第一篇,我們將分析 Toast
所帶來的問題
第二篇,將提供解決 Toast
問題的解決方案
(注:本文原始碼基於Android 7.0)
1.回顧
上一篇 [[Android] Toast問題深度剖析(一)] 筆者解釋了:
Toast
系統如何構建視窗(透過系統服務NotificationManager來生成系統視窗)Toast
異常出現的原因(系統呼叫Toast
的時序紊亂)
而本篇的重點,在於解決我們第一章所說的 Toast
問題。
2.解決思路
基於第一篇的知識,我們知道,Toast
的視窗屬於系統視窗,它的生成和生命週期依賴於系統服務 NotificationManager
。一旦 NotificationManager
所管理的視窗生命週期跟我們本地的程式不一致,就會發生異常。那麼,我們能不能不使用系統的視窗,而使用自己的視窗,並且由我們自己控制生命週期呢?事實上, SnackBar
就是這樣的方案。不過,如果不使用系統型別的視窗,就意味著你的Toast
介面,無法在其他應用之上顯示。(比如,我們經常看到的一個場景就是你在你的應用出呼叫了多次 Toast.show
函式,然後退回到桌面,結果發現桌面也會彈出 Toast
,就是因為系統的 Toast
使用了系統視窗,具有高的層級)不過在某些版本的手機上,你的應用可以申請許可權,往系統中新增 TYPE_SYSTEM_ALERT
視窗,這也是一種系統視窗,經常用來作為浮層顯示在所有應用程式之上。不過,這種方式需要申請許可權,並不能做到讓所有版本的系統都能正常使用。
如果我們從體驗的角度來看,當使用者離開了該程式,就不應該彈出另外一個程式的 Toast
提示去干擾使用者的。Android
系統似乎也意識到了這一點,在新版本的系統更新中,限制了很多在桌面提示視窗相關的許可權。所以,從體驗上考慮,這個情況並不屬於問題。
“那麼我們可以選擇哪些視窗的型別呢?”
使用子視窗: 在
Android
程式內,我們可以直接使用型別為子視窗型別的視窗。在Android
程式碼中的直接應用是PopupWindow
或者是Dialog
。這當然可以,不過這種視窗依賴於它的宿主視窗,它可用的條件是你的宿主視窗可用採用
View
系統: 使用View
系統去模擬一個Toast
視窗行為,做起來不僅方便,而且能更加快速的實現動畫效果,我們的SnackBar
就是採用這套方案。這也是我們今天重點講的方案
“如果採用 View 系統方案,那麼我要往哪個控制元件中新增我的 Toast 控制元件呢?”
在Android
程式中,我們所有的可視操作都依賴於一個 Activity
。 Activity
提供上下文(Context)和檢視視窗(Window) 物件。我們透過 Activity.setContentView
方法所傳遞的任何 View
物件 都將被檢視視窗( Window
) 中的 DecorView
所裝飾。而在 DecorView
的子節點中,有一個 id
為 android.R.id.content
的 FrameLayout
節點(後面簡稱 content
節點) 是用來容納我們所傳遞進去的 View
物件。一般情況下,這個節點佔據了除了通知欄的所有區域。這就特別適合用來作為 Toast
的父控制元件節點。
“我什麼時機往這個content
節點中新增合適呢?這個 content
節點什麼時候被初始化呢?”
根據不同的需求,你可能會關注以下兩個時機:
Content
節點生成Content
內容顯示
實際我們只需要將我們的 Toast
新增到 Content
節點中,只要滿足第一條即可。如果你是為了完成效能檢測,測量或者其他目的,那麼你可能更關心第二條。 那麼什麼情況下 Content
節點生成呢?剛才我們說了,Content
節點包含在我們的 DecorView
控制元件中,而 DecorView
是由 Activity
的 Window
物件所持有的控制元件。Window
在 Android
中的實現類是 PhoneWindow
,(這部分程式碼有興趣可以自行閱讀) 我們來看下原始碼:
//code PhoneWindow.java @Override public void setContentView(int layoutResID) { if (mContentParent == null) { //mContentParent就是我們的 content 節點 installDecor();//生成一個DecorView } else { mContentParent.removeAllViews(); } mLayoutInflater.inflate(layoutResID, mContentParent); final Callback cb = getCallback(); if (cb != null && !isDestroyed()) { cb.onContentChanged(); } }
PhoneWindow
物件透過 installDecor
函式生成 DecorView
和 我們所需要的 content
節點(最終會存到 mContentParent
) 變數中去。但是, setContentView
函式需要我們主動呼叫,如果我並沒有呼叫這個 setContentView
函式,installDecor
方法將不被呼叫。那麼,有沒有某個時刻,content
節點是必然生成的呢?當然有,除了在 setContentView
函式中呼叫installDecor
外,還有一個函式也呼叫到了這個,那就是:
//code PhoneWindow.java @Override public final View getDecorView() { if (mDecor == null) { installDecor(); } return mDecor; }
而這個函式,將在 Activity.findViewById
的時候呼叫:
//code Activity.java public View findViewById(@IdRes int id) { return getWindow().findViewById(id); } //code Window.java public View findViewById(@IdRes int id) { return getDecorView().findViewById(id); }
因此,只要我們只要呼叫了 findViewById
函式,一樣可以保證 content
被正常初始化。這樣我們解釋了第一個”就緒”(Content
節點生成)。我們再來看下第二個”就緒”,也就是 Android
介面什麼時候顯示呢?相信你可能迫不及待的回答不是 onResume
回撥的時候麼?實際上,在 onResume
的時候,根本還沒處理跟介面相關的事情。我們來看下 Android
程式是如何處理 resume
訊息的:
(注: AcitivityThread
是 Android
程式的入口類, Android
程式處理 resume
相關訊息將會呼叫到 AcitivityThread.handleResumeActivity
函式)
//code AcitivityThread.java void handleResumeActivity(...) { ... ActivityClientRecord r = performResumeActivity(token, clearHide); // 之後會呼叫call onResume ... View decor = r.window.getDecorView(); //呼叫getDecorView 生成 content節點 decor.setVisibility(View.INVISIBLE); .... if (r.activity.mVisibleFromClient) { r.activity.makeVisible();//add to WM 管理 } ... } //code Activity.java void makeVisible() { if (!mWindowAdded) { ViewManager wm = getWindowManager(); wm.addView(mDecor, getWindow().getAttributes()); mWindowAdded = true; } mDecor.setVisibility(View.VISIBLE); }
Android
程式在處理 resume
訊息的時候,將走以下的流程:
呼叫
performResumeActivity
回撥Activity
的onResume
函式呼叫
Window
的getDecorView
生成DecorView
物件和content
節點將
DecorView
納入WindowManager
(程式內服務)的管理呼叫
Activity.makeVisible
顯示當前Activity
按照上述的流程,在 Activity.onResume
回撥之後,才將控制元件納入本地服務 WindowManager
的管理中。也就是說, Activity.onResume
根本沒有顯示任何東西。我們不妨寫個程式碼驗證一下:
//code DemoActivity.java public DemoActivity extends Activity { private View view ; @Override protected void onCreate( Bundle savedInstanceState) { super.onCreate(savedInstanceState); view = new View(this); this.setContentView(view); } @Override protected void onResume() { super.onResume(); Log.d("cdw","onResume :" +view.getHeight());// 有高度是顯示的必要條件 } }
這裡,我們透過在 onResume
中獲取高度的方式驗證介面是否被繪製,最終我們將輸出日誌:
D cdw : onResume :0
那麼,介面又是在什麼時候完成的繪製呢?是不是在 WindowManager.addView
之後呢?我們在 onResume
之後會呼叫Activity.makeVisible
,裡面會呼叫 WindowManager.addView
。因此我們在onResume
裡post
一個訊息就可以檢測WindowManager.addView
之後的情況:
@Override protected void onResume() { super.onResume(); this.runOnUiThread(new Runnable() { @Override public void run() { Log.d("cdw","onResume :" +view.getHeight()); } }); } //控制檯輸出: 01-02 21:30:27.445 2562 2562 D cdw : onResume :0
從結果上看,我們在 WindowManager.addView
之後,也並沒有繪製介面。那麼,Android的繪製是什麼時候開始的?又是到什麼時候結束?
在 Android
系統中,每一次的繪製都是透過一個 16ms
左右的 VSYNC
訊號控制的,這種訊號可能來自於硬體也可能來自於軟體模擬。每一次非動畫的繪製,都包含:測量,佈局,繪製三個函式。而一般觸發這一事件的的動作有:
View
的某些屬性的變更View
重新佈局Layout增刪
View
節點
當呼叫 WindowManager.addView
將空間新增到 WM
服務管理的時候,會呼叫一次Layout請求,這就觸發了一次 VSYNC
繪製。因此,我們只需要在 onResume
裡 post
一個幀回撥就可以檢測繪製開始的時間:
@Override protected void onResume() { super.onResume(); Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { //TODO 繪製開始 } }); }
我們先來看下 View.requestLayout
是怎麼觸發介面重新繪製的:
//code View.java public void requestLayout() { .... if (mParent != null) { ... if (!mParent.isLayoutRequested()) { mParent.requestLayout(); } } }
View
物件呼叫 requestLayout
的時候會委託給自己的父節點處理,這裡之所以不稱為父控制元件而是父節點,是因為除了控制元件外,還有 ViewRootImpl
這個非控制元件型別作為父節點,而這個父節點會作為整個控制元件樹的根節點。按照我們上面說的委託的機制,requestLayout
最終將會呼叫到 ViewRootImpl.requestLayout
。
//code ViewRootImpl.java @Override public void requestLayout() { if (!mHandlingLayoutInLayoutRequest) { checkThread(); mLayoutRequested = true; scheduleTraversals();//申請繪製請求 } } void scheduleTraversals() { if (!mTraversalScheduled) { mTraversalScheduled = true; .... mChoreographer.postCallback( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);//申請繪製 .... } }
ViewRootImpl
最終會將 mTraversalRunnable
處理命令放到 CALLBACK_TRAVERSAL
繪製佇列中去:
final class TraversalRunnable implements Runnable { @Override public void run() { doTraversal();//執行佈局和繪製 } } void doTraversal() { if (mTraversalScheduled) { mTraversalScheduled = false; ... performTraversals(); ... } }
mTraversalRunnable
命令最終會呼叫到 performTraversals()
函式:
private void performTraversals() { final View host = mView; ... host.dispatchAttachedToWindow(mAttachInfo, 0);//attachWindow ... getRunQueue().executeActions(attachInfo.mHandler);//執行某個指令 ... childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height); host.measure(childWidthMeasureSpec, childHeightMeasureSpec);//測量 .... host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());//佈局 ... draw(fullRedrawNeeded);//繪製 ... }
performTraversals
函式實現了以下流程:
呼叫
dispatchAttachedToWindow
通知子控制元件樹當前控制元件被attach
到視窗中執行一個命令佇列
getRunQueue
執行
meausre
測量指令執行
layout
佈局函式執行繪製
draw
這裡我們看到一句方法呼叫:
getRunQueue().executeActions(attachInfo.mHandler);
這個函式將執行一個延時的命令佇列,在 View
物件被 attach
到 View
樹之前,透過呼叫 View.post
函式,可以將執行訊息命令加入到延時執行佇列中去:
//code View.java public boolean post(Runnable action) { Handler handler; AttachInfo attachInfo = mAttachInfo; if (attachInfo != null) { handler = attachInfo.mHandler; } else { // Assume that post will succeed later ViewRootImpl.getRunQueue().post(action); return true; } return handler.post(action); }
getRunQueue().executeActions
函式執行的時候,會將該命令訊息延後一個UI執行緒訊息執行,這就保證了執行的這個命令訊息發生在我們的繪製之後:
//code RunQueue.java void executeActions(Handler handler) { synchronized (mActions) { ... for (int i = 0; i < count; i++) { final HandlerAction handlerAction = actions.get(i); handler.postDelayed(handlerAction.action, handlerAction.delay);//推遲一個訊息 } } }
所以,我們只需要在檢視被 attach
之前透過一個 View
來丟擲一個命令訊息,就可以檢測檢視繪製結束的時間點:
//code DemoActivity.java @Override protected void onResume() { super.onResume(); Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { start = SystemClock.uptimeMillis(); log("繪製開始:height = "+view.getHeight()); } }); } @Override protected void onCreate( Bundle savedInstanceState) { super.onCreate(savedInstanceState); view = new View(this); view.post(new Runnable() { @Override public void run() { log("繪製耗時:"+(SystemClock.uptimeMillis()-start)+"ms"); log("繪製結束後:height = "+view.getHeight()); } }); this.setContentView(view); } //控制檯輸出: 01-03 23:39:27.251 27069 27069 D cdw : --->繪製開始:height = 0 01-03 23:39:27.295 27069 27069 D cdw : --->繪製耗時:44ms 01-03 23:39:27.295 27069 27069 D cdw : --->繪製結束後:height = 1232
我們帶著我們上面的知識儲備,來看下SnackBar是如何做的呢:
3.Snackbar
SnackBar
系統主要依賴於兩個類:
SnackBar
作為門面,與業務程式互動SnackBarManager
作為時序管理器,SnackBar
與SnackBarManager
的互動,透過Callback
回撥物件進行
SnackBarManager
的時序管理跟 NotifycationManager
的很類似不再贅述
SnackBar
透過靜態方法 make
靜態構造一個 SnackBar
:
public static Snackbar make(@NonNull View view, @NonNull CharSequence text, @Duration int duration) { Snackbar snackbar = new Snackbar(findSuitableParent(view)); snackbar.setText(text); snackbar.setDuration(duration); return snackbar; }
這裡有一個關鍵函式 findSuitableParent
,這個函式的目的就相當於我們上面的 findViewById(R.id.content)
一樣,給 SnackBar
所定義的 Toast
控制元件找一個合適的容器:
private static ViewGroup findSuitableParent(View view) { ViewGroup fallback = null; do { if (view instanceof CoordinatorLayout) { return (ViewGroup) view; } else if (view instanceof FrameLayout) { if (view.getId() == android.R.id.content) {//把 `Content` 節點作為容器 ... return (ViewGroup) view; } else { // It's not the content view but we'll use it as our fallback fallback = (ViewGroup) view; } } ... } while (view != null); // If we reach here then we didn't find a CoL or a suitable content view so we'll fallback return fallback; }
我們發現,除了包含 CoordinatorLayout
控制元件的情況, 預設情況下, SnackBar
也是找的 Content
節點。找到的這個父節點,作為 Snackbar
構造器的形參:
private Snackbar(ViewGroup parent) { mTargetParent = parent; mContext = parent.getContext(); ... LayoutInflater inflater = LayoutInflater.from(mContext); mView = (SnackbarLayout) inflater.inflate( R.layout.design_layout_snackbar, mTargetParent, false); ... }
Snackbar
將生成一個 SnackbarLayout
控制元件作為 Toast
控制元件。最後當時序控制器 SnackBarManager
回撥返回的時候,通知 SnackBar
顯示,即將 SnackBar.mView
增加到 mTargetParent
控制元件中去。
這裡有人或許會有疑問,這裡使用強引用,會不會造成一段時間內的記憶體洩漏呢?
假如你現在彈了 10
個 Toast
,每個 Toast
的顯示時間是 2s
。也就是說你的最後一個 SnackBar
將被 SnackBarManager
持有至少 20s
。而 SnackBar
中又存在有父控制元件 mTargetParent
的強引用。相當於在這20s內, 你的mTargetParent
和它所持有的 Context
(一般是 Activity
)無法釋放
這個其實是不會的,原因在於 SnackBarManager
在管理這種回撥 callback
的時候,採用了弱引用。
private static class SnackbarRecord { final WeakReference<Callback> callback; .... }
但是,我們從 SnackBar
的設計可以看出,SnackBar
無法定製具體的樣式: SnackBar
只能生成 SnackBarLayout
這種控制元件和佈局,可能並不滿足你的業務需求。當然你也可以變更 SnackBarLayout
也能達到目的。不過,有了上面的知識儲備,我們完全可以寫一個自己的 Snackbar
。
4.基於Toast的改法
從第一篇文章我們知道,我們直接在 Toast.show
函式外增加 try-catch
是沒有意義的。因為 Toast.show
實際上只是發了一條命令給 NotificationManager
服務。真正的顯示需要等 NotificationManager
通知我們的 TN
物件 show
的時候才能觸發。NotificationManager
通知給 TN
物件的訊息,都會被 TN.mHandler
這個內部物件進行處理
//code Toast.java private static class TN { final Runnable mHide = new Runnable() {// 透過 mHandler.post(mHide) 執行 @Override public void run() { handleHide(); mNextView = null; } }; final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { IBinder token = (IBinder) msg.obj; handleShow(token);// 處理 show 訊息 } }; }
在NotificationManager
通知給 TN
物件顯示的時候,TN
物件將給 mHandler
物件傳送一條訊息,並在 mHandler
的 handleMessage
函式中執行。 當NotificationManager
通知 TN
物件隱藏的時候,將透過 mHandler.post(mHide)
方法,傳送隱藏指令。不論採用哪種方式傳送的指令,都將執行 Handler
的 dispatchMessage(Message msg)
函式:
//code Handler.java public void dispatchMessage(Message msg) { if (msg.callback != null) { handleCallback(msg);// 執行 post(Runnable)形式的訊息 } else { ... handleMessage(msg);// 執行 sendMessage形式的訊息 } }
因此,我們只需要在 dispatchMessage
方法體內加入 try-catch
就可以避免 Toast
崩潰對應用程式的影響:
public void dispatchMessage(Message msg) { try { super.dispatchMessage(msg); } catch(Exception e) {} }
因此,我們可以定義一個安全的 Handler
裝飾器:
private static class SafelyHandlerWarpper extends Handler { private Handler impl; public SafelyHandlerWarpper(Handler impl) { this.impl = impl; } @Override public void dispatchMessage(Message msg) { try { super.dispatchMessage(msg); } catch (Exception e) {} } @Override public void handleMessage(Message msg) { impl.handleMessage(msg);//需要委託給原Handler執行 } }
由於 TN.mHandler
物件複寫了 handleMessage
方法,因此,在 Handler
裝飾器裡,需要將 handleMessage
方法委託給 TN.mHandler
執行。定義完裝飾器之後,我們就可以透過反射往我們的 Toast
物件中注入了:
public class ToastUtils { private static Field sField_TN ; private static Field sField_TN_Handler ; static { try { sField_TN = Toast.class.getDeclaredField("mTN"); sField_TN.setAccessible(true); sField_TN_Handler = sField_TN.getType().getDeclaredField("mHandler"); sField_TN_Handler.setAccessible(true); } catch (Exception e) {} } private static void hook(Toast toast) { try { Object tn = sField_TN.get(toast); Handler preHandler = (Handler)sField_TN_Handler.get(tn); sField_TN_Handler.set(tn,new SafelyHandlerWarpper(preHandler)); } catch (Exception e) {} } public static void showToast(Context context,CharSequence cs, int length) { Toast toast = Toast.makeText(context,cs,length); hook(toast); toast.show(); } }
我們再用第一章中的程式碼測試一下:
public void showToast(View view) { ToastUtils.showToast(this,"hello", Toast.LENGTH_LONG); try { Thread.sleep(10000); } catch (InterruptedException e) {} }
等 10s 之後,程式正常執行,不會因為 Toast
的問題而崩潰。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31557897/viewspace-2220765/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- [Android] Toast問題深度剖析(一)AndroidAST
- AndroidToast問題深度剖析(一)AndroidAST
- JAVA重難點問題剖析(二)Java
- Toast原始碼深度分析AST原始碼
- Android Toast小解AndroidAST
- 二叉樹的最小深度問題二叉樹
- Android對話方塊Dialog深度剖析Android
- Android 對話方塊 Dialog 深度剖析Android
- 快取與資料庫一致性問題深度剖析快取資料庫
- Android自定義ToastAndroidAST
- Spring IOC原始碼深度剖析:Spring IoC迴圈依賴問題Spring原始碼
- android Toast五種特效AndroidAST特效
- [問題貼] 如何解決獲取到的 toast 的文字為空的問題?AST
- 深度剖析 | 關於資料鎖定和讀取一致性問題
- Android 自定義Toast及BUGAndroidAST
- Android 自定義Toast,修改Toast樣式和顯示時長AndroidAST
- keras框架下的深度學習(二)二分類和多分類問題Keras框架深度學習
- Android Flutter混合開發問題總結(二)AndroidFlutter
- Android JetPack~LiveData(二) 資料倒灌問題AndroidJetpackLiveData
- offsetParent、offsetLeft/offsetTop深度剖析
- 深度學習之電影二分類的情感問題深度學習
- Android 5中不同效果的ToastAndroidAST
- Android App中使用全域性ToastAndroidAPPAST
- 設計模式面試與筆試題剖析(二)設計模式面試筆試
- 深度剖析Reflect + 實戰案例
- spark核心原始碼深度剖析Spark原始碼
- ThreadLocal原始碼深度剖析thread原始碼
- URL Schemes深度剖析(上)Scheme
- 深度剖析WhatsApp傳奇APP
- Hadoop-Drill深度剖析Hadoop
- android問題Android
- Android Toast 預設和自定義使用AndroidAST
- Android UI控制元件系列:Toast(提示)AndroidUI控制元件AST
- Android中自定義Toast文字大小AndroidAST
- uview-ui toast 二次封裝ViewUIAST封裝
- JavaScript 的 this 指向問題深度解析JavaScript
- Android 核心剖析Android
- Android 自定義Toast實現多次觸發只會顯示一次toastAndroidAST