Message 引发的 DialogFragment 内存泄漏分析与解决方案

5,949 阅读12分钟

Q:咋回事?正常使用 Dialog 和 DialogFragment 也有可能会导致内存泄漏?

A: ....是的,说来话长。

长话短说:

  1. 某一个 HandlerThread 的 Looper#loop 方法,一直等待 queue#next 方法返回,但是它的 msg 局部变量还引用着上一个循环中已经被放到 Message Pool 中 Message,我们称之为 MessageA。
  2. DialogFragment#onActivityCreated 方法中,会调用 Dialog#setOnCancelListener 方法,将自身的引用作为 listener 参数传递给该方法
  3. Dialog#setOnCancelListener 方法内部,会尝试从 Message Pool 中获取一个 Message,取出的 Message 刚好是 MessageA,然后将传入的 Listener 实例赋值给 MessageA#obj。
  4. 外部调用 cancel 的时候,Dialog 内部会将 MessageA 拷贝一份,我们称它为 MessageB,然后将 MessageB 发送到消息队列中。
  5. DialogFragment 收到 onDestory 回调之后,LeakCanary 开始监听这个 DialogFragment 是否正常被回收,发现这个实例一直存在,dump 内存,分析引用链,报告内存泄漏问题。

具体细节介绍见下文👇

1、问题

开发的时候, LeakCanary 报告了一个诡异的内存泄漏链。

操作路径:app 显示 DialogFragment 然后点击外部使其消失,之后 LeakCanary 就报了如下问题:

LeakCanary 报告的异常

从上面的截图 👆 可以看出:GCRoot 是 HandlerThread 正在执行的方法中的一个局部变量。这个局部变量强引用了一个 Message 对象,message 的 obj 字段又强引用了 NormalDialogFragment ,导致其调用了 onDestory 方法之后,也无法被回收。

2、分析

注:本文中的「HandlerThread」泛指那些带有 Looper 并且开启了消息循环(调用了 Looper#loop)的线程

DialogFragment 为啥会被一个 Message 的 obj 字段强引用?而且那还是一个被 HandlerThread 引用着的 Message。

回顾一下我们正常显示 DialogFragment 的流程:1、实例化 DialogFragment,2、调用 DialogFragment#show 方法让其显示出来。这个流程中有可能导致 Fragment 被 Message 强引用吗?

  • 首先看 DialogFragment 的构造方法是一个空实现。排除。
  • 其次看 DialogFragment show 方法逻辑如 👇,也是正常的 Fragment 显示逻辑。排除。
  public void show(@NonNull FragmentManager manager, @Nullable String tag) {
    mDismissed = false;
    mShownByMe = true;
    FragmentTransaction ft = manager.beginTransaction();
    ft.add(this, tag);
    ft.commit();
  }

难道是 show 过程的某个步骤中去获取了 Message? 在 DialogFragment#onActivityCreated 方法中,可以看到

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
  super.onActivityCreated(savedInstanceState);
  if (!mShowsDialog) {
    return;
  }
  //省略一些代码
  mDialog.setCancelable(mCancelable);
  mDialog.setOnCancelListener(this);//设置 cancel 监听器
  mDialog.setOnDismissListener(this);//设置 dismiss 监听器
  //省略一些代码
}

以 Dialog#setOnCancelListener 方法为例 👇

public void setOnCancelListener(@Nullable OnCancelListener listener) {
  if (mCancelAndDismissTaken != null) {
    throw new IllegalStateException(
        "OnCancelListener is already taken by "
        + mCancelAndDismissTaken + " and can not be replaced.");
  }
  if (listener != null) {
    //Listener 不为 null,取出一条 message(会尝试先从 pool 中获取,如果没有消息才会 new 一个新的)  这是一个比较关键的点,后续会讲到
    mCancelMessage = mListenersHandler.obtainMessage(CANCEL, listener);
  } else {
    mCancelMessage = null;
  }
}

可以看到,Dialog#setOnCancelListener 方法会从消息池中获取一条 message,并赋值给 Dialog 的 mCancelMessage 成员变量。

这个 message 什么时候会用到?当 cancel 方法被调用的时候。下面看下 Dialog#cancel 方法

Dialog#cancel 方法 👇

@Override
public void cancel() {
  if (!mCanceled && mCancelMessage != null) {
    mCanceled = true;
    // Obtain a new message so this dialog can be re-used
     //复制一份,然后发送。这里为啥需要复制而不是用原来的消息?看官方的注释说,是为了 Dialog 能够被复用。(所谓「复用」应该是指,Dialog cancel 之后,再调用 show 还可以显示出来, 并且之前设置的监听都还有效)
    Message.obtain(mCancelMessage).sendToTarget();
  }
  dismiss();
}

重点 👇👇👇

也就是说,我们调用 Dialog#setOnCancelListener 方法从消息池获取到的 Message 最终是不会被发送出去的。因此 Message#recycleUnchecked 方法不会被调用。

但是即使没有发送出去,也只是 Dialog 的一个成员变量呀,Dialog 销毁的时候,这个 message 应该也能被回收,不至于导致内存泄漏吧?

再看回前面 LeakCanary 报出来的引用链,GCRoot 是一个 HandlerThread 中的局部变量。

Q:回顾一下 Android 的消息机制中,Message 是如何被使用的?

A:我们通过 Handler#postDelayed() 或者是 Message#sendToTarget 方法发送的消息,最终都会进入到 当前线程的 MessageQueue 中,然后 Looper#loop 方法不断地从队列中取出 Message,派发执行。当消息队列为空的时候,就会休眠。等到有新的 message 可以取出的时候,重新唤醒。

Looper#loop 方法

public static void loop() {
  final Looper me = myLooper();
  final MessageQueue queue = me.mQueue;
   //省略一些代码
  for (;;) {
    Message msg = queue.next(); // might block
    //省略一些代码
    msg.target.dispatchMessage(msg);
     //省略一些代码
    msg.recycleUnchecked();
  }
}

正常情况下,msg 派发到目标对象之后,都会调用 msg.recycleUnchecked() 方法完成重置,放入消息池。

难道执行 for 循环体中的一次迭代之后,msg 局部变量还是持有上一个迭代中的 Message 的强引用?

如果这个假设成立,那么上面的泄漏就说得通了。

2.1、 验证

咱们可以写一段类似的代码,然后用 javap 命令查看字节码验证一下。

新建一个 Test.java 文件,添加如下代码:

import java.util.concurrent.BlockingQueue;
public class Test {
  static void loop(BlockingQueue<String> blockingQueue) throws InterruptedException {
    while (true) {
      String msg = blockingQueue.take();
      System.out.println(msg);
    }
  }
}

执行如下命令:

javac Test.java javap -v Test

loop 方法对应的字节码如下 👇:

static void loop(java.util.concurrent.BlockingQueue<java.lang.String>) throws java.lang.InterruptedException;
 descriptor: (Ljava/util/concurrent/BlockingQueue;)V
 flags: ACC_STATIC
 Code:
  stack=2, locals=2, args_size=1
    0: aload_0  #加载 slot0 的参数  将第0个引用类型本地变量推送至栈顶,因为是静态方法,没有 this,因此,是方法参数列表中的第一个参数,也就是加载 BlockingQueue
    1: invokeinterface #2,  1       // InterfaceMethod java/util/concurrent/BlockingQueue.take:()Ljava/lang/Object;
    6: checkcast   #3          // class java/lang/String
    9: astore_1  #将 blockingQueue.take(); 执行的结果(一个 String 类型的值)存到第一个 slot
   10: getstatic   #4          // Field java/lang/System.out:Ljava/io/PrintStream;
   13: aload_1  #  将第1个引用类型本地变量推送至栈顶
   14: invokevirtual #5          // Method java/io/PrintStream.println:(Ljava/lang/String;)V
   17: goto      0  #无条件跳转到第 0 行
  LineNumberTable:
   line 6: 0
   line 7: 10
   line 8: 17
  StackMapTable: number_of_entries = 1
   frame_type = 0 /* same */
 Exceptions:
  throws java.lang.InterruptedException
 Signature: #18              // (Ljava/util/concurrent/BlockingQueue<Ljava/lang/String;>;)V

从上面的字节码可以看出,当一个迭代执行结束之后,首先会跳转会循环体的第一行,上面的例子中对应的就是 blockingQueue#take 这行代码。此时,局部变量中的 slot1,还是指向上一次迭代中的 String 变量。如果 blockingQueue 中已经没有元素了,这时就会一直等待下一个元素插入,而上一次迭代中的 String 变量虽然已经没有用了,但是因为被局部变量表引用着,无法被 GC。

重点 👇👇👇

回到我们的主线, Looper#loop 方法中 for 循环体中的第一行,queue.next(); 方法,当消息队列中没有消息的时候,这个调用会一直阻塞在那里。此时 msg 没有被重新赋值。因此,loop 方法的局部变量表中还是持有对上一个迭代中 message 实例的引用。

虽然 loop 方法结尾执行了 msg.recycleUnchecked(); 方法,会将 message 中的字段都置为空值,但是,与此同时,它会将这个 message 放入到 pool 中。这个时候,message 已经开始「泄漏」了。

再回到前面,DialogFragment#onActivityCreated 方法中,会调用 Dialog#setOnCancelListener 方法,该方法内部又会尝试从消息池中取一个 message。如果刚好取到的 message 是被某个 MessageQueue 为空的 handlerThread 的 loop 方法 (对应的栈帧中的局部变量表)所引用着的,那么 DialogFragment 销毁的时候,LeakCanary 就会报告说内存泄漏产生了。

重点 👆👆👆

如下图所示:

DialogFragment内存泄漏

2.2、复现

Q:看上面的描述,这个内存泄漏要触发的条件还是比较严苛的,有什么复现路径吗?

A:因为这个泄漏跟 message 复用有很大关系。要复现这个问题,我们可以先看下消息池中的 message message#recycleUnchecked 方法以及 Message#obtain 方法

void recycleUnchecked() {
   //省略一些代码
  synchronized (sPoolSync) {
    if (sPoolSize < MAX_POOL_SIZE) {
      next = sPool;
      //相当于插入队头
      sPool = this;
      sPoolSize++;
    }
  }
}

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();
}

从 👆 两个方法可以看出:

  1. Message 回收的时候,会插入回收池列表的第一个元素
  2. Message 重用的时候,会取出回收池链表的第一个元素

**也就是说,取出的 message 一般是最新插入的。**因此,可以尝试使用如下代码进行复现。

class MainActivity : AppCompatActivity() {
  //新建一个名为 BackgroundThread 的HandlerThread
  private val background = HandlerThread("BackgroundThread")
    .apply {
      start()
    }
  private val backgroundHandler = Handler(background.looper)
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    mBtnShowNormalDialogFragment.setOnClickListener {
      //往 通过 backgroundHandler 往 background HandlerThread 中的 MessageQueue 插入一条 msg
      backgroundHandler.post(Runnable {
       //调用 runOnUiThread,往主线程的 MessageQueue 插入一条 msg。因为当前线程并非主线程,因此会往主线程队列中 post 一个 Message(这个 message 回先尝试从 pool 中取,大概率会取到 backgroundHandler 刚刚执行完被回收的 message )
        runOnUiThread {
          val fragment = NormalDialogFragment()
          fragment.apply {
            show(supportFragmentManager, "NormalFragment")
          }
        }
      })
    }
  }
}

运行之后,点击使 DialogFragment 消失,等待 10s 左右,LeakCanary 可能就会报告内存泄漏问题了。

2.3、message 内存泄漏的影响

Q:泄漏的内存是否会不断增长?是短暂泄漏还是长时间的泄漏?

  • 存在增长的可能性,但是是有上限的。 - 增长的上限主要看 应用中有多少个之前执行过 Message 但是目前队列为空的带有 Looper 的 Thread,这种类型的 Thread 数目越多,Message 泄漏的概率就越高。 - 忽略那些不是通过继承 HandlerThread 实现的 带 Looper 的 Thread。TODO app 常驻的

  • 主要影响的是类似于 Dialog 这种从消息池中获取了 Message 但是一直没有调用 Message#recycle 方法的情况。这种情况下,需要等待相应的线程有新的 Message 入队列并且被取出之后,才会释放。 - 如果有调用 recycle,即使 message 一直被另一个线程的 Looper#loop 方法 局部引用着,真正用到这条 message 被执行完,也会调用 Message#recycleUnchecked 方法将 消息的内容清除掉。

3、解决方案

3.1、系统侧

  • Android 官方消息机制中, Java 层的代码中应该在 Looper#loop 方法的末尾,将 msg 变量置为 null。
  • ART、Dalvik 中当引用变量无效时,可以将对应的 slot 置为 null

3.2、App 侧

相对通用的解决方案

  1. 如果是 library 的开发者,自己开发的 library 使用到了 HandlerThread,想防止自己的库中的 HandlerThread 引发类似的内存泄漏问题,可以将 handlerThread 的 looper 传递给下面的 flushStackLocalLeaks 方法。
/**
 * 接收 handlerThread 的 looper
 * */
fun flushStackLocalLeaks(looper: Looper) {
  val handler = Handler(looper)
  handler.post {
    //当队列闲置的时候,就给它发送空的 message,以确保不会发生 message 内存泄漏
    Looper.myQueue().addIdleHandler {
      handler.sendMessageDelayed(handler.obtainMessage(), 1000)
      //返回 true,不会自动移除
      return@addIdleHandler true
    }
  }
}
  1. 针对 app,可以通过 Thread.getAllStackTraces().keys 方法获取所有的线程。迭代遍历,判断线程是否为 HandlerThread,如果是,则调用上面的 flushStackLocalLeaks 方法。采用这种方案要注意的点是,要注意调用时机,确保调用的时候所有的 HandlerThread 都已经启动了,不然会有遗漏的情况。
Thread.getAllStackTraces().keys.forEach { thread ->
  if (thread is HandlerThread && thread.isAlive) {
    //添加 IdleHandler
    flushStackLocalLeaks(thread.looper)
  }
}

但是这种方案也存在不足的地方:

  • App 中可能存在带有 Looper 和 MessageQueue 的 Thread 但又不是通过继承 HandlerThread 来实现的,需要用更通用的判断方式。 Looper 是存在 Thread 的 threadLocalMap 里面的,仅通过线程实例对象,并不是很好获取。
  • 系统版本限制。 Looper#getQueue 方法是 API Level 23 才添加的 ,也就是说,直接用这种方式无法涵盖 <= Android 5.1 版本的系统。
    • Looper#myQueue 方法没有 API 限制,但是它只能拿到当前线程的 queue,没法通过线程实例去获取 queue
    • 针对版本 < 6.0 的手机,可以考虑通过反射获取 Looper#mQueue 字段解决

只针对 Dialog/DialogFragment 泄漏的解决方案:

在保证 Dialog 原有的复用功能正常运行的前提下:有两个思路:

  1. 思路:从 pool 中取出的 message 有可能是被其他某个 HandlerThread 引用着的,那我们不要从 pool 中取消息,而是直接 new Message 不就没有这个问题了吗?

    • 查看 Dialog 源码 mCancelMessage mDismissMessage mShowMessage 访问权限都是 private 的,虽然可以通过继承重写 setOnXxxListener 方法,但是不使用反射的话,无法为 mCancelMessage 赋值。
      • 反射有点 hack,我们优先看看是否有别的方案。
      • Dialog 中还有 Dialog#setCancelMessage、以及 setDismissMessage 方法,可以实现对 cancelMessage 和 dismissMessage 赋值,但是没有 setShowMessage 这样的方法。这种方式覆盖不全面。
  2. 另一种思路,切断引用链

    • 定义一个继承自 Dialog 的 AvoidLeakDialog 重写 setOnDismissListener setOnShowListener setOnCancelListener 方法,将传入的 Listener 包装一层。同时为了避免 Listener 变量因为仅被弱引用者,导致在 GC 的时候被提前回收,还应该添加在 重写的 Dialog 中添加三个成员变量,存储对应 Listener 的值。然后定义一个 DialogFragment 的子类,AvoidfLeakDialogFragment,重写 onCreateDialog 方法,返回自定义的 Dialog。

以 setOnShowListener 方法为例,包装类如下:

class WrappedShowListener(delegate: DialogInterface.OnShowListener?) :
  DialogInterface.OnShowListener {
  private var weakRef = WeakReference(delegate)
  override fun onShow(dialog: DialogInterface?) {
    weakRef.get()?.onShow(dialog)
  }
}

最终代码:

附:其他解决方案

square 以及其他网上的文章中,有一种解决方案,是将设置给 Dialog 的 Listener 包装一层为 ClearOnDetachListener ,然后业务方调用 Dialog#show 方法之后,再去手动 clearOnDetach 方法。

这种方法确实可以解决内存泄漏问题。但是存在这样的问题:在 dialog 调用 dimiss 方法之后,再调用 show 方法的话,原来设置的 Listener 就失效了。

/**
 * https://medium.com/square-corner-blog/a-small-leak-will-sink-a-great-ship-efbae00f9a0f
 * square 的解决方案。View detach 的时候就将引用置为 null 了,
 * 会导致 Dialog 重新显示的时候,原来设置的 Listener 收不到回调
 *
 * 在 show 之后,调用 clearOnDetach
 * */
class ClearOnDetachListener(private var delegate: DialogInterface.OnClickListener?) :
  DialogInterface.OnClickListener {
  override fun onClick(dialog: DialogInterface?, which: Int) {
    delegate?.onClick(dialog, which)
  }
  fun clearOnDetach(dialog: Dialog) {
    dialog.window?.decorView?.viewTreeObserver?.addOnWindowAttachListener(object :
      ViewTreeObserver.OnWindowAttachListener {
      override fun onWindowDetached() {
        Log.d(TAG, "onWindowDetached: ")
        delegate = null
      }
      override fun onWindowAttached() {
      }
    })
  }
}

使用方式

val clearOnDetachListener =
  ClearOnDetachListener(DialogInterface.OnClickListener { dialog, which -> {} })
val dialog = AlertDialog.Builder(this)
  .setPositiveButton("sure", clearOnDetachListener)
  .show()
clearOnDetachListener.clearOnDetach(dialog)

4、参考资料与学习资源推荐

由于本人水平有限,可能出于误解或者笔误难免出错,如果发现有问题或者对文中内容存在疑问请在下面评论区告诉我,谢谢!