Firebase Crashlytics поможет вам оптимизировать объем потребляемой памяти и избежать ошибок сегментации. На этом занятии мы рассмотрим полный набор инструментов для устранения неполадок с памятью, доступных в консоли Firebase и Android Studio. Сначала мы расскажем, как использовать статистику ANR и отчеты GWP-ASan для диагностики трудновоспроизводимых ошибок памяти. Затем мы рассмотрим наши новейшие инструменты для событий Out-Of-Memory (OOM) и Memory Limiter Kill (MLK), разработанные специально для того, чтобы помочь вам ориентироваться в новых ограничениях памяти, введенных в Android 17, и адаптироваться к ним.
ApplicationExitInfo, чтобы выявить многопоточные трассировки и определить основные блокирующие потоки.ProfilingManager в Android 17, чтобы собирать деобфусцированные снимки кучи, которые можно анализировать в Perfetto или через Android CLI.Расшифровка
Полина Говоркова
Здравствуйте!
Добро пожаловать в руководство по устранению неполадок с памятью с помощью 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.
Полина Говоркова
Что же делать в такой ситуации, кроме как паниковать?
Поскольку тестовый телефон подключен к ноутбуку, мы открываем 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, сфотографируйте слайд и зарегистрируйтесь по ссылке.
Мы обсудили почти все, что я хотел. Спасибо, что уделили мне время.
Желаем, чтобы ваш основной поток никогда не блокировался, куча оставалась чистой, а следующий выпуск был самым стабильным.
Удачной отладки!
Ещё раз спасибо.
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"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.main14: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)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)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)# 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># 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.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
}// 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
}// 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
}