工具與成效

使用 Firebase Crashlytics 排解記憶體問題

喇叭

  • Polina Govorkova

    資深軟體開發人員

關於

講座簡介

使用 Firebase Crashlytics,讓應用程式的記憶體用量保持精簡,並避免發生區段錯誤。本課程將介紹 Firebase 控制台和 Android Studio 提供的全套記憶體疑難排解工具。首先,我們會說明如何運用 ANR 深入分析和 GWP-ASan 報告,診斷難以察覺的記憶體錯誤。接著,我們會深入介紹最新的記憶體不足 (OOM) 和記憶體限制終止 (MLK) 事件工具,協助您因應 Android 17 的新記憶體限制。

摘要

  • 「應用程式無回應」錯誤通常源自背景瓶頸或記憶體耗盡,而非活躍的 CPU 負載。Firebase Crashlytics 會查詢 ApplicationExitInfo,找出多執行緒追蹤記錄和造成阻礙的根源執行緒。
  • 當應用程式超出執行階段 Java 堆積限制時,就會發生記憶體不足當機。Crashlytics 與 Android 17 的 ProfilingManager 整合,可收集去模糊化的堆積快照,並在 Perfetto 或透過 Android CLI 檢查。
  • Android 17 的記憶體限制器會終止耗用過多 RAM 總量 (包括原生分配) 的個別程序。Crashlytics 會在資訊主頁上將這些終止事件與記憶體快照資料分組,協助區分 C++ 記憶體流失和代管堆積分配量。
  • 原生記憶體毀損通常會出現在系統執行緒上,而非應用程式程式碼中。Crashlytics 支援 GWP-ASan 和 Arm MTE,而 GWP-ASan 報告會指出確切的配置和取消配置位置,方便您找出 UAF 等錯誤。

資源

Gemini Notebook

主題

  • firebase-crashlytics
  • android-memory
  • anr
  • 記憶體不足
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

轉錄稿

簡介

  • Polina Govorkova

    大家好。

  • 歡迎使用 Firebase Crashlytics 進行記憶體疑難排解。

  • 等等,這職稱真有企業感。

  • 我們來稍微調整一下。

  • 使用 Firebase Crashlytics 讓應用程式保持精簡高效。

  • 聽起來好多了。

  • 我是 Polina,Firebase Crashlytics 團隊的資深軟體開發人員,也是今天的主講人。

  • 在開發週期的某個階段,您會覺得自己已解決所有錯誤。

  • JSON 剖析錯字、忘記檢查空值指標,這些問題都將不復存在!

  • 您可能會想:「我完成了嗎?」

  • 很遺憾,你並非如此。

  • 因為接下來要面對的,其實是更棘手的問題。

  • 我認為,您將面臨的兩大難題是回應能力和記憶體。

  • 好消息是,我們能為您提供協助。

  • 今天,我們將逐步介紹 Android 和 Crashlytics 現有及全新的工具,協助您找出回應問題的根本原因,包括「應用程式無回應」錯誤、記憶體不足例外狀況、記憶體流失和原生記憶體毀損。

  • 我來進一步說明

  • 為什麼回應速度和記憶體問題這麼棘手?

  • 舉個經典錯誤為例:您宣告一個字串,將其設為空值,然後在某個時間點嘗試讀取其長度。

  • 您會獲得什麼?

  • NullPointerException。

  • 但實際上,這裡的生活很簡單,因為例外狀況堆疊追蹤會指向破壞應用程式的確切程式碼行。

  • 請查看左側的堆疊追蹤。

    java.lang.NullPointerException
      at MainActivity.onCreate(MainActivity.kt:42)
      at android.app.Activity.performCreate
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • 我們馬上就能看出問題出在 MainActivity.kt 的第 42 行。

  • 這種做法適用於大多數不重要的錯誤。

  • 現在,當發生回應速度或記憶體問題時,例如右側的「應用程式無回應」問題,堆疊追蹤幾乎不會指向真正的問題來源。

    "main" prio=5 tid=1 Blocked
      | group="main" sCount=1 dsCount=0 flags=1
      | state=S schedstat=( 1450239 48192 145 )
      at DataManager.getData(DataManager.kt:25)
      - waiting to lock <0x0a1b> held by thread 12
      at MainActivity.onResume(MainActivity.kt:50)
      at android.app.Activity.performResume
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • 在最理想的情況下,這項工具只會顯示效果,也就是在應用程式凍結或記憶體不足時,剛好執行的無害執行緒或分配。

  • 但真正的根本原因可能發生在更早之前,或完全在其他地方。

  • 幸好,遇到這類問題時,你不必瞎猜,因為 Firebase Crashlytics 可以提供解答。

  • 不過,在開始疑難排解之前,如果你熟悉 Crashlytics,請舉手。

  • 太好了。

  • 很高興看到這麼多人舉手。

  • 首先,為新朋友介紹一下,Firebase Crashlytics 是即時錯誤回報解決方案,可提供現場資料、情境和 AI 輔助洞察、疑難排解提示,以及新版本的明確信號,協助您更快修正問題。

  • Crashlytics 廣受開發人員喜愛,是行動裝置上最受歡迎的當機回報解決方案,支援超過 37 萬個原生應用程式,包括 57% 的熱門 Android 應用程式。

  • 為了瞭解 Crashlytics 如何協助我們追蹤錯誤,我們來看一個具體範例。

  • 今天,我想和大家分享 ChromaClip 的故事。

  • 這是我正在開發的影片調色盤擷取工具,我想分享在開發過程中,如何解決回應能力和記憶體錯誤。

  • ChromaClip 的概念很簡單:使用者挑選影片 (例如美麗的日落片段或色彩分級出色的場景),應用程式就會分析影格,產生自訂調色盤,供使用者用於設計、數位藝術、情緒板等。

  • 故事要從 1.0 版推出前說起。

  • 我想確保應用程式在推出前能穩定運作,因此在幾部裝置上進行最終測試,包括挑選影片、輕觸按鈕擷取調色盤,結果就出現了所有 Android 開發人員最愛的對話方塊,真是太幸運了。

  • ChromaClip 沒有回應。

  • 這就是「應用程式無回應」錯誤,也稱為 ANR。

偵錯及分析 ANR

  • Polina Govorkova

    那麼,除了驚慌失措之外,我們還能做些什麼?

  • 測試手機已直接插入筆電,因此我們首先開啟 Android Studio 並查看 Logcat。

  • 我們可以看到主執行緒的堆疊追蹤。

    14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
        PID: 14205
        Reason: Input dispatching timed out
        Dumped ANR trace to /data/anr/traces.txt
    "main" prio=5 tid=1 Native
      | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
      | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
      | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
      | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
      at sun.misc.Unsafe.park(Native method)
      - waiting on an unknown object
      at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
      at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
      at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
      at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
      at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
      at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
  • 這代表什麼?

  • 首先,在最上方,我們確認這是在 MainActivity 中執行的 ANR。

  • 還有呢?

  • 我們可以看到原因,也就是輸入調度逾時。

  • 這表示使用者輕觸了螢幕。

  • 沒錯。

  • 但主要執行緒太忙碌,無法在 Android 的 5 秒監控逾時時間內處理該事件。

  • 接著,我們也會看到系統將檔案傾印到 traces.txt,而 logcat 會在下方列印主執行緒的堆疊追蹤。

  • 請注意,狀態等於 S。

  • 主執行緒甚至沒有主動消耗 CPU。

  • S 代表睡眠。

  • 為什麼主執行緒在等待時會處於休眠狀態?

  • 我們來仔細看看堆疊框架。

  • 在追蹤記錄的中間,我們可以看到主執行緒停在 Unsafe.park 內,等待不明物件。

  • 為什麼我們不知道它在等什麼?

  • 事實上,對於某些 ANR (例如執行緒遭到傳統 Java 同步鎖定封鎖時),主要執行緒堆疊追蹤會提供相關資訊,指出執行緒正在等待什麼,以及鎖定是由什麼保留。

  • 不過,如果您在幕後使用較高層級的並行原始物件,Logcat 只會顯示我們正在等待未知的物件。

  • 因此,我們不知道正在等待哪個物件、哪個背景工作站執行緒,或為何需要超過 5 秒。

  • 由於我們是在本機測試,因此可以從裝置提取 traces.txt,手動捲動瀏覽數十個休眠執行緒,找出問題所在,或使用代幣向 AI 尋求協助。

  • 在某些情況下,這種做法確實可行,而且實際上也適用於這個案例。

  • 不過,我真的不想手動讀取原始執行緒轉儲,老實說,這種做法太過時了。而且我知道,一旦進入正式環境,這種做法根本不可行。

  • 我們只是無法直接存取使用者手機中的 traces.txt 檔案,對吧?

  • 幸好我們整合了 Firebase Crashlytics。

  • 因為您不必再搜尋,即使在正式環境中,也能找出 ANR 的根本原因。

  • 做法:

  • 這時 Android 的 ApplicationExitInfo API 就能派上用場。

  • 自 Android 11 起,每當系統標記 ANR 或終止應用程式程序時,OS 都會記錄豐富的結束中繼資料,包括當機的確切原因、記憶體狀態,以及程序中每個執行緒在當下的完整系統追蹤記錄。

  • 最棒的是,您不必安裝任何額外外掛程式,也不用編寫任何一行程式碼。

  • 只要使用 Crashlytics SDK 18.3.5 以上版本,就能直接使用這項功能。

  • 因此,應用程式重新啟動後,Crashlytics SDK 會自動查詢 ApplicationExitInfo,擷取完整的裝置遙測多執行緒轉儲,並傳送至 Crashlytics 後端。

  • 我們開啟 Crashlytics 控制台,看看會顯示什麼資訊。

  • 這是 Firebase Crashlytics 控制台的 ANR 問題詳細資料檢視畫面,我們可以在「堆疊追蹤」分頁下方看到 DefaultDispatcher worker one。

    DefaultDispatcher-worker-1 (Root blocking)
    
    dalvik.system.VMRuntime.allocateNode (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
    com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
    com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
    kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
    kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
    java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
    java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
    java.lang.Thread.run (Thread.java:923)
  • 事實上,Crashlytics 在最上方顯示的這個執行緒,就是主執行緒的瓶頸,Crashlytics 會用「Root blocking」標記。

  • 如徽章上的工具提示所述,這個背景工作執行緒確實是阻擋觸發 ANR 執行緒的執行緒。

  • 順帶一提,Crashlytics 也會為其他問題來源提供智慧徽章。

  • 舉例來說,如果執行緒卡在讀取大型檔案或執行速度極慢的資料庫查詢,且觸發 ANR,就會標記為 I/O 根封鎖。

  • 如果兩個執行緒互相等待,Crashlytics 會將其標記為死結。

  • 現在,我們來看看根層級封鎖執行緒中的堆疊框架。

  • 因此,PaletteExtractor processVideo 呼叫 FrameDecoder getUncompressedFrame (以藍色醒目顯示),而該呼叫完全卡在 createBitmap 內,只為了分配單一影格的記憶體。

  • 我們知道它並非在等待其他鎖定,也沒有在進行網路呼叫,因此主要懷疑對象應該是記憶體,而我們實際上可以立即確認,只要在 Crashlytics 控制台中檢查裝置的可用記憶體即可。

  • Crashlytics 會在兩個地方顯示這項資訊。

  • 在特定事件的資料分頁中,就在我們剛才查看的堆疊追蹤分頁旁邊,以及右上角顯示所有事件的平均值。

  • 在這兩種情況下,天啊,看起來幾乎沒有任何內容,甚至不到 100 KB。

  • 我們來想想

  • 這是因為程序缺乏記憶體,Android 執行階段花費所有時間瘋狂執行垃圾收集,試圖為下一個點陣圖找到足夠的空間,但始終找不到。

  • 凍結背景工作執行緒,並讓 UI 飢餓超過五秒。

  • 在此提供一個訣竅:

  • 在您取得,每當您取得 ANR 搭配這類低可用 RAM 時,這就是 ANR 實際上是記憶體流失或膨脹問題的確鑿證據。

  • 現在的問題是,我們的應用程式是受害者,還是也是囤積記憶體的應用程式?

  • 現在回到專案的問題清單,看看是否有其他相關問題。

調查記憶體不足例外狀況

  • Polina Govorkova

    看這裡。

  • 在底部,我們可以看到問題清單,在有八個事件的 ANR 上方,我們有 OutOfMemory 例外狀況。

  • 所以,我們可能才是罪魁禍首。

  • 這其實是個重要線索。

  • 因為在 Android 上,Java OutOfMemoryError (簡稱 OOM) 不僅代表手機 RAM 不足,

  • 這表示應用程式程序超過 Android 執行階段 Java 堆積限制。

  • 因此,目前我的工作理論是,ANR 和 OOM 實際上是一體兩面。

  • 視測試執行的時間而定,ChromaClip 可能會卡在垃圾回收中,導致 ANR 觸發,或完全耗盡堆積,導致 OOM。

  • 我們來調查 OutOfMemory 例外狀況,確認原因。

  • 首先是堆疊追蹤。

    java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
     at android.graphics.Bitmap.createBitmap
     at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
     at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
  • 稍等一下。

  • Bitmap.createBitmap,這聽起來很熟悉。

  • 我們之前不是看過那個嗎?

  • 不過,請注意這次是從哪裡呼叫。

  • 開啟設定活動時,無法分配 256 個位元組。

  • 我們又掉進同一個陷阱了。

  • 如果應用程式因無法加載微小的設定圖示而當機,設定活動並非罪魁禍首。

  • 只是呼叫端運氣不好,在沒有記憶體時要求記憶體。

  • 因此,為了找出真正的罪魁禍首,我們需要縮小範圍,查看當機時記憶體中的所有內容。

  • 換句話說,我們需要 Java 堆積轉儲。

  • 在辦公桌前,如果您知道如何觸發 OOM,可以使用 Android Studio 記憶體分析器,手動擷取 Java 記憶體快照資料。

  • 不過,如果 OOM 發生在測試執行期間或實際環境中,且無法預測,這種方法就無法發揮作用。

擷取及分析記憶體快照資料

  • Polina Govorkova

    這又是 Firebase Crashlytics 的一大優點。

  • 除了應用程式結束資訊外,Crashlytics 現在也整合了 Android 的 ProfilingManager API。

  • Android 17 的 ProfilingManager API 強化了系統觸發的記憶體分析功能,這正是我們需要的。

  • 也就是說,當應用程式發生 OutOfMemory 錯誤或超出系統記憶體限制時,OS 會在背景擷取堆積快照,然後在 Crashlytics 中,只要使用 Crashlytics SDK 20.1.0 以上版本,就能免費使用觸發的收集功能。

  • 接著,Crashlytics 會自動將 OOM 和記憶體限制器終止事件分組,並顯示在資訊主頁中,我們稍後會再詳細說明。

  • 透過 Firebase 控制台或 CLI 選擇加入記憶體快照資料收集功能後,Crashlytics 就會自動收集擷取的堆積快照,並為您去模糊化。基本上,您會取得當機時應用程式 Java 堆積中所有內容的完整 X 光圖。

  • 擷取堆積後,ChromaClip OOM 在 Crashlytics 控制台中的顯示方式如下。

  • 頂端會說明 Java 耗盡堆積的原因,以及發生這種情況的原因。

  • 接著,您也會看到實用的疑難排解提示和外部資源指標。

  • 在堆疊追蹤分頁中,Crashlytics 實際上會在誤導性設定活動上方標記 OOM 堆疊追蹤不具確定性,這樣使用者就不必像我們剛才那樣浪費時間查看堆疊追蹤。

  • 接著,Crashlytics 會附加該工作階段的反模糊處理 Perfetto 檔案 (記憶體快照資料)。

  • 我們可以在這裡下載檔案。

  • 我們可以在 Google Cloud Storage 中瀏覽所有傾印,也可以在 Perfetto UI 中開啟。

  • 因此,在 Perfetto UI 中點選「Analyze」會開啟 Perfetto 記憶體快照資料探索工具。

  • Now, raise your hand if you have seen this one already.

  • 好,有幾個人舉手。

  • 對我來說,這個檢視畫面非常嚇人,但在這個例子中,問題其實相當簡單。

  • 請查看幾乎橫跨整個 Java 堆積的一組大型長條。

  • 因此,這基本上是配對物件的陣列,每個物件都包含一個顏色和一些字串例項。

  • 噢!

  • 我想我其實知道發生什麼事。

  • 我們的程式碼會為每個影格中的每個像素分配顏色和一些支援資料,並將數百萬個像素保留在清單中。

  • 因此,設定活動無法分配這 256 個位元組。

  • 至少解開了一個謎團。

  • 我們來解決這個問題。

  • 此時,我們有幾個選項。

  • Perfetto 已指出 PaletteExtractor 是問題所在,因此我們可以開啟 IDE,手動重寫迴圈,或使用 AI 程式設計代理。

  • 或者,我們也可以完全略過手動堆積檢查,並利用 Android CLI。

    # 1. Install Android CLI & Perfetto skills
    $ android skills add android-cli perfetto
    
    # 2. List recent heap dumps from Crashlytics
    $ android signals heaps list --kind oom --timeframe=90d
    
    # 3. Download a heap dump as a Perfetto trace
    $ android signals heaps download <heap-dump-id> \
        --output ./heaps
    
    # 4. Query top retained objects in the heap dump
    $ trace_processor query ./heaps/oom.perfetto-trace ...
    
    # 5. Fetch matching Crashlytics events
    $ android signals heaps describe <heap-dump-id>
  • 因此,只要安裝 Android CLI 和 Perfetto 技能,就能列出及下載去模糊化的 Crashlytics 記憶體快照資料、透過 trace_processor 查詢這些記憶體快照資料中保留最多的物件,以及擷取相符的 Crashlytics 問題資料,例如自訂金鑰、記錄、導覽標記、相關推出作業等,全部都能在終端機中完成。

  • 更棒的是,請查看投影片底部。

  • 請勿自行執行這項指令。

  • 再次使用自然語言,要求您常用的 AI 程式設計代理,透過 Android 指令檢查最新的 OOM 記憶體快照資料,並提供修正程式。

    # Or ask your favorite LLM in natural language
    > Using `android` command, inspect the latest heap dump
      for my OOM issue and propose a fix
    ● Ran android signals heaps list · download · describe
    ● Analyzed ./heaps/oom.perfetto-trace (perfetto)
    ● Updated PaletteExtractor.kt (+6 -4 lines)
  • 並有足夠的背景資訊,可為您完成整個流程。

  • 也就是列出 OOM、下載追蹤記錄、執行分析,以及直接在專案中生成程式碼修補程式。

  • 接著,請查看 PaletteExtractor 檔案中的這個修補程式。

    // PaletteExtractor.kt
    fun extractPalette(framePixels: IntArray): SparseIntArray {
    -   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
    -   for (px in framePixels) {
    -       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
    -   }
    
    +   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
    +   for (i in framePixels.indices step 16) {
    +       val argb = framePixels[i]
    +       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
    +   }
    +   return colorHistogram
    }
  • 現在我們只需對影格進行降採樣,並直接計算原始整數,而不必分配數百萬個物件。

  • 因此,我們來套用修正、關閉問題、提高版本,並繼續測試。

  • 希望記憶體不足錯誤會消失,而且由於我們不會再頻繁執行垃圾收集,希望討厭的 ANR 也會消失。

  • 因此,我們在測試裝置上部署修補程式版本,挑選新的短片,並開始擷取調色盤。

  • UI 再次凍結。

  • 又一個 ANR。

  • 有趣的是,經過更多測試後,我們發現有些裝置仍會發生 ANR,但其他裝置上的應用程式現在會隨機當機,而且頻率相當高,比發生 OOM 的頻率還要高。

  • 但我們已修正 OOM,所以究竟發生了什麼事?

調查原生記憶體問題

  • Polina Govorkova

    我們回到 Crashlytics。

  • 先說好消息。

  • 查看問題清單。

  • 記憶體不足例外狀況已完全消失,符合預期。

  • 壞消息是,

  • 現在 ChromaClip 存活時間稍微變長,有時間可以找出底下還藏著什麼。

  • 因此 ANR 並未消失。

  • 現在,底部有兩個原生程式碼發生錯誤,幸好只有幾個事件。

  • 最上方有 11 個事件,是記憶體限制器終止事件,幸好已標記為有記憶體快照資料。

  • 等等。

  • 什麼是記憶體限制器終止?

  • 聽過的人可以舉手嗎?

  • 現在我們來做個小測驗

  • 少數稀有牌型。

  • 一起來探索,

  • 如果您從未看過記憶體限制器終止程序,那是因為這是 Android 17 的全新功能。

  • 只有在超過受管理的 Java 堆積限制時,才會觸發 Java OOM。不過,如果應用程式的總記憶體用量也包含原生 RAM (例如圖形緩衝區和 C++ 配置),則先前沒有個別應用程式的上限。

  • 因此,我們會有全域的記憶體不足終止工具。

  • 當某個應用程式耗盡系統 RAM,導致整支手機卡住時,這個機制就會介入。

  • 從 Android 17 開始,記憶體限制器會限制每個應用程式的記憶體用量。

  • 因此,當程序耗用過多 RAM 時,OS 就會終止該應用程式。

  • Crashlytics 會擷取所有詳細資料,並在資訊主頁中將終止事件分組。

  • 因此,在 Crashlytics 中點選「Memory Limiter kill」,就像 OOM 一樣,您會看到 OS 介入的原因,所以不需要記住我剛才說的話。

  • 您也會看到後續處理提示,以及 Android 記憶體強制執行文件的連結。

  • 就像 OOM 一樣,當 OS 終止程序時,堆疊追蹤會將責任歸咎於無辜的影格,而 Crashlytics 資訊主頁會醒目顯示該影格。

  • 現在,記憶體限制器終止作業主要發生在原生程式碼中,因為原生環境缺少處理記憶體安全性和分配的層。

  • 否則就會發生 OOM。

  • 因此,第一個問題是,Kotlin 物件是否保留大量原生支援的分配?

  • 還是原始 C++ 程式碼本身發生洩漏?

  • 在 Perfetto UI 中點選「Analyze」,一探究竟。

  • 查看 Perfetto 堆積轉儲探索工具中的「Overview」分頁。

  • 在一般資訊中,匿名 RSS 加上交換空間顯示程序記憶體總量達到 4 GB。

  • 好的。

  • 不過,在下方「Bytes Retained by Heap」部分,我們可以看到整個受管理堆積 (包括與 Kotlin 物件 (例如點陣圖) 相關聯的原生記憶體) 只有 42 MB。

  • 好的。

  • 因此,我們在 10 秒內就排除了所有 Kotlin 和 Java 程式碼。

  • 因此,問題一定出在原生 C++ 層。

  • ChromaClip 有一個用於影格處理的薄 C++ 層,因此我們不必費心尋找。

  • 將程式設計代理或 Android Studio 的原生分析器指向 NativePaletteEngine.cpp processFrame,就能找到錯誤。

    // NativePaletteEngine.cpp
    void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
        uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
        transformColorSpace(rgba, scratch, size);
    
        // BUG: Allocated every frame, but never freed!
    +   delete[] scratch; // Free native buffer when done
    }
  • 因此,在每個影片影格上,我們的 C++ 程式碼都會分配一個暫時的暫存緩衝區,如果不釋放,就不會是暫時的,這真是新手錯誤。

  • 現在,我們來整合各項資訊。

  • 狀況說明

  • 修正 Java 洩漏問題後,C++ 洩漏問題就會有時間累積並失控。

  • 在 Android 17 上,記憶體限制器偵測到這個問題,並終止了應用程式。

  • 但在尚未推出記憶體限制器的舊裝置上,這類問題實際上會以 ANR 的形式呈現。

  • 所以,最後,一切都說得通了。

  • 我們在這裡新增 delete[] scratch,提高版本號碼,然後推送更新。

  • 記憶體限制器終止和 ANR 問題終於解決,我們應該可以發布 1.0 版了。

  • 令人興奮。

  • ChromaClip 現在速度飛快且穩定,應用程式也因此大受歡迎。

  • 每天都有數千人上傳及重混影片。

  • 但隨著流量增加,還記得我們稍早在資訊主頁底部看到的那些神秘原生程式碼發生錯誤嗎?

  • 他們也四處亂竄。

  • 突然間,數量變得更多了。

  • 我們的工作尚未完成。

調查原生記憶體毀損當機

  • Polina Govorkova

    因此,在 Crashlytics 中開啟原生程式碼發生錯誤,查看堆疊追蹤。

  • 我們自己的應用程式中沒有任何一行程式碼。

  • RenderThread 在釋放記憶體時,於 libc.so 內部深處當機。

  • 另外,如果系統執行緒在記憶體配置器中當機,且不含任何應用程式程式碼,這就是原生記憶體損毀的典型跡象。

  • 因此,我們的 C++ 程式碼提早損毀或釋放緩衝區,而轉譯執行緒稍後才發生錯誤。

  • 那麼,我們該如何在資料損毀的當下,找出真正的罪魁禍首?

  • Android 提供兩項強大工具,Crashlytics 則支援這兩項工具,開箱即用。

  • 首先是 GWP-ASan,這是專為正式環境建構的輕量安全網。

  • 它會對原生堆積分配量的一小部分進行取樣,並在周圍加上防護頁面,因此 CPU 和記憶體負擔幾乎為零。

  • 在實際環境中,當錯誤在取樣分配中觸及已釋放或超出範圍的記憶體時,GWP-ASan 會立即偵測到。

  • 不過,由於 GWP-ASan 本質上是機率性的,因此在本機測試時,您不會想讓測試手機執行 16 小時以上,只為了命中隨機樣本。

  • 那麼,您會得到什麼呢?

  • 如要進行確定性本機偵錯,可以使用 ARM MTE (Memory Tagging Extension)。

  • MTE 會使用硬體指標和記憶體標記檢查每項分配,適用於 Pixel 8 和後續機型等 ARM v9 裝置。

  • 因此,在正式環境中搭配使用 GWP-ASan,就能兼顧兩者的優點。

  • 如您所料,當任一工具偵測到錯誤時,Crashlytics 會自動為問題加上徽章,並讓您依 GWP-ASan 或 MTE 篩選,並在資訊主頁中固定顯示資訊卡,指出確切的損毀原因。

  • 接著,來看看 GWP-ASan 偵測到當機時,Crashlytics 控制台會顯示什麼內容。

  • 一般堆疊追蹤分頁旁邊是記憶體堆疊追蹤。

  • 頂端則會顯示確切的損毀原因:釋放後使用。

  • GWP-ASan 不會顯示 libc.so 中轉譯執行緒當機,而是提供 effect_pipeline.cc 內該緩衝區的完整記錄。

  • 因此,在「Allocated By」下方,我們可以看到緩衝區的來源。

  • 在「Deallocated By」下方,我們可以看到確切的罪魁禍首,就是它從轉譯執行緒中釋放了這個物件。

  • 這項設定隱藏在特效管道的第 208 行。

  • 因此,我們在 EffectPipeline 中開啟原生程式碼,然後發現錯誤。

    // effect_pipeline.cc
    void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
        // BUG: Freed on worker thread while RenderThread is still reading it!
    +   std::lock_guard<std::mutex> lock(lut_mutex_);
    
        releaseLutBuffer();       // Frees old color-grading LUT
        loadFilterPreset(preset); // Allocates new color-grading LUT
    }
  • 每當使用者在背景執行緒中切換色彩濾鏡時,系統就會釋出舊緩衝區,而 RenderThread 仍會讀取該緩衝區。

  • 因此,我們在緩衝區交換作業中加入互斥鎖,並發布更新,最後發現 GWP-ASan 報告和所有神秘當機問題幾乎在一夜之間消失。

  • 未遇到當機情形的使用者指標也大幅提升至 100%。

  • 了不起!

  • 還記得我們在講座開始時問自己的問題嗎?「我完成了嗎?」

  • 但隨即就會遇到各種回應能力和記憶體問題。

  • ANR、記憶體不足錯誤、記憶體限制器終止,以及原生記憶體損毀當機等問題,現在都已解決。

  • 即使堆疊追蹤不準確,且問題隱藏在 C++、Android 和 Firebase Crashlytics 的執行緒中,您也可以使用工具追蹤問題,並安心推出應用程式。

摘要和搶先體驗註冊

  • Polina Govorkova

    最後,我們來複習一下,您回到電腦前可以立即使用的功能:透過 ApplicationExitInfo 取得 ANR 執行緒深入分析和其他相關中繼資料;透過 ProfilingManager 自動收集 OOM 和記憶體限制器終止觸發條件;以及完整支援 GWP-ASan 和 Arm MTE,可偵測原生記憶體毀損。

  • 此外,我們也將推出不公開預先發布版,可在 Android 17 以上版本執行現場記憶體快照資料。

  • 如果想搶先體驗 Firebase Crashlytics 的自動記憶體快照資料收集功能,並使用 Perfetto 和 Android CLI 進行分析,請快速拍下投影片,並透過連結註冊。

  • 現在我們已經講完大部分的內容,我想感謝大家今天撥冗參與。

  • 祝您主執行緒暢通無阻、堆積乾淨,下一個版本穩定。

  • 祝您偵錯順利!

  • 再次感謝。

程式碼

程式碼片段

「簡介」中 01:45 的文字片段
java.lang.NullPointerException
  at MainActivity.onCreate(MainActivity.kt:42)
  at android.app.Activity.performCreate
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
「簡介」中 01:58 的文字片段
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1
  | state=S schedstat=( 1450239 48192 145 )
  at DataManager.getData(DataManager.kt:25)
  - waiting to lock <0x0a1b> held by thread 12
  at MainActivity.onResume(MainActivity.kt:50)
  at android.app.Activity.performResume
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
「偵錯及分析 ANR」影片中 04:45 的文字片段
14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
    PID: 14205
    Reason: Input dispatching timed out
    Dumped ANR trace to /data/anr/traces.txt
"main" prio=5 tid=1 Native
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
  | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
  | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
  | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
  at sun.misc.Unsafe.park(Native method)
  - waiting on an unknown object
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
  at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
「偵錯及分析 ANR」影片 08:08 處的文字片段
DefaultDispatcher-worker-1 (Root blocking)

dalvik.system.VMRuntime.allocateNode (Native method)
android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
java.lang.Thread.run (Thread.java:923)
「Investigating out of memory exceptions」影片 11:54 處的文字片段
java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
 at android.graphics.Bitmap.createBitmap
 at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
 at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
「擷取及分析堆積轉儲」影片 15:54 處的殼層程式碼片段
# 1. Install Android CLI & Perfetto skills
$ android skills add android-cli perfetto

# 2. List recent heap dumps from Crashlytics
$ android signals heaps list --kind oom --timeframe=90d

# 3. Download a heap dump as a Perfetto trace
$ android signals heaps download <heap-dump-id> \
    --output ./heaps

# 4. Query top retained objects in the heap dump
$ trace_processor query ./heaps/oom.perfetto-trace ...

# 5. Fetch matching Crashlytics events
$ android signals heaps describe <heap-dump-id>
「擷取及分析記憶體快照資料」影片 16:27 處的文字片段
# Or ask your favorite LLM in natural language
> Using `android` command, inspect the latest heap dump
  for my OOM issue and propose a fix
● Ran android signals heaps list · download · describe
● Analyzed ./heaps/oom.perfetto-trace (perfetto)
● Updated PaletteExtractor.kt (+6 -4 lines)
「擷取及分析記憶體快照資料」影片 16:47 處的 Kotlin 程式碼片段
// PaletteExtractor.kt
fun extractPalette(framePixels: IntArray): SparseIntArray {
-   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
-   for (px in framePixels) {
-       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
-   }

+   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
+   for (i in framePixels.indices step 16) {
+       val argb = framePixels[i]
+       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
+   }
+   return colorHistogram
}
「調查原生記憶體問題」中 21:06 處的 C++ 程式碼片段
// NativePaletteEngine.cpp
void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
    uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
    transformColorSpace(rgba, scratch, size);

    // BUG: Allocated every frame, but never freed!
+   delete[] scratch; // Free native buffer when done
}
「調查原生記憶體損毀異常終止」中 25:14 的 C++ 程式碼片段
// effect_pipeline.cc
void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
    // BUG: Freed on worker thread while RenderThread is still reading it!
+   std::lock_guard<std::mutex> lock(lut_mutex_);

    releaseLutBuffer();       // Frees old color-grading LUT
    loadFilterPreset(preset); // Allocates new color-grading LUT
}