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)
複製代碼
因爲本人水平有限,可能出於誤解或者筆誤不免出錯,若是發現有問題或者對文中內容存在疑問請在下面評論區告訴我,謝謝!