Message 引起的 DialogFragment 內存泄漏分析與解決方案

Q:咋回事?正常使用 Dialog 和 DialogFragment 也有可能會致使內存泄漏?java

A: ....是的,說來話長。git

長話短說:github

  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 內存,分析引用鏈,報告內存泄漏問題。

具體細節介紹見下文👇bash

一、問題

開發的時候, LeakCanary 報告了一個詭異的內存泄漏鏈。app

操做路徑:app 顯示 DialogFragment 而後點擊外部使其消失,以後 LeakCanary 就報了以下問題:ide

LeakCanary 報告的異常

從上面的截圖 👆 能夠看出: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

  • 首先看 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.一、 驗證

我們能夠寫一段相似的代碼,而後用 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.二、復現

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.三、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.一、系統側

  • Android 官方消息機制中, Java 層的代碼中應該在 Looper#loop 方法的末尾,將 msg 變量置爲 null。
  • ART、Dalvik 中當引用變量無效時,能夠將對應的 slot 置爲 null

3.二、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)
複製代碼

四、參考資料與學習資源推薦

因爲本人水平有限,可能出於誤解或者筆誤不免出錯,若是發現有問題或者對文中內容存在疑問請在下面評論區告訴我,謝謝!

相關文章
相關標籤/搜索