ANR (Application Not Responding) система фиксирует, когда главный поток заблокирован слишком долго: около 5 секунд для необработанного input-события, около 10 секунд для BroadcastReceiver на переднем плане.
Причины: нет реакции на ввод в течение ~5 с; BroadcastReceiver.onReceive на переднем плане дольше ~10 с (~60 с в фоне); медленные колбэки Service, ContentProvider или JobService.
Как избежать: оставьте главный поток для UI; сеть, диск и базу уводите в корутины на Dispatchers.IO / Dispatchers.Default; никогда не вызывайте runBlocking, Thread.sleep и Future.get() на main; включите StrictMode в debug-сборках.
Как найти: Android Vitals в Play Console показывает стек главного потока в момент таймаута; на устройстве ApplicationExitInfo с REASON_ANR отдаёт трейс при следующем запуске.
AndroidSenior
Производительность Android и память
Профилировщики, leak-и, ANR, baseline profiles, R8, startup, перформанс рекомпозиции
Закрыто вопросов
0 / 25
- 0Повторить
- 7Часто спрашивают
Вопросы
runBlocking блокирует вызывающий поток, пока корутина не завершится, а вызывающим потоком здесь выступает главный. Сетевой вызов действительно идёт на IO, но UI-поток стоит и ждёт его.
withContext(Dispatchers.IO) меняет то, где выполняется работа, а не то, кто ждёт. Слушатель клика не может вернуться, пока не вернётся runBlocking.
Пока он ждёт, главный поток не рисует кадры и не обрабатывает ввод. Следующий тап ждёт в очереди, и примерно через 5 секунд система поднимает ANR. Даже запрос на 300 мс означает замёрзший экран.
runBlocking предназначен для функций main() и тестов, а не для UI-кода Android.
Исправить это можно, запустив корутину в скоупе, привязанном к жизненному циклу: lifecycleScope в Activity, viewModelScope во ViewModel. Корутина приостанавливается вместо блокировки, главный поток продолжает рисовать, а запрос отменяется, когда экран уходит. Вызов во ViewModel вдобавок сохраняет результат при повороте экрана.
Отладочный инструмент StrictMode вы включаете в Application.onCreate: его политика потока сообщает об обращениях к диску и сети на главном потоке, а политика VM сообщает о незакрытых ресурсах, утёкших Activity и похожих ошибках.
Две политики
ThreadPolicy действует на поток, который её установил, поэтому установка в Application.onCreate покрывает главный поток. Полезные детекторы: detectDiskReads, detectDiskWrites, detectNetwork, detectUnbufferedIo и detectCustomSlowCalls в паре с вашим StrictMode.noteSlowCall("parseFeed").
VmPolicy действует на весь процесс. detectLeakedClosableObjects сообщает о Cursor или потоке, собранном GC, пока он ещё был открыт, вместе со стеком, где его открыли. Другие: detectActivityLeaks, detectLeakedRegistrationObjects (ресивер, который не отписали), detectUnsafeIntentLaunch.
Реакция на нарушение: penaltyLog при разработке, penaltyListener (API 28+), чтобы отправлять нарушения в ваш логгер или валить тест, а penaltyDeath включайте, только если вы готовы к крашам.
Ловушки
Фреймворк сам иногда читает диск на main, например при первой загрузке SharedPreferences. Оберните именно этот вызов в StrictMode.allowThreadDiskReads(), а не удаляйте детектор, иначе вы приучитесь игнорировать лог.
Никогда не выпускайте его в релиз: penaltyDeath() в release-сборке превращает медленное чтение на дешёвом телефоне в краш. Оборачивайте настройку в проверку флага debuggable (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE).
StrictMode не видит нагрузку на CPU. Парсинг JSON на 300 мс на главном потоке для него невидим; такое находит системный трейс.
За утечкой на Android почти всегда стоит что-то долгоживущее (синглтон, статическое поле, работающий колбэк), что всё ещё ссылается на уничтоженные Activity, Fragment или View, поэтому весь экран никогда не может быть собран сборщиком мусора.
Частые причины
Статическое поле или синглтон, хранящий Context Activity. Всё, что живёт дольше экрана, должно получать applicationContext.
Слушатели, зарегистрированные в долгоживущем объекте (event bus, LocationManager, сторонний SDK) и не отписанные.
Лямбды, анонимные объекты и inner-классы, которые захватывают Activity и удерживаются чем-то ещё: колбэк Handler.postDelayed, работающий поток.
Fragment, который держит view binding после onDestroyView. Фрагмент живёт дольше своего дерева view.
Корутины, запущенные в GlobalScope или в созданном вами скоупе, который никто не отменил.
Как найти
LeakCanary в debug-сборках следит за уничтоженными Activity, Fragment и ViewModel и печатает цепочку ссылок, которая удерживает объект.
Memory Profiler: поверните экран несколько раз, снимите heap dump и поищите больше одного экземпляра одной и той же Activity.
В проде Play Vitals и ApplicationExitInfo показывают убийства процесса из-за памяти.
Как исправить
Сокращайте время жизни, а не добавляйте WeakReference: привязывайте работу к viewModelScope, lifecycleScope или viewLifecycleOwner, отписывайтесь в зеркальном колбэке (onStart/onStop), а в Compose используйте DisposableEffect, чтобы очистка стояла рядом с регистрацией.
Лямбда вызывает findViewById у Activity, значит, захватывает this, а SessionManager является синглтоном на весь процесс, который хранит лямбду вечно. Каждый поворот добавляет в его список ещё одну уничтоженную Activity.
Цепочка, которую печатает LeakCanary: SessionManager.listeners → список → лямбда → ProfileActivity → её окно и все её view.
Утечка накапливается: каждое пересоздание регистрирует нового слушателя, а старые не удаляются, так что копятся и память, и дублирующиеся колбэки. Колбэк в уничтоженную Activity к тому же может упасть.
**removeListener в таком виде не поможет: лямбда создана прямо в вызове, никто не сохранил ссылку, чтобы её передать.
Список WeakReference ничего не исправляет**: больше никто не держит лямбду сильной ссылкой, поэтому её соберут при следующем GC, пока экран ещё виден, и обновления молча прекратятся.
Исправление: привязать подписку к жизненному циклу. Либо храните слушателя в поле и вызывайте addListener/removeListener в зеркальных колбэках (onStart/onStop), либо отдавайте сессию как StateFlow и собирайте её внутри repeatOnLifecycle, который сам отменит сбор.
Он держит каждую уничтоженную Activity, Fragment, ViewModel и Service через WeakReference, после короткой паузы запускает GC, и если ссылка всё ещё не очищена, снимает heap dump и ищет кратчайший путь сильных ссылок до объекта.
Наблюдение: LeakCanary устанавливается сам через ContentProvider (без кода инициализации) и подписывается на колбэки жизненного цикла: уничтожение Activity и Fragment, ViewModel.onCleared, уничтожение сервиса. Каждый уничтоженный объект передаётся в ObjectWatcher.
**Слабая ссылка плюс ReferenceQueue: объект оборачивается в слабую ссылку, зарегистрированную в очереди. Когда GC его собирает, ссылка попадает в очередь, и объект убирается из списка наблюдения.
Подождать и вызвать GC**: примерно через 5 секунд он вызывает Runtime.getRuntime().gc() и проверяет снова. Если объект всё ещё достижим, значит, он «удерживается».
Порог: heap dump замораживает приложение на секунды, поэтому по умолчанию он ждёт, пока при видимом приложении наберётся 5 удерживаемых объектов, и только тогда вызывает Debug.dumpHprofData.
Анализ: его библиотека Shark разбирает файл .hprof, вычисляет кратчайший путь от GC roots (статические поля, стеки потоков, JNI-ссылки) до каждого удерживаемого объекта и группирует утечки по этому пути. Известные утечки внутри самого Android помечаются как library leaks, чтобы вы за ними не гонялись.
Поэтому ему место только в debugImplementation: принудительные GC и heap dump слишком тяжелы для реальных пользователей.
ART освобождает объекты, до которых больше нельзя дойти от GC roots (стеки потоков, статические поля, JNI-ссылки); тип каждой ссылки решает, держит ли она объект живым.
Как собирает ART
Он отслеживает достижимость, а не счётчики ссылок, поэтому циклы, до которых никто не доходит, тоже собираются.
Сборщик конкурентный и поколенческий: молодые объекты собираются часто и дёшево, основная работа идёт параллельно с вашими потоками, паузы короткие. Новые версии ART, которые обновляются через системные обновления Play, переходят на конкурентный mark-compact сборщик; модель для вас остаётся той же.
System.gc() работает только как подсказка. Не вызывайте его в коде приложения; он для тестов и инструментов вроде LeakCanary.
Типы ссылок
Strong: обычная переменная или поле. Объект живёт, пока к нему есть путь из сильных ссылок.
Soft (SoftReference) очищается только при нехватке памяти. Похоже на кэш, но документация Android советует так не делать: среда выполнения не знает, какие записи стоит сохранить. Используйте LruCache с ограничением размера.
Weak (WeakReference) очищается при следующем GC, как только не осталось сильных ссылок. Нужна, чтобы наблюдать, не владея: ключи WeakHashMap, наблюдатель LeakCanary.
Phantom (PhantomReference): get() всегда возвращает null. Ссылка попадает в ReferenceQueue, когда объект больше не достижим по более сильным ссылкам, что позволяет освободить нативные ресурсы. java.lang.ref.Cleaner (API 33+) оборачивает этот паттерн.
Частая ловушка на собеседовании: WeakReference не исправляет утечку. Она прячет ошибку времени жизни и заставляет колбэки пропадать в случайные моменты.
onDraw вызывается на каждом кадре анимации, и здесь он каждый раз создаёт Paint, Path и String (плюс упакованный Float из maxOrNull()). Это churn аллокаций, а не утечка: сборщик мусора постоянно работает во время анимации и отнимает время у кадров.
Чем вредит churn
Выделить память в ART дёшево; дорого собирать. Конкурентный сборщик всё равно ненадолго останавливает потоки и конкурирует за ядра CPU с UI-потоком и RenderThread.
При 120 Гц этот метод вызывается 120 раз в секунду, так что горстка объектов на вызов превращается в сотни в секунду на одну view.
Симптом в Logcat: строка GC (Background concurrent ... GC freed ...) несколько раз в секунду, пока идёт анимация. Android Lint уже помечает этот паттерн как DrawAllocation.
Исправление
Создайте Paint и Path один раз как поля и вызывайте path.reset() перед повторным заполнением в onDraw.
Собирайте подпись, когда меняются данные, а не на каждом кадре.
Смежный совет, но не причина здесь: храните числа в FloatArray, а не в List<Float>: каждый элемент List<Float> хранится как упакованный объект. То же с HashMap<Int, T>: SparseArray или IntObjectMap из androidx.collection хранят ключи примитивами.
Чтобы найти churn, который не виден глазом, запишите аллокации в Memory Profiler во время жеста и отсортируйте по количеству аллокаций, а не по размеру.
Правило: ничто, что живёт один кадр, не должно создаваться внутри кадра.
SparseArray и ArrayMap хранят ключи и значения в плоских массивах и ищут ключ бинарным поиском, поэтому не упаковывают ключи и не создают объект-узел на каждую запись; они занимают меньше памяти и меньше аллоцируют, но поиск занимает O(log n), так что выигрывают на небольших коллекциях (от сотен до нескольких тысяч записей) и проигрывают на больших.
**SparseArray, SparseIntArray, SparseBooleanArray**: ключи примитивные Int. HashMap<Int, V> упаковывает каждый ключ и выделяет узел на каждую запись.
**ArrayMap<K, V>**: та же идея для объектных ключей: один массив отсортированных хешей и один массив пар ключ/значение. Реализует MutableMap, так что подходит как прямая замена.
Цена: поиск это бинарный поиск, а вставка или удаление в середине сдвигает массивы, то есть O(n). На большой или часто изменяемой карте HashMap быстрее.
Lint: проверка UseSparseArrays предлагает такую замену для HashMap<Int, V>.
**androidx.collection**: IntObjectMap и MutableIntIntMap представляют собой хеш-карты с примитивами: без упаковки и без поиска за O(log n), хороший выбор по умолчанию для больших карт с Int-ключами.
Декодированный bitmap стоит ширина × высота × байты на пиксель, независимо от размера файла: фото 4000×3000 весит 2 МБ в JPEG, но 48 МБ в ARGB_8888. Несколько декодирований в полном размере исчерпывают память, даже когда ничего не утекает.
Утечка против OOM
Утечка: память, которую уже нельзя освободить, потому что на неё всё ещё есть ссылка. Она растёт со временем и входит в число частых причин OOM.
OOM: одно выделение памяти, которое среда выполнения не может удовлетворить. Один слишком большой bitmap вызывает его без всякой утечки, и стектрейс указывает на неудавшееся выделение, а не на то, что заполнило память.
Где живут пиксели: до Android 7 они лежали в Java-куче, поэтому большое декодирование бросало OutOfMemoryError внутри BitmapFactory. С Android 8 они в нативной памяти, поэтому график Java-кучи может выглядеть нормально при огромном объёме памяти процесса; тогда сбой чаще выглядит как убийство low-memory killer, а не как OutOfMemoryError внутри BitmapFactory.
Что помогает
Декодируйте под размер показа: inJustDecodeBounds плюс inSampleSize или ImageDecoder с setTargetSize. Coil и Glide берут размер из view и делают это за вас; в этом главная причина ими пользоваться.
Выбирайте конфиг: RGB_565 вдвое уменьшает память там, где не нужна прозрачность; Bitmap.Config.HARDWARE хранит пиксели только в памяти GPU.
Переиспользуйте буферы: BitmapPool в Glide декодирует в уже существующие bitmap через inBitmap, так что прокручиваемая сетка не выделяет новый буфер на каждую строку.
Ограничьте кэш: LruCache с размером от ActivityManager.memoryClass плюс дисковый кэш.
Просите меньше: запрашивайте у сервера или CDN картинку размером с превью, а не полный оригинал.
Это колбэки памяти, которые система присылает вашему Application, Activity и Service, когда ваш UI скрыт или памяти мало. Правильная реакция: сбросить кэши, которые можно восстановить, чтобы процесс было дешевле держать живым.
С Android 14 приходят только два уровня: TRIM_MEMORY_UI_HIDDEN (пользователь ушёл из вашего UI; освободите ресурсы, нужные только UI) и TRIM_MEMORY_BACKGROUND (процесс в фоновом списке; сильно урежьте кэши). Уровни RUNNING_*, MODERATE и COMPLETE устарели и больше не доставляются.
**onLowMemory()**: старый колбэк до API 14, равный самому тяжёлому уровню. Новый код обрабатывает только onTrimMemory.
Что освобождать: кэши картинок в памяти, распарсенные ответы, пулы объектов. Колбэк должен быть быстрым: без записи на диск и тяжёлой работы.
Почему это окупается: low-memory killer выбирает, какой закэшированный процесс убить, в том числе по его размеру. Процесс поменьше живёт дольше, и пользователь получает тёплый старт вместо холодного.
Закладывайте слабые устройства заранее: задавайте размер кэшей от ActivityManager.memoryClass и делайте их меньше, когда isLowRamDevice() возвращает true.
Под jank понимают кадр, который не уложился в дедлайн дисплея (16,7 мс при 60 Гц, 8,3 мс при 120 Гц), поэтому экран снова показывает предыдущий кадр, и пользователь видит рывок.
Как измерять
При разработке: системный трейс в Perfetto или профайлере Android Studio. Frame timeline помечает janky-кадры, и видно, какой вызов не уложился.
Быстрая проверка: adb shell dumpsys gfxinfo <package> печатает долю janky-кадров и перцентили.
В CI: Macrobenchmark с FrameTimingMetric выдаёт длительность кадра и превышение на P50–P99 для заскриптованной прокрутки.
В проде: библиотека JankStats присылает покадровые данные с реальных устройств, а Play Vitals показывает медленные кадры (дольше 16 мс) и замёрзшие кадры (дольше 700 мс).
Частые причины
Работа на главном потоке во время прокрутки: чтение с диска, парсинг JSON, декодирование bitmap.
Дорогой layout: глубокие или вложенные иерархии view, которые измеряются дважды, тяжёлый onBindViewHolder.
В Compose: лишние рекомпозиции, например чтение быстро меняющегося состояния высоко в дереве.
Паузы GC из-за аллокаций в путях отрисовки и биндинга.
Код, который при первом запуске выполняется в интерпретаторе (это лечит baseline profile).
Нагрузка на RenderThread и GPU: overdraw, загрузка больших текстур, blur.
Overdraw возникает, когда GPU за один кадр закрашивает один и тот же пиксель несколько раз (фон окна, под ним фон экрана, под ним карточка), и на дешёвом телефоне это потраченное впустую заполнение съедает бюджет кадра.
Как заметить
Параметры разработчика → Debug GPU overdraw (отладка наложения). Истинный цвет означает, что overdraw нет, синий означает 1 лишний слой, зелёный 2, розовый 3, красный 4 и больше. Проблемой считаются большие красные области.
Проверяйте на самом дешёвом поддерживаемом устройстве. За overdraw платят попиксельно, поэтому на флагмане он не виден, а на бюджетном телефоне доминирует.
Что убрать
Наложенные фоны: windowBackground из темы, фон корневого layout и фон каждого дочернего элемента часто закрашивают одну и ту же область. Пусть фоном владеет одна непрозрачная поверхность; в Compose такой поверхностью служит один Surface в корне экрана.
Фон окна: убирайте его через window.setBackgroundDrawable(null), только когда контент непрозрачен от края до края, иначе увидите чёрные вспышки.
Глубокие иерархии: ConstraintLayout вместо вложенных LinearLayout с весами, <merge> для подключаемых layout, ViewStub для редко показываемых частей. Чем меньше view, тем меньше измерять и рисовать.
Скрытый контент: view, полностью перекрытые непрозрачным диалогом или шторкой, всё равно рисуются. Скрывайте их, а полупрозрачные затемнения ограничивайте областью, которую они реально закрывают.
Холодный старт создаёт процесс, Application и первую Activity с нуля; тёплый переиспользует живой процесс, но пересоздаёт Activity; горячий только выводит существующую Activity на передний план. Холодный старт самый медленный, и оптимизировать нужно именно его.
Как измерять
Logcat печатает Displayed com.app/.MainActivity: +850ms (время до первого отображения).
adb shell am start -W -S com.app/.MainActivity сначала принудительно останавливает приложение и выводит TotalTime для холодного запуска.
Macrobenchmark с StartupTimingMetric и StartupMode.COLD даёт время до первого и до полного отображения по многим прогонам.
Play Vitals помечает слишком медленные старты: холодный от 5 с, тёплый от 2 с, горячий от 1,5 с.
Как ускорить
**Лёгкий Application.onCreate: инициализируйте только то, что нужно первому кадру; SDK откладывайте через ленивую инъекцию или на время после первого кадра.
ContentProvider**: провайдер каждой библиотеки создаётся до Application.onCreate. App Startup сводит инициализаторы библиотек в один провайдер и позволяет убрать или отложить их.
Не блокируйте первый кадр: покажите закэшированные данные или заглушку, затем вызовите reportFullyDrawn() (ReportDrawnWhen в Compose), когда виден реальный контент, чтобы время до полного отображения измерялось.
Baseline profile: код старта компилируется при установке, а не интерпретируется.
Splash screen: installSplashScreen(); держите его на экране только пока данные действительно необходимы.
Baseline Profile представляет собой список классов и методов, поставляемый внутри приложения, который ART компилирует заранее при установке, чтобы запуск и первые прокрутки шли на скомпилированном коде, а не интерпретировались и JIT-компилировались на лету.
Зачем: без профиля первый запуск оказывается самым медленным за всю жизнь приложения: код интерпретируется, затем JIT-компилируется и полностью компилируется только позже, в фоне. Google сообщает примерно о 30% более быстром выполнении кода с первого запуска.
Как создать: подключите Gradle-плагин androidx.baselineprofile и модуль-генератор профиля (в Android Studio есть шаблон). Тест с BaselineProfileRule проходит критичные сценарии: запуск, прокрутка главного списка, открытие экрана деталей. ./gradlew :app:generateBaselineProfile запускает его на устройстве или эмуляторе (API 33+ или рутованный API 28+) и записывает профиль в модуль приложения.
Startup profile: includeInStartupProfile = true дополнительно позволяет AGP разложить DEX так, чтобы классы старта лежали рядом и загружалось меньше страниц.
Как доказать: Macrobenchmark со StartupTimingMetric, сравнивая CompilationMode.None() с CompilationMode.Partial() на release-сборке. Доверяйте медиане по многим прогонам, а не ощущениям.
Что он не заменяет: Play также строит Cloud Profiles по реальным пользователям, но только через некоторое время после релиза; ваш профиль покрывает первый день. Compose и другие библиотеки Jetpack поставляют свои профили; ваш добавляет код вашего приложения.
Перегенерируйте его, когда меняются критичные сценарии: устаревший профиль заранее компилирует код, который никто не запускает.
R8 за один проход сжимает, оптимизирует и обфусцирует ваш код (а с isShrinkResources также и ресурсы) при сборке релиза. Keep-правила говорят ему, какие классы и члены должны сохраниться без изменений, потому что что-то ищет их по имени в рантайме.
Что он делает
Shrinking: начинает с точек входа (компоненты манифеста и keep-правила) и удаляет все классы, методы и поля, до которых ничто не доходит.
Оптимизация: инлайнинг, слияние классов, удаление мёртвого кода. Full mode, более агрессивный режим, включён по умолчанию с AGP 8.0.
Обфускация: переименовывает уцелевшее в короткие имена; mapping.txt сопоставляет их обратно.
R8 давно заменил ProGuard по умолчанию и читает тот же синтаксис правил, поэтому файлы до сих пор называются proguard-rules.pro.
Что ломается при неверных правилах
Слишком мало правил: всё, что находится по имени в рантайме: имена полей для Gson (поля молча приходят null), Class.forName, имена JNI-методов. Проявляется это крашем или неверными данными только в релизе: ClassNotFoundException, NoSuchMethodException.
Слишком широкие правила: -keep class com.app.** { *; } тихо отключает shrinking и обфускацию для всего приложения.
Библиотеки обычно поставляют consumer-правила; сериализаторы с кодогенерацией, такие как kotlinx.serialization или Moshi codegen, ничего дополнительно не требуют.
Как с ним работать
Пишите узкие правила -keep или ставьте @Keep, гоняйте QA на минифицированной release-сборке, спрашивайте -whyareyoukeeping, когда что-то неожиданно уцелело, и загружайте mapping.txt в Play и Crashlytics, чтобы стектрейсы из прода читались.
Публикуйте Android App Bundle, включите R8 со сжатием ресурсов и найдите самые тяжёлые части в APK Analyzer: обычно это нативные библиотеки, картинки или одна раздутая зависимость.
Выигрыши, примерно по убыванию отдачи
Публикуйте Android App Bundle: Play собирает сплиты под каждое устройство, и пользователь качает один ABI, одну плотность экрана и свои языки, а не всё сразу. С нативными библиотеками одно это часто вдвое уменьшает загрузку.
Включите R8 и сжатие ресурсов: isMinifyEnabled и isShrinkResources. Слишком широкие keep-правила незаметно сводят на нет и то, и другое.
Нативные библиотеки: вырезайте отладочные символы из release-файлов .so и загружайте их в Play отдельно (ndk.debugSymbolLevel), чтобы нативные краши оставались читаемыми.
Картинки: WebP (AVIF только на API 31+) вместо PNG и JPEG, vector drawable для иконок.
Зависимости: вкладка DEX показывает размер по пакетам. Целый SDK ради одной вспомогательной функции подтягивают очень часто.
Языки: для APK-сборок androidResources.localeFilters оставляет только языки, на которые вы реально переводите; библиотеки тащат десятки.
Play Feature Delivery: для большой и действительно необязательной функции, модуль по запросу. Не стоит такой сложности ради одного мегабайта.
Различайте два числа: размер загрузки на странице в сторе, от которого зависит конверсия в установку, и размер APK на диске. Загрузку для одного устройства проверяйте через bundletool build-apks --connected-device.
Жалобы на батарею почти всегда вызывает работа, которую приложение делает, пока на него никто не смотрит: пробуждения, поллинг, геолокация или wake lock, который так и не отпустили.
Откуда расход
Partial wake lock, удерживаемый дольше, чем нужно работе, или foreground service, которому незачем работать.
Поллинг вместо push. Каждое пробуждение выводит сотовый модем из простоя, и после крошечного запроса он остаётся включённым ещё несколько секунд.
Геолокация с высокой точностью и частотой; хуже всего, когда она работает в фоне.
Несгруппированная работа: много будильников в случайное время вместо одной задачи, которую система может объединить с другими.
Сенсоры, Bluetooth-сканирование или медиасессии, оставленные работать после выключения экрана.
Как найти
Vitals в Play Console показывает избыточные пробуждения, избыточные partial wake lock и фоновое использование сети по моделям устройств.
adb shell dumpsys batterystats --reset, прогоните сценарий, снимите дамп снова и загрузите результат в Battery Historian, чтобы увидеть таймлайн.
adb shell dumpsys deviceidle force-idle и am set-standby-bucket позволяют намеренно проверить Doze и ограниченные бакеты.
Как исправить
Откладываемая работа уходит в WorkManager с ограничениями, чтобы система её группировала.
Замените поллинг на FCM; высокоприоритетные сообщения оставьте для того, что пользователь должен увидеть сразу.
Точные будильники используйте только для событий, видимых пользователю. SCHEDULE_EXACT_ALARM по умолчанию не выдаётся большинству новых установок на Android 14+.
Отправляйте аналитику пачками, а не отдельным запросом на каждое событие.
Работайте в рамках Doze и App Standby, а не просите исключение из оптимизации батареи: его проверяет Play.
Они измеряют три масштаба: одну функцию, один пользовательский сценарий и сессию одного реального пользователя. Лабораторные цифры имеют смысл только на сборке, похожей на релиз, на реальном устройстве и при сравнении по многим прогонам.
Microbenchmark (benchmark-junit4) гоняет блок кода в цикле с прогревом и стабилизацией частот и выдаёт время на итерацию. Отвечает на вопрос «этот парсер быстрее того?» и ничего не говорит о кадрах или запуске.
Macrobenchmark (benchmark-macro-junit4) управляет установленным приложением из отдельного тестового процесса и выдаёт StartupTimingMetric, FrameTimingMetric, TraceSectionMetric, MemoryUsageMetric и PowerMetric. Отвечает на вопросы «сколько длится холодный старт» и «насколько дёргается эта прокрутка», а ещё генерирует baseline profiles.
JankStats: библиотека, которую вы поставляете в приложении. Она присылает длительности кадров с реальных устройств, помеченные экраном или состоянием, которое вы указали, так что вы узнаёте, какой экран дёргается на железе ваших пользователей.
Собственные секции трейса: trace("name") { ... } из androidx.tracing выводит ваш код на таймлайн Perfetto, а TraceSectionMetric измеряет его в CI. <profileable android:shell="true" /> делает release-сборку доступной для трассировки, не делая её debuggable.
Play Vitals и Firebase Performance: агрегированный взгляд на прод: доля ANR, распределение времени старта, медленные и замёрзшие кадры.
Чтобы цифры что-то значили
Сборка как релиз: минифицированная и profileable, никогда не debuggable; debug-сборка в разы медленнее, и её цифры ничего не значат.
**Явный CompilationMode**: None() даёт худший случай, Partial() соответствует случаю с baseline profile, Full() задаёт режим, в котором реальные пользователи не бывают.
Реальное устройство, стабильные частоты: троттлинг от нагрева и шум эмулятора легко прячут регрессию, которую вы ищете.
Медиана и P90 по многим итерациям, никогда не один прогон.
CI на одной фиксированной модели устройства со сравнением с предыдущим коммитом, чтобы регрессия была привязана к изменению, которое её вызвало.
Кадр проходит конвейер из главного потока, RenderThread и GPU, и каждая стадия должна уложиться в один интервал vsync (8,3 мс при 120 Гц). Время главного потока покрывает лишь первую стадию; опоздать может RenderThread или GPU.
Что такое один кадр на самом деле
Сигнал vsync будит Choreographer, который выполняет колбэки в фиксированном порядке: input, animation, insets animation, traversal (measure, layout, draw), commit.
draw не рисует пиксели. Он записывает display list из RenderNode.
RenderThread синхронизирует этот display list, загружает новые текстуры, отдаёт команды GPU и ставит готовый буфер в очередь.
SurfaceFlinger компонует буфер из очереди на одном из следующих vsync. Кадр, который вы сейчас готовите, будет показан через один-два vsync.
Поэтому только что декодированный bitmap, blur или несколько offscreen-слоёв могут сорвать кадр на RenderThread, пока главный поток выглядит невиновным.
Читать, а не гадать
Frame timeline в Perfetto (Android 12+) показывает ожидаемый и фактический таймлайн для каждого кадра и говорит, кто опоздал: ваше приложение или SurfaceFlinger.
Window.addOnFrameMetricsAvailableListener даёт покадровые цифры на любом устройстве: LAYOUT_MEASURE_DURATION и DRAW_DURATION для главного потока; SYNC_DURATION, COMMAND_ISSUE_DURATION и SWAP_BUFFERS_DURATION (и GPU_DURATION на API 31+) для RenderThread и GPU; плюс UNKNOWN_DELAY_DURATION.
Большой UNKNOWN_DELAY_DURATION значит, что кадр начался поздно. Часто это buffer stuffing: один пропущенный кадр заполняет очередь буферов, и каждый следующий кадр ждёт свободный буфер, поэтому после одной заминки приложение ещё какое-то время кажется медленнее.
Вы не соревнуетесь с фиксированными часами; вы кормите конвейер, частоту кадров которого задаёт самая медленная стадия.
refresh() держит лок на всё время сетевого вызова, а current() нужен тот же лок. Если главный поток биндит список во время обновления, он ждёт окончания запроса: секунды замёрзшего UI и ANR, когда ввод не обработан около 5 с.
Почему так происходит: synchronized заставляет читателя ждать писателя, чем бы тот ни был занят внутри. Сам главный поток не делает I/O; он заблокирован за потоком, который его делает. Загрузка на 2 с превращает этот current() в вызов на 2 с.
Как работает исправление: сначала загрузить данные без всякого лока, затем опубликовать результат одним присваиванием в @Volatile-поле. Замена ссылки на неизменяемый список атомарна, поэтому читатель получает старый список или новый, но никогда не полусобранный, и никогда не ждёт.
Если лок всё же нужен (например, чтобы смёржить со старыми данными), держите его только на время работы в памяти. Никогда не вызывайте сеть, диск или чужой колбэк, держа лок, который может взять главный поток. ReentrantReadWriteLock не спасает: читатели всё равно ждут писателя.
Как это поймать: StrictMode молчит, потому что главный поток не трогает ни диск, ни сеть. ANR-трейс в Play Vitals показывает "main" ... Blocked с waiting to lock <0x...> held by thread NN; дальше идите по этому потоку до загрузки. В трейсе Perfetto ART помечает ожидание слайсом Lock contention on ... на главном потоке.
Low-memory killer смотрит на весь объём памяти процесса, а нативные выделения, графическая память, код и стеки потоков в него входят, хотя ни одно из них не видно на графике Java-кучи.
Читайте App Summary
adb shell dumpsys meminfo <package> заканчивается сводкой, которую большинство пропускает:
Java Heap: ваши объекты, ограничены ActivityManager.memoryClass (обычно 192–512 МБ). Превышение именно этого лимита бросает OutOfMemoryError.
Native Heap: пиксели bitmap (они там с Android 8), ML-модели, всё, что выделила нативная библиотека.
Graphics: текстуры, поверхности и HARDWARE-bitmap. Часто самое большое число, и Java-профайлер его не видит.
Code: отображённые в память файлы .so, .dex, .oat и .art, в основном освобождаемые.
PSS делит общие страницы между процессами, которые их используют; RSS считает их целиком. ApplicationExitInfo сообщает оба значения для реально убитого процесса (как его читать, см. в вопросе про ApplicationExitInfo).
Два сбоя, два исправления
OutOfMemoryError упирается в лимит Java-кучи. android:largeHeap="true" его поднимает и почти всегда это ошибка: он ничего не даёт для нативной и графической памяти, удлиняет сборки мусора и делает процесс более заметной целью для killer.
Убийство из-за нехватки памяти зависит от общего объёма под давлением системы. Уменьшение Java-кучи не поможет, когда память сидит в Graphics или Native.
Профилируйте нативную память записью нативных аллокаций в Memory Profiler или через heapprofd в Perfetto, освобождайте кэши в onTrimMemory и подтверждайте результат через ApplicationExitInfo по реальным убийствам, а не по лабораторной сессии, где не было никакого давления.
Система записывает, как завершился предыдущий процесс, а ApplicationExitInfo (API 30+) позволяет прочитать эту запись при следующем запуске.
Вызовите getHistoricalProcessExitReasons(packageName, 0, 5) в начале Application.onCreate, залогируйте последнюю запись и приложите её к краш-репортеру. Какие причины встретятся:
REASON_LOW_MEMORY: low-memory killer забрал процесс в фоне. Очень частая причина и сама по себе не баг.
REASON_ANR: getTraceInputStream() отдаёт трейс ANR.
REASON_CRASH и REASON_CRASH_NATIVE: второй означает нативный краш, который ваш JVM-обработчик крашей никогда не видит.
REASON_USER_REQUESTED: пользователь смахнул приложение. Исправлять нечего.
Другие: REASON_EXCESSIVE_RESOURCE_USAGE, REASON_PERMISSION_CHANGE, REASON_FREEZER.
Логируйте также pss, rss и importance: убийство при низкой важности и большом объёме памяти указывает на проблему памяти, а не на баг восстановления.
Большинство таких жалоб связано вовсе не с крашами. Убийство процесса в фоне считается нормальным поведением Android; пользователь замечает потерянное состояние. Поэтому исправление обычно в восстановлении состояния: храните состояние экрана в SavedStateHandle и rememberSaveable, восстанавливайте back stack навигации и никогда не держите единственную копию пользовательского ввода в поле Activity.
Воспроизводите это специально: уведите приложение в фон и выполните adb shell am kill <package>. Это убивает процесс, но сохраняет задачу, то есть делает ровно то, что происходит у пользователей. Force-stop означает другой, более жёсткий путь, и проверять нужно не его.
Чтобы вас убивали реже, будьте меньше: урезайте кэши в onTrimMemory, не держите в фоне большие bitmap и распарсенные данные и не держите foreground service, который не нужен.
Отслеживайте то, что чувствует пользователь: доли крашей и ANR, время старта, медленные и замёрзшие кадры, убийства из-за памяти и батарею. Core vitals в Play включают user-perceived crash rate (плохо выше 1,09%) и user-perceived ANR rate (плохо выше 0,47%), плюс 8% на любой отдельной модели телефона.
Core vitals в Play
User-perceived crash rate: доля ежедневных активных пользователей, у которых был краш во время использования приложения. Порог плохого поведения: 1,09% в целом, 8% на модель устройства.
User-perceived ANR rate: то же для ANR, которые пользователь заметил. Порог: 0,47% в целом, 8% на модель устройства.
Выше общего порога Play может снизить видимость приложения, а выше порога на устройство может показать предупреждение на странице в сторе для этой модели.
Другие метрики, которым нужен дашборд
Старт: время до первого и полного отображения, P50 и P90. Vitals помечает медленные холодные (от 5 с), тёплые (от 2 с) и горячие (от 1,5 с) старты.
Рендеринг: медленные кадры (дольше 16 мс) и замёрзшие кадры (дольше 700 мс) по экранам, из JankStats или Firebase Performance.
Память: убийства из-за нехватки памяти из ApplicationExitInfo, доля OOM-крашей.
Батарея: избыточные пробуждения, избыточные partial wake lock, фоновое использование сети.
Как ими пользоваться
Смотрите перцентили и срезы по устройствам, а не средние; длинный хвост живёт на слабых телефонах.
Делите по версиям приложения, выкатывайте поэтапно и останавливайте выкатку, когда vitals ухудшаются.
Лабораторные проверки (Macrobenchmark в CI) ловят регрессии до релиза; данные с поля подтверждают, что реально вышло.
Устройства на Android 15+ могут работать со страницами памяти 16 КБ вместо 4 КБ, и с ноября 2025 года Google Play требует, чтобы приложения с target Android 15+ их поддерживали. Для приложений с нативными файлами .so это значит пересобрать их с выравниванием ELF по 16 КБ.
Кого это касается
Приложению только с кодом на Kotlin или Java ничего делать не нужно; размер страницы берёт на себя ART.
Затронуто любое приложение с нативными библиотеками, включая те, что пришли с SDK: кодеки картинок и видео, ML-рантаймы, криптография, кроссплатформенные движки. Библиотека, у которой LOAD-сегменты выровнены по 4 КБ, не загрузится на устройстве с 16 КБ, и обычно это краш при старте.
Ломается и нативный код, в котором размер страницы зашит как 4096 (для смещений mmap или выравнивания аллокатора). Спрашивайте систему через sysconf(_SC_PAGESIZE).
Как сделать совместимым
AGP 8.5.1+ кладёт несжатые нативные библиотеки в APK с выравниванием по 16 КБ.
NDK r28+ по умолчанию собирает библиотеки с выравниванием 16 КБ. С r27 задайте ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES; более старым NDK нужен флаг линкера -Wl,-z,max-page-size=16384.
Сторонние SDK должны поставлять выровненные бинарники, поэтому их обновление часто оказывается самой долгой частью работы.
Как проверить
APK Analyzer в Android Studio и bundle explorer в Play Console помечают несовместимые библиотеки.
zipalign и llvm-objdump проверяют пакет и каждую библиотеку из командной строки.
Тестируйте на образе эмулятора Android 15+ со страницами 16 КБ или на Pixel с опцией разработчика, которая загружает ядро с 16 КБ.