1. 程式人生 > >[Android] Toast問題深度剖析(二)

[Android] Toast問題深度剖析(二)

實踐 ostc 將在 離開 ica 代碼 etime edr 不同

歡迎大家前往雲+社區,獲取更多騰訊海量技術實踐幹貨哦~

作者: QQ音樂技術團隊

題記

Toast 作為 Android 系統中最常用的類之一,由於其方便的api設計和簡潔的交互體驗,被我們所廣泛采用。但是,伴隨著我們開發的深入,Toast 的問題也逐漸暴露出來。 本系列文章將分成兩篇: 第一篇,我們將分析 Toast 所帶來的問題 第二篇,將提供解決 Toast 問題的解決方案 (註:本文源碼基於Android 7.0)

1.回顧

上一篇 [[Android] Toast問題深度剖析(一)] 筆者解釋了:

  1. Toast 系統如何構建窗口(通過系統服務NotificationManager來生成系統窗口)
  2. Toast 異常出現的原因(系統調用 Toast的時序紊亂)

而本篇的重點,在於解決我們第一章所說的 Toast 問題。

2.解決思路

基於第一篇的知識,我們知道,Toast 的窗口屬於系統窗口,它的生成和生命周期依賴於系統服務 NotificationManager。一旦 NotificationManager 所管理的窗口生命周期跟我們本地的進程不一致,就會發生異常。那麽,我們能不能不使用系統的窗口,而使用自己的窗口,並且由我們自己控制生命周期呢?事實上, SnackBar 就是這樣的方案。不過,如果不使用系統類型的窗口,就意味著你的Toast 界面,無法在其他應用之上顯示。(比如,我們經常看到的一個場景就是你在你的應用出調用了多次 Toast.show

函數,然後退回到桌面,結果發現桌面也會彈出 Toast,就是因為系統的 Toast 使用了系統窗口,具有高的層級)不過在某些版本的手機上,你的應用可以申請權限,往系統中添加 TYPE_SYSTEM_ALERT 窗口,這也是一種系統窗口,經常用來作為浮層顯示在所有應用程序之上。不過,這種方式需要申請權限,並不能做到讓所有版本的系統都能正常使用。 如果我們從體驗的角度來看,當用戶離開了該進程,就不應該彈出另外一個進程的 Toast 提示去幹擾用戶的。Android 系統似乎也意識到了這一點,在新版本的系統更新中,限制了很多在桌面提示窗口相關的權限。所以,從體驗上考慮,這個情況並不屬於問題。

“那麽我們可以選擇哪些窗口的類型呢?”

  1. 使用子窗口: 在 Android 進程內,我們可以直接使用類型為子窗口類型的窗口。在 Android 代碼中的直接應用是 PopupWindow 或者是 Dialog 。這當然可以,不過這種窗口依賴於它的宿主窗口,它可用的條件是你的宿主窗口可用
  2. 采用 View 系統: 使用 View 系統去模擬一個 Toast 窗口行為,做起來不僅方便,而且能更加快速的實現動畫效果,我們的 SnackBar 就是采用這套方案。這也是我們今天重點講的方案

“如果采用 View 系統方案,那麽我要往哪個控件中添加我的 Toast 控件呢?”

Android進程中,我們所有的可視操作都依賴於一個 ActivityActivity 提供上下文(Context)和視圖窗口(Window) 對象。我們通過 Activity.setContentView 方法所傳遞的任何 View對象 都將被視圖窗口( Window) 中的 DecorView 所裝飾。而在 DecorView 的子節點中,有一個 idandroid.R.id.contentFrameLayout 節點(後面簡稱 content 節點) 是用來容納我們所傳遞進去的 View 對象。一般情況下,這個節點占據了除了通知欄的所有區域。這就特別適合用來作為 Toast 的父控件節點。

“我什麽時機往這個content節點中添加合適呢?這個 content 節點什麽時候被初始化呢?”

根據不同的需求,你可能會關註以下兩個時機:

  1. Content 節點生成
  2. Content 內容顯示

實際我們只需要將我們的 Toast 添加到 Content 節點中,只要滿足第一條即可。如果你是為了完成性能檢測,測量或者其他目的,那麽你可能更關心第二條。 那麽什麽情況下 Content 節點生成呢?剛才我們說了,Content 節點包含在我們的 DecorView 控件中,而 DecorView 是由 ActivityWindow對象所持有的控件。WindowAndroid 中的實現類是 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 消息的: (註: AcitivityThreadAndroid 進程的入口類, 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 消息的時候,將走以下的流程:

  1. 調用 performResumeActivity 回調 ActivityonResume 函數
  2. 調用 WindowgetDecorView 生成 DecorView 對象和 content 節點
  3. DecorView納入 WindowManager (進程內服務)的管理
  4. 調用 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。因此我們在onResumepost一個消息就可以檢測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 信號控制的,這種信號可能來自於硬件也可能來自於軟件模擬。每一次非動畫的繪制,都包含:測量,布局,繪制三個函數。而一般觸發這一事件的的動作有:

  1. View 的某些屬性的變更
  2. View 重新布局Layout
  3. 增刪 View 節點

當調用 WindowManager.addView 將空間添加到 WM 服務管理的時候,會調用一次Layout請求,這就觸發了一次 VSYNC 繪制。因此,我們只需要在 onResumepost 一個幀回調就可以檢測繪制開始的時間:

技術分享圖片

 @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(00, host.getMeasuredWidth(), host.getMeasuredHeight());//布局
    ...
    draw(fullRedrawNeeded);//繪制
    ...
}

performTraversals 函數實現了以下流程:

  1. 調用 dispatchAttachedToWindow 通知子控件樹當前控件被 attach 到窗口中
  2. 執行一個命令隊列 getRunQueue
  3. 執行 meausre 測量指令
  4. 執行 layout 布局函數
  5. 執行繪制 draw

這裏我們看到一句方法調用:

getRunQueue().executeActions(attachInfo.mHandler);

這個函數將執行一個延時的命令隊列,在 View 對象被 attachView樹之前,通過調用 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 系統主要依賴於兩個類:

  1. SnackBar 作為門面,與業務程序交互
  2. SnackBarManager 作為時序管理器, SnackBarSnackBarManager 的交互,通過 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 控件中去。

這裏有人或許會有疑問,這裏使用強引用,會不會造成一段時間內的內存泄漏呢? 假如你現在彈了 10Toast ,每個 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 對象發送一條消息,並在 mHandlerhandleMessage 函數中執行。 當NotificationManager 通知 TN 對象隱藏的時候,將通過 mHandler.post(mHide) 方法,發送隱藏指令。不論采用哪種方式發送的指令,都將執行 HandlerdispatchMessage(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 的問題而崩潰。

相關閱讀

[Android] Toast問題深度剖析(一)

Android基礎:Fragment,看這篇就夠了

Android圖像處理 - 高斯模糊的原理及實現


此文已由作者授權雲加社區發布,轉載請註明文章出處

[Android] Toast問題深度剖析(二)