DialogFragment内存泄漏原理及解决方案

DialogFragment泄漏引用链

上图是DialogFragment泄露的典型路径,引用链根部的HandlerThread可能是app中任何一个HandlerThread。DialogFragment内存泄漏问题覆盖Android全部版本(目前最高版本Q),其泄漏的根源与Dialog有关,也就是说,Dialog导致的内存泄漏同样覆盖了Android全部版本。

(源码参考AOSP android-9.0.0_r34分支)

为了本文引用源码的稳定性,这里引用的源码为android.app.DialogFragment。对support包或者androidx包下的DialogFragment,泄露同样存在,原理是一样的。

Message如何引用DialogFragment

首先看是哪里的Message引用了DialogFragment。

DialogFragment重写了onActivityCreated,在其中调用了Dialog.takeCancelAndDismissListeners

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

        ...
        mDialog.setCancelable(mCancelable);
        if (!mDialog.takeCancelAndDismissListeners("DialogFragment", this, this)) {
            throw new IllegalStateException(
                    "You can not set Dialog's OnCancelListener or OnDismissListener");
        }
        ...
    }

DialogFragment直接实现了DialogInterface.OnCancelListenerDialogInterface.OnDismissListener并在调用Dialog.takeCancelAndDismissListeners时传入:

    public boolean takeCancelAndDismissListeners(@Nullable String msg,
            @Nullable OnCancelListener cancel, @Nullable OnDismissListener dismiss) {
        ...
        
        setOnCancelListener(cancel);
        setOnDismissListener(dismiss);
        ...
    }

这里我们只查看setOnDismissListener方法即可,setOnCancelListener与其类似。

    public void setOnDismissListener(@Nullable OnDismissListener listener) {
        ...
        if (listener != null) {
            mDismissMessage = mListenersHandler.obtainMessage(DISMISS, listener);
        } else {
            mDismissMessage = null;
        }
    }

可以看到Dialog通过obtainMessage获取了Message存储为成员变量mDismissMessage,将外部传入的OnDismissListener(也就是DialogFragment实例)存储在mDismissMessage的成员obj中。

Dialog.mDismissMessageDialog.mCancelMessage均持有DialogFragment。

为了分析方便,我们假设是Dialog.mDismissMessage泄露了DialogFragment。对于mCancelMessage原理是一样的,就不再赘述。

至此我们找到了泄露DialogMessage的上一级引用mDismissMessage

Message为何没有recycle

我们知道一个Message只要被Looper处理了,就会被recycle,recycle的时候会将其各种字段重置,其中包括将Message.obj字段置null,也就是说,一个被处理过的Message是不会泄露引用的,为什么mDismissMessage.obj能一直持有DialogFragment?

这就需要看Dialog被dismiss的时候,是怎么使用这个mDismissMessage的:

    void dismissDialog() {
        ...
        try {
            ...
        } finally {
            ...
            sendDismissMessage();
        }
    }

    private void sendDismissMessage() {
        if (mDismissMessage != null) {
            // Obtain a new message so this dialog can be re-used
            Message.obtain(mDismissMessage).sendToTarget();
        }
    }

这里Dialog为了重用mDismissMessage,并不是直接使用它,而是通过Message.obtain拷贝出了一个新的Message发送出去。因此mDismissMessage不会被Looper处理,自然也就不会调用recycle方法,所以它会一直引用着DialogFragment

也就是说,如果mDismissMessage发生了泄露,则DialogFragment泄露。

引用链显示,是一个HandlerThread泄露了Message引用,继而导致DialogFragment泄露,那么mDismissMessage引用是如何被HandlerThread拿到的呢?

HandlerThread如何引用mDismissMessage

Dialog本身是不会把mDismissMessage的引用传到外面去的,唯一的可能就是Dialog拿到mDismissMessage的时候,HandlerThread就已经持有对mDismissMessage的引用了。

回想一下Dialog是如何获取mDismissMessage的:

    public void setOnDismissListener(@Nullable OnDismissListener listener) {
        ...
        if (listener != null) {
            mDismissMessage = mListenersHandler.obtainMessage(DISMISS, listener);
        } else {
            mDismissMessage = null;
        }
    }

mListenersHandler.obtainMessage最终是通过Message.obtain获取消息的:

    public static Message obtain() {
        synchronized (sPoolSync) {
            if (sPool != null) {
                Message m = sPool;
                sPool = m.next;
                m.next = null;
                m.flags = 0; // clear in-use flag
                sPoolSize--;
                return m;
            }
        }
        return new Message();
    }

sPool指向一个Message链表的表头,这个链表其实就是一个Message对象池。

Message在recycle的时候,就会被放入sPool对象池中,假如HandlerThread在一个Message被recycle之后依然保持对该Message的引用,则当Dialog从对象池中获取一个Message时,极有可能获取到被HandlerThread持有的那个Message。

至此我们知道了HandlerThread是如何拿到Dialog.mDismissMessage的引用的,其本质是HandlerThread插入到sPool的Message被Dialog拿去用了,同时HandlerThread维持对该Message的引用(泄露了Message)。

因此问题的关键就成了HandlerThread是如何泄露Message的。

HandlerThread如何泄露Message。

HandlerThread本身不会直接持有Message,但与HandlerThread关联的Looper会持有Message,代码如下:

    public static void loop() {
        ...
        for (;;) {
            Message msg = queue.next(); // might block
            ...

            msg.recycleUnchecked();
        }
    }

考虑以下场景:

Looper通过queue.next获取了一个MessageA存储在局部变量msg中,经过一系列消息处理代码之后顺利调用msg.recycleUnchecked,将MessageA插入对象池中,然后进入下一次循环,此时消息队列已经没有其他消息,Looper阻塞在queue.next上。

假如Looper此后一直没有收到新消息,一直阻塞在queue.next上,请问Looper现在是否持有MessageA的引用。

遗憾的是,不论是Dalvik还是ART上,这个问题的答案都是:Looper依然持有MessageA的引用。即:

阻塞状态的HandlerThread会泄露它处理的最后一个Message

一般人看到上面的代码,都会说,不存在内存泄露,理由是:

局部变量msg的作用域仅在单次循环内有效,单次循环结束时,局部变量msg就已经消失,不再指向MessageA,且在新的循环中,msg都是全新的局部变量,在queue.next返回之前,它都处于未赋值状态。

要理解为什么会存在内存泄露,就需要明确一个概念:所谓的作用域,局部变量,都是高级语言给我们提供的一种抽象,在实际的机器码/字节码执行过程中,并不存在所谓的局部变量,只有对寄存器的读写。

可以猜测,在Looper.loop中的for循环对应的字节码中,一定没有对寄存器的值进行擦除,导致最后一次写入寄存器的置,在queue.next阻塞的时候,一直指向前一个Message。

smali字节码验证猜想

我们可以仿照HandlerThread和Looper,写一段代码来验证我们的猜想

public class LeakThread extends Thread {
    private final LinkedBlockingDeque<LeakObj> linkedBlockingDeque = new LinkedBlockingDeque<>();

    @Override
    public void run() {
        for (;;) {
            LeakObj obj = nextObj();
            obj.emptyMethod();
        }
    }

    private LeakObj nextObj() {
        try {
            return linkedBlockingDeque.takeFirst();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return null;
    }

    public void offer(LeakObj obj) {
        linkedBlockingDeque.offer(obj);
    }
}

上面这个LeakThread,可以视为一个简陋的精简版HandlerThread,线程启动后将通过nextObj不断获取对象并调用方法,如果没有对象则会阻塞,外部可以通过offer方法插入对象。

编译后在Android机器上运行,会发现最后一个offer进去的LeakObj,无法被垃圾回收。

我们反编译看看它的run方法对应的smali字节码:

.method public run()V
    .locals 1

    .line 11
    :goto_0
    invoke-direct {p0}, Lio/github/wonshaw/testleak/LeakThread;->nextObj()Lio/github/wonshaw/testleak/LeakObj;

    move-result-object v0

    .line 12
    .local v0, "obj":Lio/github/wonshaw/testleak/LeakObj;
    invoke-interface {v0}, Lio/github/wonshaw/testleak/LeakObj;->emptyMethod()V

    .line 13
    .end local v0    # "obj":Lio/github/wonshaw/testleak/LeakObj;
    goto :goto_0
.end method

p0寄存器指向当前对象,相当于this,首先调用this.nextObj,将结果存储到v0寄存器,随后调用v0的emptyMethod,最后goto指令跳转goto_0,也就是进行下一轮循环。

可以明显的看出,v0寄存器在被赋值之后,就一直指向一个LeakObj,即便goto语句跳转goto_0,也就是新一轮循环开始的时候,v0也依然指向前一次循环中获得的LeakObj,如果线程阻塞在nextObj上,v0便一直指向前一次循环中的LeakObj。在字节码层面,单纯的循环内局部变量,在多次循环时,并没有所谓的局部引用超出范围这一概念,仅仅是寄存器的值被覆盖而已。

无论是Dalvik还是ART的垃圾回收器,对这种情况都很保守,不会尝试回收v0指向的对象。

需要注意的是,单看上面的字节码,我们可以明确的看到局部变量obj的作用域,从.local v0, "obj":Lio/github/wonshaw/testleak/LeakObj;开始,到.end local v0结束,但这些都是调试用的信息,如果去掉调试信息,则run方法对应的smali字节码如下:

.method public run()V
    .locals 1

    .line 11
    :goto_0
    invoke-direct {p0}, Lio/github/wonshaw/testleak/LeakThread;->nextObj()Lio/github/wonshaw/testleak/LeakObj;

    move-result-object v0

    .line 12
    invoke-interface {v0}, Lio/github/wonshaw/testleak/LeakObj;->emptyMethod()V

    goto :goto_0
.end method

验证猜想的注意点

如果你看了我的文章,想自己验证一下,那么你需要注意demo的代码写法是有一定要求的:
比如以下两个run方法,第二个方法在无调试信息的状态下,是没有内存泄漏的:

    @Override
    public void run() {
        for (;;) {
            LeakObj obj = nextObj();
            obj.emptyMethod();
        }
    }

    @Override
    public void run() {
        for (;;) {
            LeakObj obj = nextObj();
            Log.e("shaw", obj.toString());
        }
    }

第二个run方法,在无调试信息的情况下,存储obj引用的寄存器,会被obj.toString的结果覆盖,相当于清理了寄存器的引用,此时泄露的是obj.toString的返回值,如果你监测obj的泄露,是检测不到的。

而Android系统的Looper.loop方法的循环里面,最后一行恰好是调用msg.recycleUnchecked,使得存储msg的寄存器在循环的结尾不会被其他值覆盖。

解决方案

DialogFragment的泄漏模式,满足以下两点:

  1. 阻塞状态的Looper(通常与HandlerThread配合)会泄漏最后一个处理过的Message
  2. 通过Message.obtain获取Message,让Message.obj引用其他对象,且不及时切断引用(比如不手动置null,不手动调Message.recycle,不让Looper处理该Message)

满足这两点,就大概率会泄漏Message.obj引用的对象。由此可知不仅是DialogFragment泄露,DialogAlertDialog均可能间接导致我们设进去的OnDismissListener,OnCancelListener,OnShowListener发生泄漏。

对于 1,我们没有什么好办法,也就是说,我们通过Message.obtain拿到的任何Message,都有可能被一个不知名的HandlerThread引用着。

对于 2,我们可以干涉。

由于DialogFragment泄漏的本质是Dialog间接泄露了DialogFragment传入的OnDismissListener,OnCancelListener,因此,首要任务,就是让setXXListener操作,不泄露,或者最多只泄露一个对象,因此我们需要在方法调用上做手脚,创建一个自己的WeakDialog:

public class WeakDialog extends Dialog {
    public WeakDialog(@NonNull Context context) {
        super(context);
    }

    public WeakDialog(@NonNull Context context, int themeResId) {
        super(context, themeResId);
    }

    protected WeakDialog(@NonNull Context context, boolean cancelable, @Nullable OnCancelListener cancelListener) {
        super(context, cancelable, cancelListener);
    }

    @Override
    public void setOnCancelListener(@Nullable OnCancelListener listener) {
        super.setOnCancelListener(Weak.proxy(listener));
    }

    @Override
    public void setOnDismissListener(@Nullable OnDismissListener listener) {
        super.setOnDismissListener(Weak.proxy(listener));
    }

    @Override
    public void setOnShowListener(@Nullable OnShowListener listener) {
        super.setOnShowListener(Weak.proxy(listener));
    }
}

注意我们在listener上做了手脚,Weak.proxy会将listener包在我们自己的代理类里:

    public static WeakOnCancelListener proxy(DialogInterface.OnCancelListener real) {
        return new WeakOnCancelListener(real);
    }

代理类很简单,就是用弱引用保存外部的listener:

public class WeakOnCancelListener implements DialogInterface.OnCancelListener {
    private WeakReference<DialogInterface.OnCancelListener> mRef;

    public WeakOnCancelListener(DialogInterface.OnCancelListener real) {
        this.mRef = new WeakReference<>(real);
    }

    @Override
    public void onCancel(DialogInterface dialog) {
        DialogInterface.OnCancelListener real = mRef.get();
        if (real != null) {
            real.onCancel(dialog);
        }
    }
}

如果Dialog中拿到已经泄露的Message来保存listener,最多也就泄露一个空壳代理类,不会导致外部listener泄露。WeakDialog准备好了,接下来就是用WeakDialog替换DialogFragment中的Dialog:

以androidx包下的DialogFragment为例:

public class DialogFragment extends androidx.fragment.app.DialogFragment {
    @NonNull
    @Override
    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
        return new WeakDialog(requireContext(), getTheme());
    }
}

对于AOSP里的DialogFragment也可以如法炮制。

使用弱引用需要注意的一个问题是,弱引用所引用的对象,会不会因为没有GC roots的引用链而被回收?万幸的是DialogFragment直接实现OnDismissListener,OnCancelListener,即便Dialog不强引用外部传入的listener,listener也不会被回收,而是和DialogFragment对象的生命周期一致,因此这个解决方案对于DialogFragment是安全的,既切断了泄露的引用链,也能保证listener不会因为Dialog不强引用它而被回收。

相关代码我没有自己真正运行验证过,仅供参考:https://github.com/WonShaw/noleak

错误的解决方案

网上有人说只要通过setOnDismissListener(null)这种方式,把所有的listener全部置null就能解决内存泄漏,其实并不行。DialogFragment泄露的根源并不是Dialog泄露了,而是Dialog把listener的引用赋给了一个已经泄露的Message.obj,所以光清空Dialog中对Message的引用,并不能切断已经泄露的Message本身对listener的引用,必须从一开始就完全杜绝一个强引用的listener被设入,事后设null没有任何意义。

无法修改的DialogFragment

如果是第三方库中存在的DialogFragment,且我们无法修改源码,建议通过一些编译期字节码变换的工具,将他们的DialogFragment改为继承我们安全版本的DialogFragment。