记一次 DialogFragment 造成的内存泄漏

3,502 阅读5分钟
原文链接: blog.jiyang.site

实例

首先看下给出的引用链:

GC Root Leaked Object Message
* EXCLUDED LEAK.
* XXXActivity has leaked:
* thread HandlerThread.!(<Java Local>)! (named 'XXHandlerThread')
* ↳ Message.!(obj)! , matching exclusion field android.os.Message#obj
* ↳  XXXDialogFragment$3.!(val$context)! (anonymous implementation of android.content.DialogInterface$OnCancelListener)
* ↳ XXXActivity

* Details:
* Instance of android.os.HandlerThread
|   mHandler = null
|   mLooper = android.os.Looper@316943936 (0x12e42e40)
* Instance of android.os.Message
|   static sPoolSize = 29
|   static sPool = android.os.Message@316941920 (0x12e42660)
|   callback = null
|   data = null
|   flags = 0
|   next = null
|   obj =  XXXDialogFragment$3@325843528 (0x136bfa48)
|   replyTo = null
|   sendingUid = -1
|   target = android.app.Dialog$ListenersHandler@325843544 (0x136bfa58)
|   what = 68
|   when = 0
* Instance of XXXDialogFragment$3
|   val$context = XXXActivity@325713968 (0x136a0030)

原因分析

从引用链上可以看到是一个 Message 被一个 HanderThread(在 Java 中,处于运行状态的 Thread 也是 GC Root) 引用了,而且通过几次查看发现每次的 GC Root 是不同的 HanderThread, 貌似是随机的。详细查看 Message 的 obj 和 what 字段,再与 DialogmCancelMessage mDismissMessage mShowMessage 对比发现, 泄漏的 Message 正是其中之一。于是去查看这几个 Message 是在什么时候被创建的 :

Dialog 创建和发送 Message

  1. DialogFragment 在 onActivityCreated 方法中会为内部的 mDialog 设置监听器
//androidx.fragment.app.DialogFragment#onActivityCreated
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    ...
    // DialogFragment 实现了 OnDismissListener 和 OnCancelListener
    mDialog.setCancelable(mCancelable);
    mDialog.setOnCancelListener(this);
    mDialog.setOnDismissListener(this);
    ...
}
  1. Dialog 在设置监听器时会调用 mListenersHandler.obtainMessage 获取一个消息, 然后设置 whatobj 字段
//android.app.Dialog#setOnDismissListener
public void setOnDismissListener(@Nullable OnDismissListener listener) {
	// listener 就是外部传入的 DialogFragment, 也就导致 Dialog 的 mDismissMessage, mCancelMessage, mShowMessage 的 obj 引用了 DialogFragment
    if (listener != null) {
        mDismissMessage = mListenersHandler.obtainMessage(DISMISS, listener);
    } else {
        mDismissMessage = null;
    }
}
  1. 当调用 dialogdismiss show hide 时把消息发送到 Looper 中
//android.app.Dialog#sendDismissMessage 例如: 发送 dismiss 消息
private void sendDismissMessage() {
    if (mDismissMessage != null) {
        // 没有直接把 mDismissMessage 发出去,而是通过 obtain 复制了一个新的
        Message.obtain(mDismissMessage).sendToTarget();
    }
}
发送消息的时候,并没有直接将已有的 mDismissMessage 发出去,而是又调用 obtain 获取了一个新的消息发送到 Looper 的 MessageQueue 中
  1. 当消息回调时再进行对应的操作
//android.app.Dialog.ListenersHandler
private static final class ListenersHandler extends Handler {
    private final WeakReference<DialogInterface> mDialog;
    public ListenersHandler(Dialog dialog) {
        mDialog = new WeakReference<>(dialog);
    }
    @Override
    public void handleMessage(Message msg) {
        //对比截图中 Message 的 what 能匹配这里的 case
        switch (msg.what) {
            case DISMISS:
                ((OnDismissListener) msg.obj).onDismiss(mDialog.get());
                break;
            case CANCEL:
                ((OnCancelListener) msg.obj).onCancel(mDialog.get());
                break;
            case SHOW:
                ((OnShowListener) msg.obj).onShow(mDialog.get());
                break;
        }
    }
}

看了 Dialog 中 Message 的创建逻辑,也没有涉及 HandlerThread 的内容,那为什么 HandlerThread 会引用了这些 Message,而且一直不释放呢?

按照正常的逻辑, Message 的生命周期应该是:

在回收进消息池之前会先解除 Message 引用的所有对象.

void recycleUnchecked() {
    flags = FLAG_IN_USE;
    // 清空所有引用
    what = 0;
    arg1 = 0;
    arg2 = 0;
    obj = null;
    replyTo = null;
    sendingUid = -1;
    when = 0;
    target = null;
    callback = null;
    data = null;

    synchronized (sPoolSync) {
        // 放到链表头部
        if (sPoolSize < MAX_POOL_SIZE) {
            next = sPool;
            sPool = this;
            sPoolSize++;
        }
    }
}

那说明 Dialog 在 sendDismissMessage 时发出去的 Message 是不可能一直持有其他对象的引用的, 所以只有可能是在 setOnDismissMessage 时获取的 mDismissMessage 泄漏了. 但是 mDismissMessage 是 Dialog 的一个成员变量, 理论上
应该随着 Dialog 的释放而被 GC 回收。那这个 Message 是为何被一个 HandlerThread 持有了呢?

HandlerThread 消费 Message

在每个 Android 应用进程中, 有一个消息池是由所有线程共用的, 通过 Message.obtain() 就是复用这个池子中已有的 Message, 池子以链表的方式实现。
HandlerThread 则是在创建时就会自己创建一个 Looper 的线程, 所以当它 start 了之后, 就会调用 Looper.loop() 一直循环消费MessageQueue 中的消息。

for (;;) {
    Message msg = queue.next(); // 在 MessageQueue 中没有新的消息时, 阻塞当前线程
    if (msg == null) {
        return;
    }
    msg.target.dispatchMessage(msg);
    msg.recycleUnchecked();
}

在 MessageQueue 中没有新的消息时, 当前线程线程就会被阻塞. 同时上一条被回收的消息会暂时被当前线程持有. 所以, 有一种可能就是 Dialog 获取的 mDismissMessage 就是被 HandlerThread 在等待下一条消息时阻塞的消息. 导致 mDismissMessage 无法被 GC 回收.

复现

  • 首先向一个 HandlerThread(称为 BackThread) 通过 Handler 发送一条 Message(称为 A)(handler.post)
  • 然后当 A 被 BackThread 执行之后, 再通过主线程 Handler 向主线程发送一条 Message(称为B)(runOnUiThread), 该 Message 的 obj 引用当前 Activity
  • 这时有很大可能 B 就是 A(因为消息池的第一条消息会是A), 而 A 由于 BackThread 的 MessageQueue 没有新 Message, 被 BackThread 引用着.
  • 当 Activity 退出后, BackThread 还继续处于阻塞状态, Message A 也就不能被 GC 回收
class XXActivity : Activity() {

    private val byte = ByteArray(1024 * 1024 * 10)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bThread = HandlerThread("BackendThread")
        bThread.start()
        val handler = Handler(xxThread.looper)
        val obj = this
        // 先向 BackendThread 通过 handler 发送一条 Message
        handler.post { // 会产生一个 Message A
            // 当 Message A 被消费时, 会被回收到消息池
            runOnUiThread {
                // 通过 runOnUiThread 向主线程发送一条消息
                val messageB = handler.obtainMessage(1, obj) // 很有可能 B 就是刚被回收的 A 
                Message.obtain(messageA).sendToTarget()
            }
        }
    }
}

进入 XXActivity, 退出, 然后再做其他功能, Leak Canrray 就检测到了如下内存泄漏:

* Details:
* Instance of android.os.HandlerThread
|   name = "BackendThread"
|   tid = 507
* Instance of android.os.Message
|   arg1 = 0
|   arg2 = 0
|   callback = null
|   data = null
|   flags = 0
|   next = null
|   obj = io.github.stefanji.playground.XXActivity@316341328 (0x12dafc50)
|   replyTo = null
|   sendingUid = -1
|   target = android.os.Handler@316341560 (0x12dafd38)
|   what = 1
|   when = 0
* Instance of io.github.stefanji.playground.XXActivity
|   byte = byte[10485760]@3441664000 (0xcd23a000)

解决办法

网上有说在 super.onActivityCreated 执行完之后, 再单独调用 getDialog().setOnDismissListener(null) 来置空 Message。这样其实是不行的,因为在 super.onActivityCreated 执行时有可能 Dialog.setOnDismissListener 里的 mDismissMessage 已经被其他 HandlerThread 持有了. 所以根本的方法是避免 Dialog 里的 Message 直接引用 Fragment/Activity/View.

1. 复写 DialogFragment 的 onCreateDialog 返回自己实现的 Dialog

适用于不需要监听 Dialog 的 onShow onDismiss 事件时

继承 Dialog, 复写 setOnXXListener 另其不会创建 cancelMessage, dismissMessage, showMessage.

private class MDialog(context: Context, @StyleRes themeResId: Int) : Dialog(context, themeResId) {

    override fun setOnCancelListener(listener: DialogInterface.OnCancelListener?) {
        // 空实现
    }

    override fun setOnDismissListener(listener: DialogInterface.OnDismissListener?) {
        // 空实现
    }

    override fun setOnShowListener(listener: DialogInterface.OnShowListener?) {
        // 空实现
    }
}

2. 向 HandlerThread 发送空 Message

该方法的原理是, 通过调用 HandlerThread 的 MessgeQueue 的 addIdleHandler, 添加一个当 MessageQueue 中无消息时的监听,
IdleHandler 被回调时, 向对应 MessageQueue 发送一条空白 Message, 从而避免 HandlerThread 阻塞在 queue.next.

handler.looper.queue.addIdleHandler {
    handler.obtainMessage().sendToTarget()
    true
}

但是, 一般情况下, 我们应用中会存在很多 HandlerThread, 比如一些第三方库内部也会创建 HandlerThread, 这种方法就不能保证处理了每个 HandlerThread.

3. 其他方法

其他方法应该还有, 只要达到了最终目的(避免 Dialog 里的 Message 直接引用 Fragment/Activity/View.)就行.

总结

  • 首先, 这种泄漏存在一定概率, 要你的应用中存在这样的经常没有新 Message 处理的 HandlerThread, 恰巧又遇到了 Dialog 中需要 obtain Message 了.
  • 其次, 通过这个问题, 自己也加深了对 Message 消息池复用的理解.

参考

medium.com/square-corn…