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

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

從上面的字節碼可以看出,當一個迭代執行結束之後,首先會跳轉會循環體的第一行,上面的例子中對應的就是 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
    }
  }
}

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 賦值。
  1. 反射有點 hack,我們優先看看是否有別的方案。
  2. 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/ 

粉絲裙:

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章