解決Android5.0以下Dialog引起的內存泄漏


  最近項目開發中,開發人員和測試人員均反應在android5.0以下手機上LeakCanary頻繁監控到內存泄漏,如下圖所示,但凡用到Dialog或DialogFragment地方均出現了內存泄漏。

V3GE@Z_X6XB(D4MBU$%D_8L

  如上圖所示,存在一個Message實例的obj成員變量,間接引用着Activity的實例,導致Activity無法正常退出。通過Android Monitors內存快照分析,確實有Message實例持有對LoadingDialogFragment的引用,進而導致Activity也無法正常銷毀,出現內存泄漏(如下圖)。

QQ圖片20170928192056

  參考一個內存泄漏引發的血案一文,了解到問題發生原因:局部變量的生命周期在Dalvik VM跟ART/JVM中有區別。在DVM中,假如線程死循環或者阻塞,那么線程棧幀中的局部變量假如沒有被置為null,那么就不會被回收。 在 VM 中,每一個棧幀都是本地變量的集合,而垃圾回收器是保守的:只要存在一個存活的引用,就不會回收它。在每次循環結束后,本地變量不再可訪問,然而本地變量仍持有對 Message 的引用,interpreter/JIT 理論上應該在本地變量不可訪問時將其引用置為 null,然而它們並沒有這樣做,引用仍然存活,而且不會被置為 null,使得它不會被回收。

  1、例如HandlerThread中,Looper會不停的從阻塞隊列MessageQueue中取Message進行處理。當沒有可消費Message對象時,就會開始阻塞,而此時最后一個被取出的Message就會被本地變量引用,一直不會釋放引用,哪怕Message已經被recycler(僅僅是清理了內容並放回消息隊列)。其實到這一步,只是一個空殼的Message被泄漏,無法回收,畢竟Message實例的內容還是被清理了(demo中的SecondActivity模擬了沒有recycler時的泄漏情況,適用於自己實現類似HandlerThread時需要注意的情況)。

  2、在Dialog源碼中,我們可以看到如下代碼片段,包括setOnCancelListener、setOnDismissListener在內的方法,其實都是將設置進來的listener對象(listener對象包含對Activity的引用)放到一個從消息隊列中拿到的Message實例中,將listener賦給了Message實例的obj變量。例如mShowMessage,mShowMessage會一直保存這個Message實例,不會再放回消息隊列中,因為在sendShowMessage時,Dialog是從消息隊列中再次obtain一個Message實例,復制mShowMessage內容進行發送。當然前面這些也不會存在什么問題,mShowMessage也會在Dialog銷毀時跟着銷毀。

  綜合1與2,分開來看,一般情況下大家互不干擾。但兩者碰撞在一起時,問題就來了。Dialog從消息隊列中可能會恰巧取到一個“仍然被某個阻塞中的HandlerThread本地變量引用的Message實例”,然后把listener賦給Message的obj,並一直保存在Dialog實例中(例如mShowMessage),這樣內存泄漏就發生了。就算Dialog銷毀,本地變量仍然引用保持着對Message的引用,導致obj變量的指向的listener無法回收,listener又包含對Activity的引用,導致Activity也無法正確回收。

  在這種情況下,除非HandlerThread收到新的Message處理,而給本地變量重新賦值從而切斷了對上一個Message引用,否則會一直內存泄漏。

public void setOnShowListener(@Nullable OnShowListener listener) {
        if (listener != null) {
            mShowMessage = mListenersHandler.obtainMessage(SHOW, listener);
        } else {
            mShowMessage = null;
        }
    }
private void sendShowMessage() {
        if (mShowMessage != null) {
            // Obtain a new message so this dialog can be re-used
            Message.obtain(mShowMessage).sendToTarget();
        }
    }

  解決方案:我們可以通過提供一個DialogInterface.OnCancelListener的包裝類(Dialog其他listener也一樣可行),僅包含對真正listener的引用,當Dialog退出后,解除對listener的引用。還有一個辦法就是在Handler空閑時發送一個空Message,當然處理Dialog Message的Handler我們無法直接控制(在Dialog內部的私有變量),所以采用包裝類方法解決。

public final class DetachableDialogCancelListener implements DialogInterface.OnCancelListener
{
    public static DetachableDialogCancelListener wrap(DialogInterface.OnCancelListener delegate)
    {
        return new DetachableDialogCancelListener(delegate);
    }

    private DialogInterface.OnCancelListener delegateOrNull;

    private DetachableDialogCancelListener(DialogInterface.OnCancelListener delegate)
    {
        this.delegateOrNull = delegate;
    }

    @Override
    public void onCancel(DialogInterface dialog)
    {
        if (delegateOrNull != null)
        {
            delegateOrNull.onCancel(dialog);
            delegateOrNull = null;
        }
    }

    public void clearOnDetach(Dialog dialog)
    {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2
                && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
        {
            dialog.getWindow()
                    .getDecorView()
                    .getViewTreeObserver()
                    .addOnWindowAttachListener(new ViewTreeObserver.OnWindowAttachListener()
                    {
                        @Override
                        public void onWindowAttached()
                        {

                        }

                        @Override
                        public void onWindowDetached()
                        {
                            if (delegateOrNull != null)
                            {
                                delegateOrNull.onCancel(dialog);
                                delegateOrNull = null;
                            }
                        }
                    });
        }
    }
}

  如下圖所示,通過對內存進行快照,看到確實達到了我們的目的。

QQ圖片20170928192156

  當然,問題並沒有因此而結束,當我將所有設置了setOnCancelListener等監聽事件的地方都用包裝類處理后,仍然收到了LeakCanary的內存泄漏通知。到底是怎么回事呢?通過一番debug,發現在DialogFragment的onActivityCreated中,設置過setOnCancelListener和setOnDismissListener,當自己再去設置時,還是會發生內存泄漏。其實問題就出在默認的設置,雖然我們重新設置了,但在執行默認設置時,仍然有可能會恰巧取到一個“仍然被某個阻塞中的HandlerThread本地變量引用的Message實例”,就算后面被重新設置了,但包含默認listener設置的Message仍然還被HandlerThread的本地變量引用,所以也就內存泄漏了。

@Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);

        if (!mShowsDialog) {
            return;
        }

        View view = getView();
        if (view != null) {
            if (view.getParent() != null) {
                throw new IllegalStateException(
                        "DialogFragment can not be attached to a container view");
            }
            mDialog.setContentView(view);
        }
        final Activity activity = getActivity();
        if (activity != null) {
            mDialog.setOwnerActivity(activity);
        }
        mDialog.setCancelable(mCancelable);
        mDialog.setOnCancelListener(this);
        mDialog.setOnDismissListener(this);
        if (savedInstanceState != null) {
            Bundle dialogState = savedInstanceState.getBundle(SAVED_DIALOG_STATE_TAG);
            if (dialogState != null) {
                mDialog.onRestoreInstanceState(dialogState);
            }
        }
    }

  至此,問題既然出在DialogFragment的onActivityCreated默認設置上,那么如果能取消默認的設置,就不會發生內存泄漏。上面這段代碼是DialogFragment的源碼,不能修改,而super.onActivityCreated又必須調用。如何解決呢?看上面代碼的第5行,通過調用setShowsDialog將mShowDialog設置為false,這樣super.onActivityCreated就等於不會執行剩余代碼邏輯了。在自己的onActivityCreated中,自行實現super類中本應執行的代碼邏輯(copy即可),然后將setOnCancelListener和setOnDismissListener通過包裝類進行設置,我這里是直接刪除了這兩行代碼,由繼承自BaseDialogFragment的子類自行設置。

public class BaseDialogFragment extends DialogFragment
{
    @Override
    public void onActivityCreated(Bundle savedInstanceState)
    {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
        {
            boolean isShow = this.getShowsDialog();
            this.setShowsDialog(false);
            super.onActivityCreated(savedInstanceState);
            this.setShowsDialog(isShow);

            View view = getView();
            if (view != null)
            {
                if (view.getParent() != null)
                {
                    throw new IllegalStateException(
                            "DialogFragment can not be attached to a container view");
                }
                this.getDialog().setContentView(view);
            }
            final Activity activity = getActivity();
            if (activity != null)
            {
                this.getDialog().setOwnerActivity(activity);
            }
            this.getDialog().setCancelable(this.isCancelable());
            if (savedInstanceState != null)
            {
                Bundle dialogState = savedInstanceState.getBundle("android:savedDialogState");
                if (dialogState != null)
                {
                    this.getDialog().onRestoreInstanceState(dialogState);
                }
            }
        }
        else
        {
            super.onActivityCreated(savedInstanceState);
        }
    }
}

  至此,Dialog和DialogFragment在Android5.0以下的內存泄漏問題均得以解決。但該方案並不完美,能夠解決內存泄漏的關鍵,還是通過監聽OnWindowAttachListener,在Dialog退出時切斷Message實例與真正listener對象的關聯。但OnWindowAttachListener需要level18,所以。。。如果有什么好的低版本同樣實現,煩請告知,感謝!

  如果是使用DialogFragment,可以在onDestory中切斷Message實例與真正listener對象的關聯。

  補充,本文一直在重點分析Dialog如何因為Message產生內存泄漏。而事實上,自己寫的HandlerThread中,如果是Android5.0以下,一定要在取出Message用完后,將Message置為null,並且要防止被編譯器優化掉,否則也會因為HandlerThread阻塞后,導致Message無法正確釋放包含的內容,產生內存泄漏。(可運行本文給出的demo,重現問題)。

  demo運行后,打開SecondActivity,發送Message,然后返回,此時Activity應該被銷毀,但LeakCanary會提示內存泄漏。將SecondActivity的Handler中取出的msg用完后置為null即可解決。而FourActivity模擬了HandlerThread發生泄漏的情況,可以嘗試用本文提出的辦法解決,Demo中給出了通過發送一個空消息,回收本地變量引用的Message實例。

demo GitHub地址


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM