代码改变世界

解决Android5.0以下Dialog引起的内存泄漏

2017-10-14 00:44  soar.  阅读(7679)  评论(0编辑  收藏  举报

  最近项目开发中,开发人员和测试人员均反应在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地址