Q:咋回事?正常使用 Dialog 和 DialogFragment 也有可能會導致內存泄漏?
A: ....是的,說來話長。
長話短說:
- 某一個 HandlerThread 的 Looper#loop 方法,一直等待 queue#next 方法返回,但是它的 msg 局部變量還引用着上一個循環中已經被放到 Message Pool 中 Message,我們稱之爲 MessageA。
- DialogFragment#onActivityCreated 方法中,會調用 Dialog#setOnCancelListener 方法,將自身的引用作爲 listener 參數傳遞給該方法
- Dialog#setOnCancelListener 方法內部,會嘗試從 Message Pool 中獲取一個 Message,取出的 Message 剛好是 MessageA,然後將傳入的 Listener 實例賦值給 MessageA#obj。
- 外部調用 cancel 的時候,Dialog 內部會將 MessageA 拷貝一份,我們稱它爲 MessageB,然後將 MessageB 發送到消息隊列中。
- DialogFragment 收到 onDestory 回調之後,LeakCanary 開始監聽這個 DialogFragment 是否正常被回收,發現這個實例一直存在,dump 內存,分析引用鏈,報告內存泄漏問題。
具體細節介紹見下文👇
1、問題
開發的時候, LeakCanary 報告了一個詭異的內存泄漏鏈。
操作路徑:app 顯示 DialogFragment 然後點擊外部使其消失,之後 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;>;)
從上面的字節碼可以看出,當一個迭代執行結束之後,首先會跳轉會循環體的第一行,上面的例子中對應的就是 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 就會報告說內存泄漏產生了。
重點 👆👆👆
如下圖所示:
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();
}
從 👆 兩個方法可以看出:
- Message 回收的時候,會插入回收池列表的第一個元素
- 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
}
}
}
2. 針對 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、參考資料與學習資源推薦
更多資料推薦:https://shimo.im/docs/VYcc3wyJRpy9jJ83/
粉絲裙:
由於本人水平有限,可能出於誤解或者筆誤難免出錯,如果發現有問題或者對文中內容存在疑問請在下面評論區告訴我,謝謝!