工具和性能

使用 Firebase Crashlytics 排查内存问题

扬声器

  • Polina Govorkova

    高级软件开发者

关于

演讲简介

借助 Firebase Crashlytics,让您的应用保持精简、高效且无段错误。本讲座将探讨 Firebase 控制台和 Android Studio 中提供的全套内存问题排查工具。首先,我们将介绍如何利用 ANR 数据分析和 GWP-ASan 报告来诊断难以捉摸的内存 bug,然后深入探讨针对内存不足 (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 报告可针对释放后使用等错误精确定位分配和取消分配位置。

资源

Gemini Notebook

主题

  • firebase-crashlytics
  • android-memory
  • anr
  • 内存不足
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

转写文稿

简介

  • Polina Govorkova

    大家好

  • 欢迎使用 Firebase Crashlytics 排查内存问题。

  • 等等,这个职位名称太正式了。

  • 我们来做点不一样的。

  • 借助 Firebase Crashlytics 保持应用精简高效。

  • 哦,听起来好多了。

  • 我叫 Polina,是 Firebase Crashlytics 团队的高级软件开发者,也是今天的主讲人。

  • 在开发周期的某个阶段,您会觉得已经解决了所有 bug。

  • 那些 JSON 解析拼写错误、被遗忘的空指针检查,统统消失了!

  • 您可能会问自己:“我完成了吗?”

  • 好吧,我有个坏消息要告诉你,因为你不是。

  • 因为剩下的实际上都是难题。

  • 今天,我敢说,您将面临的两类最棘手的问题是响应能力和内存。

  • 好消息是,我们是您的后盾。

  • 今天,我们将介绍 Android 和 Crashlytics 中现有和全新的工具,帮助您找出导致应用无响应错误、内存不足异常、内存泄漏和原生内存损坏的响应能力问题的根本原因。

  • 下面我们就来深入探讨一下

  • 为什么响应能力和内存问题如此臭名昭著?

  • 以一个经典 bug 为例:您声明了一个字符串,将其设置为 null,然后在某个时刻尝试读取其长度。

  • 您能获得什么?

  • 一个常见的 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 行。

  • 对于大多数微不足道的 bug,这种方法都适用。

  • 现在,当出现响应速度或内存问题时,例如右侧的“应用无响应”问题,堆栈轨迹几乎永远不会指向真正的罪魁祸首。

    "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 如何帮助我们跟踪实际运行中的 bug,我们来看一个具体示例。

  • 今天,我想和大家分享 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)
  • 这到底说明了什么?

  • 首先,在最上方,我们确认了这是一个 ANR,发生在 MainActivity 中。

  • 还有什么?

  • 我们看到了原因,即输入分发超时。

  • 这意味着用户点按了屏幕。

  • 是的,我们做到了。

  • 但主线程太忙,无法在 Android 的 5 秒看门狗超时内处理该事件。

  • 然后,我们还会看到系统将文件转储到了 traces.txt 中,紧接着,logcat 会输出主线程的堆栈轨迹。

  • 请注意,这里的 state 等于 S。

  • 我们的主线程甚至没有主动消耗 CPU。

  • S 代表睡眠。

  • 为什么主线程在等待时处于休眠状态?

  • 我们来仔细看看堆栈帧。

  • 在跟踪记录中间,我们可以看到主线程停在 Unsafe.park 内部,等待未知对象。

  • 为什么我们不知道它在等待什么?

  • 确实,对于某些 ANR(例如当线程被经典 Java 同步锁阻塞时),主线程堆栈轨迹会非常贴心地告诉我们它实际上在等待什么,以及是什么在持有锁。

  • 但如果您在后台使用更高级别的并发原语,Logcat 只会告诉我们,我们正在等待一个未知对象。

  • 因此,我们不知道正在等待哪个对象、具体在等待哪个后台工作器线程,或者为什么需要 5 秒以上的时间。

  • 由于我们是在本地进行测试,因此可以从设备中提取该 traces.txt,手动滚动浏览数十个休眠线程,并可能找出罪魁祸首,或者使用 AI 询问。

  • 在某些情况下,这种方法确实有效,事实上,对于本例,它也有效。

  • 但我不仅不想手动读取原始线程转储(说实话,这种做法太落伍了),而且我知道,一旦进入生产环境,这种方法根本行不通。

  • 我们只是无法直接访问用户手机中的 traces.txt 文件,对吧?

  • 幸好我们集成了 Firebase Crashlytics。

  • 因为这样就无需搜索,即使在生产环境中也能查明 ANR 的根本原因。

  • 要怎么做呢?

  • 这时,Android 的 ApplicationExitInfo API 就可以派上用场了。

  • 从 Android 11 开始,每当系统标记 ANR 或终止应用进程时,操作系统都会记录丰富的退出元数据,包括崩溃的确切原因、内存状态以及当时在进程中运行的每个线程的完整系统跟踪记录。

  • 最棒的是,您无需安装任何额外的插件,也无需编写任何代码。

  • 只要您使用的是 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 运行时花费了所有时间疯狂地运行垃圾回收,试图为下一个位图找到足够的空间,但始终没有找到。

  • 冻结工作器线程,并使界面处于饥饿状态超过 5 秒。

  • 因此,我们为您提供了一个小技巧。

  • 在您遇到 ANR 之前,只要您遇到 ANR 且可用 RAM 较低,这就是 ANR 实际上是内存泄漏或膨胀问题的确凿证据。

  • 现在,问题是我们的应用是受害者,还是它也是占用内存的应用?

  • 现在,让我们回到项目的问题列表,看看是否有其他相关问题。

调查内存不足异常

  • Polina Govorkova

    看这里。

  • 在底部,我们可以看到问题列表,在包含 8 个事件的 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,这个名字很熟悉。

  • 我们之前不是见过那个吗?

  • 不过,这次要看看是从哪里调用的。

  • 打开设置 activity 时未能分配 256 字节。

  • 我们又掉进了同一个陷阱。

  • 如果您的应用因无法扩大小小的设置图标而崩溃,那么设置 activity 并不是罪魁祸首。

  • 只是因为调用方运气不好,在没有内存时请求了一点内存。

  • 因此,为了找到真正的罪魁祸首,我们需要放大视野,查看崩溃发生时内存中的所有内容。

  • 换句话说,我们需要 Java 堆转储。

  • 在办公桌前,如果您知道如何触发 OOM,可以使用 Android Studio 内存分析器手动获取 Java 堆转储。

  • 但是,当 OOM 无法预测地发生时,无论是在测试运行期间还是在实际使用中,这种方法都无法奏效。

捕获和分析堆转储

  • Polina Govorkova

    因此,这是 Firebase Crashlytics 的又一个高光时刻。

  • 除了应用退出信息之外,Crashlytics 现在还与 Android 的 ProfilingManager API 集成。

  • Android 17 中的 ProfilingManager API 经过了强化,支持系统触发的内存性能分析,这正是我们现在所需要的。

  • 这意味着,当您的应用遇到 OutOfMemory 错误或超出系统内存限制时,操作系统会在后台捕获堆快照,然后,只要您使用的是 Crashlytics SDK 20.1.0 或更高版本,Crashlytics 就会自动触发收集。

  • 然后,Crashlytics 会自动将 OOM 和内存限制器终止事件分组,我们稍后会在信息中心内详细讨论这些事件。

  • 当您通过 Firebase 控制台或 CLI 选择启用堆转储收集功能后,Crashlytics 就会开始自动收集这些捕获的堆快照,并为您进行去混淆处理。基本上,您将获得崩溃发生时应用 Java 堆中所有内容的完整 X 射线透视图。

  • 下面是捕获堆后,ChromaClip OOM 在 Crashlytics 控制台中的显示效果。

  • 在顶部,您可以看到 Java 耗尽堆的原因以及发生这种情况的原因。

  • 然后,您还会看到实用的问题排查提示和外部资源。

  • 在堆栈轨迹标签页中,就在那个误导性的设置 activity 上方,Crashlytics 实际上会标记 OOM 堆栈轨迹不确定,这样用户就不必像我们刚才那样浪费时间查看堆栈轨迹了。

  • 然后,Crashlytics 会附加该会话的去混淆 Perfetto 文件(即堆转储)。

  • 在这里,我们可以下载该文件。

  • 我们可以在 Google Cloud Storage 中浏览所有转储,也可以在 Perfetto 界面中打开转储。

  • 因此,在 Perfetto 界面中点击“Analyze”会打开 Perfetto 堆转储浏览器。

  • 现在,如果您已经看过这个,请举手。

  • 好的,有几个人举手了。

  • 对我来说,这个视图非常吓人,但在这里,问题其实很简单。

  • 查看几乎贯穿整个 Java 堆的一组巨大条形。

  • 因此,在大多数情况下,它是一个由 Pair 对象组成的数组,每个 Pair 对象都封装了一种颜色和某个字符串实例。

  • 哦!

  • 我想,我其实知道发生了什么。

  • 我们的代码会为每一帧中的每个像素分配一种颜色和一些支持数据,并将数百万个像素保存在一个列表中。

  • 因此,难怪“设置”activity 无法分配这 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 也会消失。

  • 因此,我们在测试设备上部署了补丁 build,选择了一个新片段,并开始提取调色板。

  • 界面再次卡顿。

  • 又一个 ANR。

  • 有趣的是,在进行更多测试后,我们发现,在某些设备上,我们仍然会看到 ANR,但在其他设备上,应用现在只是随机崩溃,而且非常频繁,比那些 OOM 更频繁。

  • 但我们已经修复了 OOM 问题,那么现在是什么情况?

调查原生内存问题

  • Polina Govorkova

    好了,我们回到 Crashlytics。

  • 先说好消息。

  • 查看我们的问题列表。

  • 正如我们所希望的那样,内存不足异常完全消失了。

  • 坏消息是,

  • 现在,ChromaClip 的存活时间稍微长了一些,它有时间去发现下面还隐藏着什么。

  • 因此,ANR 问题并未消失。

  • 现在,我们在底部看到了两个原生代码崩溃,幸好只有几个事件。

  • 最顶端是内存限制器终止事件,共 11 次,谢天谢地,它已经标记为有堆转储。

  • 等等。

  • 什么是内存限制器终止?

  • 请问在座的各位,有谁听说过这个概念?

  • 好了

  • 少数罕见牌型。

  • 让我们一起学习。

  • 如果您之前没有见过内存限制器终止进程,那是因为这是 Android 17 中的全新功能。

  • Java OOM 仅在您超出受管理的 Java 堆限制时触发,但当应用的总内存占用还包括原生 RAM(例如图形缓冲区和 C++ 分配)时,之前并没有针对每个应用的上限。

  • 因此,我们会有全局低内存终止守护程序。

  • 当某个应用不断消耗系统 RAM 直至整个手机卡顿时,它就会介入。

  • 现在,从 Android 17 开始,内存限制器会限制每个应用的内存占用。

  • 因此,当进程消耗的 RAM 比例过高时,操作系统会终止该特定应用。

  • Crashlytics 会捕获所有这些详细信息,并在信息中心内将终止事件分组。

  • 因此,在 Crashlytics 中点击进入内存限制器终止事件后,您会看到操作系统介入的原因说明,就像 OOM 一样,所以您无需记住我刚才说的话。

  • 您还会获得有关后续处理方式的提示,以及指向 Android 内存强制执行文档的链接。

  • 与 OOM 一样,当操作系统终止应用时,堆栈轨迹会将问题归咎于无辜的帧,而 Crashlytics 信息中心会突出显示该帧。

  • 现在,内存限制器终止主要发生在原生代码中,因为原生环境缺少处理内存安全和分配的层。

  • 否则,就会发生 OOM。

  • 因此,我们的第一个问题是,Kotlin 对象是否会保留大量原生支持的分配?

  • 还是原始 C++ 代码本身存在泄漏?

  • 我们点击“在 Perfetto 界面中分析”来看看。

  • 查看 Perfetto 堆转储浏览器中的“Overview”标签页。

  • 在常规信息下,我们看到匿名 RSS 加上交换空间显示进程总内存达到了 4 GB。

  • 好的。

  • 不过,在下方,我们看到在“堆保留的字节数”下,整个托管堆(包括与 Kotlin 对象 [如位图] 关联的原生内存)只有 42 MB。

  • 好的。

  • 因此,在 10 秒内,我们排除了所有 Kotlin 和 Java 代码。

  • 因此,罪魁祸首一定是原生 C++ 层。

  • 由于 ChromaClip 有一个用于帧处理的薄 C++ 层,因此我们不必费心寻找。

  • 将我们的编码智能体或 Android Studio 的原生性能分析器指向 NativePaletteEngine.cpp processFrame,就会发现 bug 就在那里。

    // 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 和内存开销几乎为零。

  • 在实际环境中的数千个用户会话中,当 bug 触及采样分配中的已释放内存或越界内存时,GWP-ASan 会立即捕获它。

  • 不过,由于 GWP-ASan 本质上是概率性的,因此在本地测试时,您不希望测试手机运行 16 个小时或更长时间才命中随机样本。

  • 那么,您会得到什么呢?

  • 对于确定性本地调试,您可以使用 ARM MTE(即 Memory Tagging Extension)。

  • MTE 使用硬件指针和内存标记来检查每次分配,适用于 Pixel 8 及更新型号等 ARM v9 设备。

  • 因此,在生产环境中将其与 GWP-ASan 搭配使用,您就可以兼得两者的优势。

  • 正如您所料,当任一工具捕获到 bug 时,Crashlytics 会自动为问题添加标记,让您能够按 GWP-ASan 或 MTE 进行过滤,并在信息中心内固定显示包含确切损坏原因的信息卡片。

  • 因此,我们来看看当 GWP-ASan 捕获到崩溃时,Crashlytics 控制台中会发生什么情况。

  • 在常规堆栈轨迹标签页旁边,有一个内存堆栈轨迹标签页。

  • 在顶部,我们可以看到确切的损坏原因:释放后使用。

  • 现在,GWP-ASan 不会显示 libc.so 中崩溃的渲染线程,而是会在我们自己的 effect_pipeline.cc 中提供该缓冲区的完整生命周期。

  • 因此,在“Allocated By”下,我们可以看到缓冲区的创建位置。

  • 在“Deallocated By”下方,我们可以看到导致该对象从渲染线程中释放的确切原因。

  • 它隐藏在特效流水线的第 208 行。

  • 因此,我们在 EffectPipeline 中打开原生代码,发现 bug 正在向我们“微笑”。

    // 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%。

  • 棒极了!

  • 还记得我们在演讲开始时问过自己:“我完成了吗?”

  • Right before getting hit by basically every single responsiveness and memory boss in the book.

  • 现在,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)
“调查内存不足异常”中 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 处的 Shell 代码段
# 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
}