歡迎你們前往雲+社區,獲取更多騰訊海量技術實踐乾貨哦~java
做者: QQ音樂技術團隊 android
Toast
做爲 Android
系統中最經常使用的類之一,因爲其方便的api設計和簡潔的交互體驗,被咱們所普遍採用。可是,伴隨着咱們開發的深刻,Toast
的問題也逐漸暴露出來。 本系列文章將分紅兩篇: 第一篇,咱們將分析 Toast
所帶來的問題 第二篇,將提供解決 Toast
問題的解決方案 (注:本文源碼基於Android 7.0)api
上一篇 [[Android] Toast問題深度剖析(一)] 筆者解釋了:安全
Toast
系統如何構建窗口(經過系統服務NotificationManager來生成系統窗口)Toast
異常出現的緣由(系統調用 Toast
的時序紊亂)而本篇的重點,在於解決咱們第一章所說的 Toast
問題。bash
基於第一篇的知識,咱們知道,Toast
的窗口屬於系統窗口,它的生成和生命週期依賴於系統服務 NotificationManager
。一旦 NotificationManager
所管理的窗口生命週期跟咱們本地的進程不一致,就會發生異常。那麼,咱們能不能不使用系統的窗口,而使用本身的窗口,而且由咱們本身控制生命週期呢?事實上, SnackBar
就是這樣的方案。不過,若是不使用系統類型的窗口,就意味着你的Toast
界面,沒法在其餘應用之上顯示。(好比,咱們常常看到的一個場景就是你在你的應用出調用了屢次 Toast.show
函數,而後退回到桌面,結果發現桌面也會彈出 Toast
,就是由於系統的 Toast
使用了系統窗口,具備高的層級)不過在某些版本的手機上,你的應用能夠申請權限,往系統中添加 TYPE_SYSTEM_ALERT
窗口,這也是一種系統窗口,常常用來做爲浮層顯示在全部應用程序之上。不過,這種方式須要申請權限,並不能作到讓全部版本的系統都能正常使用。 若是咱們從體驗的角度來看,當用戶離開了該進程,就不該該彈出另一個進程的 Toast
提示去幹擾用戶的。Android
系統彷佛也意識到了這一點,在新版本的系統更新中,限制了不少在桌面提示窗口相關的權限。因此,從體驗上考慮,這個狀況並不屬於問題。ide
「那麼咱們能夠選擇哪些窗口的類型呢?」函數
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
的父控件節點。post
「我什麼時機往這個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
從新佈局LayoutView
節點當調用 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是如何作的呢:
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
。
從第一篇文章咱們知道,咱們直接在 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
的問題而崩潰。
此文已由做者受權雲加社區發佈,轉載請註明文章出處