Q:咋回事?正常使用 Dialog 和 DialogFragment 也有可能会致使内存泄漏?java
A: ....是的,说来话长。git
长话短说:github
具体细节介绍见下文👇bash
开发的时候, LeakCanary 报告了一个诡异的内存泄漏链。app
操做路径:app 显示 DialogFragment 而后点击外部使其消失,以后 LeakCanary 就报了以下问题:ide
从上面的截图 👆 能够看出:GCRoot 是 HandlerThread 正在执行的方法中的一个局部变量。这个局部变量强引用了一个 Message 对象,message 的 obj 字段又强引用了 NormalDialogFragment ,致使其调用了 onDestory 方法以后,也没法被回收。oop
注:本文中的「HandlerThread」泛指那些带有 Looper 而且开启了消息循环(调用了 Looper#loop)的线程post
DialogFragment 为啥会被一个 Message 的 obj 字段强引用?并且那仍是一个被 HandlerThread 引用着的 Message。学习
回顾一下咱们正常显示 DialogFragment 的流程:一、实例化 DialogFragment,二、调用 DialogFragment#show 方法让其显示出来。这个流程中有可能致使 Fragment 被 Message 强引用吗?ui
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 的强引用?
若是这个假设成立,那么上面的泄漏就说得通了。
我们能够写一段相似的代码,而后用 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 就会报告说内存泄漏产生了。
重点 👆👆👆
以下图所示:
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();
}
复制代码
从 👆 两个方法能够看出:
**也就是说,取出的 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 可能就会报告内存泄漏问题了。
Q:泄漏的内存是否会不断增加?是短暂泄漏仍是长时间的泄漏?
存在增加的可能性,可是是有上限的。 - 增加的上限主要看 应用中有多少个以前执行过 Message 可是目前队列为空的带有 Looper 的 Thread,这种类型的 Thread 数目越多,Message 泄漏的几率就越高。 - 忽略那些不是经过继承 HandlerThread 实现的 带 Looper 的 Thread。TODO app 常驻的
主要影响的是相似于 Dialog 这种从消息池中获取了 Message 可是一直没有调用 Message#recycle 方法的状况。这种状况下,须要等待相应的线程有新的 Message 入队列而且被取出以后,才会释放。 - 若是有调用 recycle,即便 message 一直被另外一个线程的 Looper#loop 方法 局部引用着,真正用到这条 message 被执行完,也会调用 Message#recycleUnchecked 方法将 消息的内容清除掉。
相对通用的解决方案
/** * 接收 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
}
}
}
复制代码
Thread.getAllStackTraces().keys.forEach { thread ->
if (thread is HandlerThread && thread.isAlive) {
//添加 IdleHandler
flushStackLocalLeaks(thread.looper)
}
}
复制代码
可是这种方案也存在不足的地方:
只针对 Dialog/DialogFragment 泄漏的解决方案:
在保证 Dialog 原有的复用功能正常运行的前提下:有两个思路:
思路:从 pool 中取出的 message 有多是被其余某个 HandlerThread 引用着的,那咱们不要从 pool 中取消息,而是直接 new Message 不就没有这个问题了吗?
另外一种思路,切断引用链
以 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)
复制代码
因为本人水平有限,可能出于误解或者笔误不免出错,若是发现有问题或者对文中内容存在疑问请在下面评论区告诉我,谢谢!