Инструменты и эффективность

Устранение неполадок с памятью с помощью Firebase Crashlytics

Колонки

  • Полина Говоркова

    Старший разработчик ПО

О приложении

О выступлении

Firebase Crashlytics поможет вам оптимизировать объем потребляемой памяти и избежать ошибок сегментации. На этом занятии мы рассмотрим полный набор инструментов для устранения неполадок с памятью, доступных в консоли Firebase и Android Studio. Сначала мы расскажем, как использовать статистику ANR и отчеты GWP-ASan для диагностики трудновоспроизводимых ошибок памяти. Затем мы рассмотрим наши новейшие инструменты для событий Out-Of-Memory (OOM) и Memory Limiter Kill (MLK), разработанные специально для того, чтобы помочь вам ориентироваться в новых ограничениях памяти, введенных в Android 17, и адаптироваться к ним.

Сводка

  • Ошибки "Приложение не отвечает" часто связаны с узкими местами в фоновом режиме или нехваткой памяти, а не с активной загрузкой ЦП. Firebase Crashlytics запрашивает ApplicationExitInfo, чтобы выявить многопоточные трассировки и определить основные блокирующие потоки.
  • Сбои из-за нехватки памяти происходят, когда приложения превышают лимиты кучи Java во время выполнения. Crashlytics интегрируется с ProfilingManager в Android 17, чтобы собирать деобфусцированные снимки кучи, которые можно анализировать в Perfetto или через Android CLI.
  • Ограничитель памяти в Android 17 завершает отдельные процессы, которые потребляют слишком много ОЗУ, включая нативные выделения. Crashlytics группирует эти завершения на панели управления вместе с дампами кучи, чтобы помочь отделить утечки памяти C++ от управляемых выделений кучи.
  • Повреждение нативной памяти часто проявляется в системных потоках, а не в коде приложения. Crashlytics поддерживает GWP-ASan и Arm MTE, а отчеты GWP-ASan точно указывают на места выделения и освобождения памяти для таких ошибок, как использование после освобождения.

Ресурсы

Gemini Notebook

Темы

  • firebase-crashlytics
  • android-memory
  • anr
  • нехватка памяти
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

Расшифровка

Введение

  • Полина Говоркова

    Здравствуйте!

  • Добро пожаловать в руководство по устранению неполадок с памятью с помощью Firebase Crashlytics.

  • Подождите, что за корпоративное название.

  • Давайте немного разнообразим.

  • Как Firebase Crashlytics помогает поддерживать эффективность приложений.

  • О, так лучше.

  • Меня зовут Полина, я старший разработчик ПО в команде Firebase Crashlytics и ваш сегодняшний докладчик.

  • На определенном этапе цикла разработки вам покажется, что вы устранили все ошибки.

  • Опечатки в синтаксическом анализе JSON, забытые проверки нулевых указателей – все это в прошлом.

  • И вы можете спросить себя: "Я закончил?"

  • У меня для вас плохие новости: это не так.

  • Потому что то, что останется, и есть сложные проблемы.

  • И я утверждаю, что две категории самых сложных проблем, с которыми вы столкнетесь, будут связаны с отзывчивостью и памятью.

  • Хорошая новость: мы можем вам помочь.

  • Сегодня мы расскажем о существующих и новых инструментах в Android и Crashlytics, которые помогут вам выявлять основные причины проблем с отзывчивостью, проявляющихся в виде ошибок «Приложение не отвечает», а также исключений нехватки памяти, утечек памяти и повреждения нативной памяти.

  • Приступим!

  • Почему проблемы с отзывчивостью и памятью так известны?

  • Рассмотрим классическую ошибку: вы объявляете строку, присваиваете ей значение 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
  • Мы сразу видим, что проблема находится в строке 42 файла MainActivity.kt.

  • И этот подход верен для большинства тривиальных ошибок.

  • Теперь, когда возникает проблема с отзывчивостью или памятью, например ошибка "Приложение не отвечает", трассировка стека почти никогда не указывает на истинную причину.

    "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 – это решение для отчетов об ошибках в реальном времени, которое предоставляет данные о работе приложения, а также контекстные и ИИ-подсказки, советы по устранению неполадок и четкие сигналы о новых выпусках. Все это помогает быстрее устранять проблемы.

  • Crashlytics – это самый популярный инструмент для создания отчетов о сбоях на мобильных устройствах. Он используется в более чем 370 000 нативных приложений, включая 57 % лучших приложений для Android.

  • Чтобы понять, как Crashlytics помогает нам отслеживать ошибки в действии, давайте рассмотрим конкретный пример.

  • Сегодня я хочу рассказать вам историю ChromaClip.

  • Это экстрактор цветовой палитры видео, который я создаю, и я хочу рассказать вам, как я справился с ошибками адаптивности и памяти, пока разрабатывал его.

  • Идея ChromaClip проста: пользователи выбирают видео, например красивый закат или сцену с отличной цветокоррекцией, а приложение анализирует кадры и генерирует пользовательскую цветовую палитру, которую затем можно использовать для дизайна, цифрового искусства, мудбордов и многого другого.

  • Итак, наша история начинается прямо перед моим релизом 1.0.

  • Я хотел убедиться, что все работает идеально, поэтому проводил финальные тесты на нескольких устройствах. Я выбирал видео, нажимал кнопку, чтобы извлечь палитру, и, конечно же, видел любимое диалоговое окно каждого разработчика Android.

  • ChromaClip не отвечает.

  • Это ошибка "Приложение не отвечает", также известная как ANR.

Отладка и анализ ошибок ANR

  • Полина Говоркова

    Что же делать в такой ситуации, кроме как паниковать?

  • Поскольку тестовый телефон подключен к ноутбуку, мы открываем 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.

  • Затем мы видим, что система сбросила файл в traces.txt, а прямо под ним logcat выводит трассировку стека для нашего основного потока.

  • Обратите внимание, что состояние равно S.

  • Наш основной поток даже не использует активно ЦП.

  • S – сон.

  • Почему основной поток спит, пока мы ждем?

  • Давайте рассмотрим стековые фреймы подробнее.

  • В середине трассировки мы видим, что основной поток заблокирован в Unsafe.park и ожидает неизвестный объект.

  • Почему мы не знаем, чего оно ждет?

  • В некоторых случаях, например когда поток заблокирован классической синхронизированной блокировкой Java, трассировка стека основного потока действительно сообщает, чего он ждет и что удерживает блокировку.

  • Но когда вы используете примитивы параллелизма более высокого уровня, logcat сообщает только о том, что мы ожидаем неизвестный объект.

  • Поэтому мы не знаем, какой объект или фоновый рабочий поток мы ждем и почему это занимает больше пяти секунд.

  • Поскольку мы тестируем локально, мы можем извлечь этот traces.txt с устройства и вручную прокрутить десятки спящих потоков, чтобы найти виновника, или, возможно, потратить токены, чтобы спросить ИИ.

  • В некоторых случаях это действительно сработает, и, по правде говоря, это сработает и в данном случае.

  • Но я не только не хочу читать дампы потоков вручную (честно говоря, это так в стиле 2019 года), я знаю, что этот подход вообще нежизнеспособен, когда мы находимся в производстве.

  • У нас просто нет прямого доступа к файлу traces.txt на телефонах пользователей, верно?

  • Хорошо, что мы интегрировали Firebase Crashlytics.

  • Потому что она позволяет не тратить время на поиск и сразу понять причину ошибки ANR даже в рабочей версии

  • Как?

  • Для этого в Android есть ApplicationExitInfo API.

  • Начиная с Android 11, когда система отмечает ошибку ANR или завершает процесс приложения, ОС записывает подробные метаданные выхода, включая точную причину сбоя, состояние памяти и полную трассировку системы для каждого потока, выполняющегося в процессе в данный момент.

  • И самое главное – вам не нужно устанавливать дополнительные плагины или писать код.

  • Если вы используете Crashlytics SDK версии 18.3.5 или более поздней, то все будет работать по умолчанию.

  • Поэтому, как только ваше приложение перезапустится, Crashlytics SDK автоматически запросит ApplicationExitInfo, захватит полный многопоточный дамп вместе с телеметрией устройства и отправит его в серверную часть Crashlytics.

  • Давайте откроем консоль Crashlytics и посмотрим, что она нам скажет.

  • Это страница с подробными сведениями об ошибке ANR в консоли Firebase Crashlytics. На вкладке трассировки стека мы сразу видим ошибку DefaultDispatcher worker.

    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 поместил в самом верху и пометил значком "Блокировка корневого потока".

  • Как объясняется в подсказке на значке, этот рабочий поток действительно блокирует поток, вызвавший ошибку ANR.

  • Кстати, в Crashlytics есть умные значки и для других виновников.

  • Например, если поток завис при чтении огромного файла или выполнении очень медленного запроса к базе данных, а затем вызвал ошибку ANR, он будет помечен как блокирующий корневой поток I/O.

  • И если бы два потока застряли в ожидании друг друга, Crashlytics пометил бы их как заблокированные.

  • Теперь давайте посмотрим на сами стековые фреймы в корневом блокирующем потоке.

  • Итак, у нас есть PaletteExtractor processVideo, вызывающий FrameDecoder getUncompressedFrame, выделенный синим цветом, и он полностью застрял внутри createBitmap, просто пытаясь выделить память для одного кадра.

  • Поскольку мы знаем, что он не ожидает другой блокировки и не находится в сетевом вызове, нашим главным подозреваемым должна быть память, и мы действительно можем подтвердить это прямо сейчас, проверив свободную память устройства прямо здесь, в консоли Crashlytics.

  • Посмотрите на два места, где Crashlytics показывает это.

  • На вкладке данных для определенного события рядом с вкладкой трассировки стека, которую мы только что изучали, а также в правом верхнем углу, где показано среднее значение по всем событиям.

  • В обоих случаях размер файла не превышает 100 КБ.

  • Давайте подумаем.

  • В этом случае из-за нехватки памяти Android Runtime тратила все время на сборку мусора, пытаясь найти достаточно места для следующей битовой карты, но так и не смогла этого сделать.

  • замораживая рабочий поток и лишая пользовательский интерфейс ресурсов более чем на пять секунд.

  • Вот вам совет.

  • Если вы видите ошибку ANR, связанную с нехваткой ОЗУ, это явный признак того, что она вызвана утечкой памяти или раздуванием.

  • Теперь вопрос в том, является ли наше приложение жертвой или оно также накапливает память?

  • Итак, давайте вернемся к списку проблем нашего проекта и посмотрим, есть ли что-нибудь ещё, что кажется актуальным.

Как исследовать исключения, связанные с нехваткой памяти

  • Полина Говоркова

    И посмотрите сюда.

  • Внизу мы видим список проблем, и прямо над нашей ошибкой ANR с восемью событиями находится исключение OutOfMemory.

  • Так что, возможно, виноваты все-таки мы.

  • И это на самом деле огромная подсказка.

  • Потому что на Android ошибка Java OutOfMemoryError, или OOM, не просто означает, что на телефоне мало ОЗУ.

  • Это означает, что процесс нашего собственного приложения превысил лимит кучи Java в среде выполнения Android.

  • Итак, на данный момент моя рабочая теория заключается в том, что наши ANR и OOM на самом деле являются двумя сторонами одной медали.

  • В зависимости от времени выполнения теста ChromaClip либо застревал в сборке мусора достаточно долго, чтобы вызвать ошибку ANR, либо полностью исчерпывал кучу, что приводило к нехватке памяти.

  • Давайте изучим исключение 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 байт.

  • Мы снова попадаем в ту же ловушку.

  • Если приложение аварийно завершает работу из-за того, что не может развернуть крошечный значок настроек, то виноват не объект activity настроек.

  • Просто вызывающему объекту не повезло: он запросил немного памяти, когда ее не было.

  • Чтобы найти настоящего виновника, нам нужно уменьшить масштаб и посмотреть на все, что находилось в памяти в момент сбоя.

  • Другими словами, нам нужен дамп кучи Java.

  • Если вы знаете, как вызвать ошибку нехватки памяти, то можете вручную получить дамп кучи Java с помощью профилировщика памяти Android Studio.

  • Но если нехватка памяти происходит непредсказуемо, во время тестовых запусков или в реальных условиях, этот метод не сработает.

Получение и анализ дампов кучи

  • Полина Говоркова

    Это ещё один звездный час Firebase Crashlytics.

  • Помимо информации о выходе из приложения, Crashlytics теперь интегрируется с ProfilingManager API для Android.

  • В Android 17 API ProfilingManager поддерживает профилирование памяти, запускаемое системой. Это именно то, что нам нужно.

  • Это означает, что когда в вашем приложении возникает ошибка OutOfMemory или нарушаются ограничения системной памяти, ОС делает снимок кучи в фоновом режиме, а затем в Crashlytics активированный сбор данных происходит бесплатно и без дополнительных настроек, если вы используете Crashlytics SDK 20.1.0 или более поздней версии.

  • Затем Crashlytics автоматически сгруппирует сбои, связанные с нехваткой памяти и ограничением ее использования, и покажет их на панели управления. Подробнее об этом мы поговорим позже.

  • Если вы включите сбор дампов кучи в консоли Firebase или CLI, Crashlytics начнет автоматически собирать снимки кучи и деобфусцировать их. Вы получите полную картину того, что находилось в куче Java вашего приложения в момент сбоя.

  • Вот как выглядит ошибка нехватки памяти ChromaClip в консоли Crashlytics после захвата кучи.

  • Вверху вы увидите объяснение, почему Java исчерпала кучу и почему это произошло.

  • Вы также увидите полезные советы по устранению неполадок и ссылки на внешние ресурсы.

  • На вкладке трассировки стека, прямо над этой вводящей в заблуждение активностью настроек, Crashlytics фактически помечает, что трассировки стека OOM не являются детерминированными, чтобы людям не приходилось тратить время на просмотр трассировки стека, как мы только что сделали.

  • Затем Crashlytics прикрепляет деобфусцированный файл Perfetto, дамп кучи, для сеанса.

  • И отсюда мы можем скачать файл.

  • Мы можем просмотреть все дампы в Google Cloud Storage или открыть их в Perfetto UI.

  • Нажав "Анализировать" в Perfetto UI, вы откроете Perfetto heap dump explorer.

  • Поднимите руку, если вы уже видели это.

  • Хорошо, вижу пару рук.

  • На мой взгляд, это представление очень пугающее, но в данном случае проблема на самом деле довольно проста.

  • Посмотрите на набор массивных полос, охватывающих почти всю кучу Java.

  • Поэтому в основном это массив парных объектов, каждый из которых содержит цвет и экземпляр строки.

  • О!

  • Кажется, я знаю, что происходит.

  • Наш код выделяет цвет и некоторые вспомогательные данные для каждого пикселя в каждом кадре и хранит миллионы из них в списке.

  • Неудивительно, что SettingsActivity не удалось выделить эти 256 байт.

  • Ну, по крайней мере, одна загадка решена.

  • Давайте это исправим.

  • И на этом этапе у нас есть несколько вариантов.

  • Perfetto уже указал нам на PaletteExtractor, поэтому мы можем открыть IDE и переписать цикл вручную или использовать ИИ-агента для программирования.

  • Или мы можем вообще обойти ручную проверку кучи и использовать 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, такие как пользовательские ключи, журналы, контрольные точки, связанные развертывания и многое другое, прямо из терминала.

  • Ещё лучше – посмотрите на нижнюю часть слайдов.

  • Не выполняйте эти команды самостоятельно.

  • Снова попросите ИИ-агента на естественном языке проверить последний дамп кучи OOM с помощью команды Android и исправить ошибку.

    # 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)
  • и у него будет достаточно контекста, чтобы выполнить всю процедуру за вас.

  • То есть он может составить список ошибок, скачать трассировку, выполнить анализ и сгенерировать исправление кода прямо в вашем проекте.

  • И посмотрите на этот патч в файле 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 тоже исчезнет.

  • Мы развернули исправленную сборку на тестовых устройствах, выбрали новый клип и начали извлекать палитры.

  • и интерфейс снова зависает.

  • Ещё одна ошибка ANR.

  • Интересно, что при дальнейшем тестировании мы обнаружили, что на некоторых устройствах по-прежнему возникают ошибки ANR, а на других приложение теперь просто случайным образом аварийно завершает работу, и довольно часто, чаще, чем при ошибках OOM.

  • Но мы исправили OOM, так в чем же дело?

Как исследовать проблемы с нативной памятью

  • Полина Говоркова

    Вернемся к Crashlytics.

  • Начнем с хороших новостей.

  • Посмотрите список проблем.

  • Исключение, связанное с нехваткой памяти, полностью исчезло, как мы и надеялись.

  • Плохая новость:

  • Теперь, когда Хромаклип выживает чуть дольше, у него есть время узнать, что ещё скрывалось под поверхностью.

  • Поэтому ошибки ANR не исчезли.

  • Внизу списка мы видим два сбоя на уровне ОС, к счастью, с небольшим количеством событий.

  • А в самом верху, с 11 событиями, у нас есть завершение работы Memory Limiter, слава богу, уже помеченное как имеющее дамп кучи.

  • Подождите.

  • Что такое принудительное завершение процесса из-за ограничения памяти?

  • Поднимите руки, кто из вас слышал о нем?

  • Теперь

  • Несколько редких рук.

  • Давайте учиться вместе.

  • Если вы не сталкивались с этим раньше, то это потому, что функция появилась только в Android 17.

  • Ошибка OOM в Java возникает, только если вы превышаете лимит управляемой кучи Java. Однако, когда общий объем потребляемой памяти приложения также включает нативную оперативную память, например графические буферы и выделения C++, раньше не было ограничения для каждого приложения.

  • У нас был глобальный LMK.

  • Он вмешивался, когда какое-нибудь приложение забивало оперативную память до отказа, из-за чего телефон начинал тормозить.

  • Начиная с Android 17, ограничитель памяти устанавливает лимит на объем потребляемой памяти для каждого приложения.

  • Поэтому, когда процесс потребляет непропорционально большой объем ОЗУ, ОС завершает работу этого конкретного приложения.

  • Crashlytics фиксирует все эти сведения и группирует сбои на панели управления.

  • Итак, нажав на Memory Limiter kill в Crashlytics, как и в случае с OOM, вы получите объяснение, почему ОС вмешалась, так что вам не нужно запоминать то, что я только что сказал.

  • Вы также получите советы по устранению проблемы и ссылки на документацию Android по принудительному освобождению памяти.

  • Как и в случае с нехваткой памяти, трассировка стека указывает на невиновный фрейм, когда ОС завершает работу, а панель управления Crashlytics выделяет его.

  • Теперь ограничитель памяти в основном завершает процессы в нативном коде, поскольку в нативных средах отсутствует уровень, отвечающий за безопасность памяти и распределение ресурсов.

  • В противном случае произошла бы ошибка нехватки памяти.

  • Итак, первый вопрос: удерживают ли наши объекты Kotlin огромные нативные аллокации?

  • Или это необработанный код C++, который сам по себе вызывает утечку?

  • Чтобы узнать, нажмите "Анализировать в Perfetto UI".

  • Откройте вкладку Overview (Обзор) в Perfetto heap dump explorer.

  • В разделе общей информации мы видим, что в анонимной RSS и файле подкачки общий объем памяти процесса достиг 4 ГБ.

  • Окей.

  • Однако ниже, в разделе Bytes Retained by Heap (Байты, сохраненные в куче), мы видим, что вся управляемая куча, включая нативную память, связанную с объектами Kotlin, такими как растровые изображения, занимает всего 42 МБ.

  • Окей.

  • Таким образом, за 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 наше приложение становится популярным.

  • Тысячи людей ежедневно загружают видео и делают ремиксы.

  • Но помните те загадочные сбои на уровне ОС, которые мы видели внизу панели управления?

  • Они тоже с ума посходили.

  • Внезапно их становится намного больше.

  • Но наша работа ещё не закончена.

Как исследовать сбои, связанные с повреждением нативной памяти

  • Полина Говоркова

    Итак, открыв этот сбой на уровне ОС в Crashlytics, посмотрите на трассировку стека.

  • В нем нет ни одной строчки кода из нашего приложения.

  • RenderThread аварийно завершает работу в libc.so при освобождении памяти.

  • И ещё один совет: если системный поток аварийно завершает работу в распределителе памяти, а код вашего приложения не задействован, это явный признак повреждения нативной памяти.

  • Это значит, что наш код C++ повредил или освободил буфер раньше, а поток рендеринга просто споткнулся о него позже.

  • Как же поймать настоящего виновника в тот самый момент, когда происходит повреждение?

  • Android предлагает два мощных инструмента, и Crashlytics поддерживает оба.

  • Первый – GWP-ASan, легковесный инструмент, созданный специально для производственной среды.

  • Он берет небольшую часть выделенной памяти в нативной куче и окружает ее защитными страницами, чтобы нагрузка на ЦП и память была практически нулевой.

  • Если в ходе тысяч пользовательских сеансов ошибка затрагивает освобожденную или находящуюся за пределами допустимого диапазона память в выбранном фрагменте, GWP-ASan сразу же обнаруживает ее.

  • Однако, поскольку GWP-ASan по своей природе является вероятностным, при локальном тестировании вам не нужно запускать тестовый телефон на 16 или более часов, чтобы получить случайную выборку.

  • Что же вы получаете?

  • Для детерминированной локальной отладки используйте ARM MTE (Memory Tagging Extension).

  • MTE использует аппаратный указатель и теги памяти, чтобы проверять каждое выделение памяти. Эта функция доступна на устройствах ARM v9, таких как Pixel 8 и более новые модели.

  • Поэтому лучше всего использовать его вместе с GWP-ASan в рабочей версии.

  • Как и следовало ожидать, когда любой из инструментов обнаруживает ошибку, Crashlytics автоматически помечает проблему и позволяет фильтровать по GWP-ASan или MTE и закрепляет информационную карточку с точной причиной повреждения на панели инструментов.

  • Давайте посмотрим, что происходит в консоли Crashlytics, когда GWP-ASan обнаруживает сбой.

  • Рядом с обычной вкладкой трассировки стека есть вкладка трассировки стека памяти.

  • А вверху мы видим точную причину повреждения – использование после освобождения.

  • И вместо того, чтобы показывать сбой потока рендеринга в libc.so, GWP-ASan теперь предоставляет нам полную биографию этого буфера внутри нашего собственного 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 дают вам инструмент для отслеживания и уверенной доставки.

Сводка и регистрация в программе раннего доступа

  • Полина Говоркова

    Итак, давайте повторим, что вы можете использовать прямо сейчас: данные о потоках ANR и другие метаданные через ApplicationExitInfo; автоматический сбор триггеров завершения работы из-за нехватки памяти и ограничений памяти через ProfilingManager; и полную поддержку 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
Текстовый фрагмент на отметке 04:45 в видео "Отладка и анализ ошибок ANR"
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)
Текстовый фрагмент на отметке 08:08 в видео "Отладка и анализ ошибок ANR"
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 в видео "Investigating out of memory exceptions"
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 в видео "Capturing and analyzing heap dumps"
# 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 в видео "Capturing and analyzing heap dumps"
# 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)
Фрагмент кода на Kotlin в видео "Capturing and analyzing heap dumps" (Запись и анализ дампов кучи)
// 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
}
Фрагмент кода на 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
}
Фрагмент кода на C++ в видео "Investigating native memory corruption crashes" (25:14)
// 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
}