Irbisa · Шпаргалка

Производительность Android и память

Профилировщики, leak-и, ANR, baseline profiles, R8, startup, перформанс рекомпозиции

Senior-разработчик · 25 вопросов

  1. 01

    Что такое ANR, из-за чего он возникает и как его избежать?

    ANR (Application Not Responding) система фиксирует, когда главный поток заблокирован слишком долго: около 5 секунд для необработанного input-события, около 10 секунд для BroadcastReceiver на переднем плане.

  2. 02

    Этот обработчик клика вызывает ANR на медленной сети, хотя loadUser() переключается на Dispatchers.IO. Почему?

    class ProfileActivity : ComponentActivity() {  private val repo = UserRepository()   override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContentView(R.layout.activity_profile)    findViewById<Button>(R.id.refresh).setOnClickListener {      // loadUser() is a suspend fun that uses withContext(Dispatchers.IO)      val user = runBlocking { repo.loadUser() }      findViewById<TextView>(R.id.name).text = user.name    }  }}

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

  3. 03

    Что такое StrictMode и как он ловит чтение с диска на главном потоке или незакрытый Cursor?

    Отладочный инструмент StrictMode вы включаете в Application.onCreate: его политика потока сообщает об обращениях к диску и сети на главном потоке, а политика VM сообщает о незакрытых ресурсах, утёкших Activity и похожих ошибках.

    class MyApp : Application() {  override fun onCreate() {    if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) enableStrictMode()   // debug only, never in release    super.onCreate()  }  private fun enableStrictMode() {…
  4. 04

    Какие частые причины утечек памяти на Android, и как их найти и исправить?

    За утечкой на Android почти всегда стоит что-то долгоживущее (синглтон, статическое поле, работающий колбэк), что всё ещё ссылается на уничтоженные Activity, Fragment или View, поэтому весь экран никогда не может быть собран сборщиком мусора.

    // wrong: ImageCache.context = this  (the singleton keeps the whole Activity)ImageCache.context = applicationContext   // right: long-lived objects get this // Register and unregister in mirrored callbacksclass MapActivity : ComponentActivity() {  private val listener = LocationListener { location -> render(location) }…
  5. 05

    LeakCanary сообщает, что ProfileActivity удерживается после каждого поворота экрана. Что в этом коде её держит?

    object SessionManager {  private val listeners = mutableListOf<(Session) -> Unit>()  fun addListener(listener: (Session) -> Unit) { listeners += listener }  fun removeListener(listener: (Session) -> Unit) { listeners -= listener }} class ProfileActivity : ComponentActivity() {  override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContentView(R.layout.activity_profile)    SessionManager.addListener { session ->      findViewById<TextView>(R.id.name).text = session.userName    }  }}

    Лямбда вызывает findViewById у Activity, значит, захватывает this, а SessionManager является синглтоном на весь процесс, который хранит лямбду вечно.

  6. 06

    Как LeakCanary находит утечку под капотом?

    Он держит каждую уничтоженную Activity, Fragment, ViewModel и Service через WeakReference, после короткой паузы запускает GC, и если ссылка всё ещё не очищена, снимает heap dump и ищет кратчайший путь сильных ссылок до объекта.

  7. 07

    Как работает сборка мусора на Android, и что такое strong, soft, weak и phantom ссылки?

    ART освобождает объекты, до которых больше нельзя дойти от GC roots (стеки потоков, статические поля, JNI-ссылки); тип каждой ссылки решает, держит ли она объект живым.

  8. 08

    Этот кастомный график дёргается во время анимации, LeakCanary молчит, а график памяти похож на пилу. Что не так?

    class Sparkline(context: Context) : View(context) {  var points: List<Float> = emptyList()   override fun onDraw(canvas: Canvas) {    val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {      color = Color.BLUE; strokeWidth = 4f; style = Paint.Style.STROKE    }    val path = Path()    val step = width / (points.size - 1).coerceAtLeast(1).toFloat()    points.forEachIndexed { i, y -> if (i == 0) path.moveTo(0f, y) else path.lineTo(i * step, y) }    canvas.drawPath(path, paint)    canvas.drawText("max ${points.maxOrNull()}", 8f, 24f, paint)  }}

    onDraw вызывается на каждом кадре анимации, и здесь он каждый раз создаёт Paint, Path и String (плюс упакованный Float из maxOrNull()).

  9. 09

    Зачем в Android есть SparseArray и ArrayMap и когда они лучше HashMap?

    SparseArray и ArrayMap хранят ключи и значения в плоских массивах и ищут ключ бинарным поиском, поэтому не упаковывают ключи и не создают объект-узел на каждую запись; они занимают меньше памяти и меньше аллоцируют, но поиск занимает O(log n), так что выигрывают на небольших коллекциях (от сотен до нескольких тысяч записей) и проигрывают на больших.

    // Int keys: no Integer boxing, no entry node per itemval names = SparseArray<String>()names.put(42, "Ada")val name = names.get(42)           // binary search over a sorted IntArray // Object keys: flat arrays instead of hash buckets…
  10. 10

    Почему большие bitmap вызывают OutOfMemoryError, и чем OOM отличается от утечки памяти?

    Декодированный bitmap стоит ширина × высота × байты на пиксель, независимо от размера файла: фото 4000×3000 весит 2 МБ в JPEG, но 48 МБ в ARGB_8888.

    // Decode only as many pixels as the view will showfun decodeScaled(path: String, reqWidth: Int, reqHeight: Int): Bitmap? {  val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }  BitmapFactory.decodeFile(path, bounds)       // reads the header, allocates no pixels   var sample = 1…
  11. 11

    Что такое onTrimMemory() и onLowMemory(), и как приложению реагировать на нехватку памяти?

    Это колбэки памяти, которые система присылает вашему Application, Activity и Service, когда ваш UI скрыт или памяти мало.

    class App : Application() {  private val imageCache: LruCache<String, Bitmap> by lazy {    val am = getSystemService(ActivityManager::class.java)    val maxBytes = am.memoryClass * 1024 * 1024 / 8      // 1/8 of the Java heap limit    object : LruCache<String, Bitmap>(maxBytes) {      override fun sizeOf(key: String, value: Bitmap) = value.allocationByteCount…
  12. 12

    Что такое jank, как измерить пропущенные кадры и что обычно их вызывает?

    Под jank понимают кадр, который не уложился в дедлайн дисплея (16,7 мс при 60 Гц, 8,3 мс при 120 Гц), поэтому экран снова показывает предыдущий кадр, и пользователь видит рывок.

    class FeedActivity : ComponentActivity() {  private lateinit var jankStats: JankStats   override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContent { FeedScreen() }…
  13. 13

    Что такое overdraw, как его заметить и что убрать, чтобы его уменьшить?

    Overdraw возникает, когда GPU за один кадр закрашивает один и тот же пиксель несколько раз (фон окна, под ним фон экрана, под ним карточка), и на дешёвом телефоне это потраченное впустую заполнение съедает бюджет кадра.

    // wrong: window background + Surface + Box background paint the same pixelsSurface(color = MaterialTheme.colorScheme.background) {  Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {    Card(Modifier.padding(16.dp)) { Text("Hi") }  }}…
  14. 14

    Что такое холодный, тёплый и горячий старт, и как измерить и ускорить холодный старт?

    Холодный старт создаёт процесс, Application и первую Activity с нуля; тёплый переиспользует живой процесс, но пересоздаёт Activity; горячий только выводит существующую Activity на передний план.

    // App Startup: library initializers run in one provider, in dependency orderclass AnalyticsInitializer : Initializer<Analytics> {  override fun create(context: Context): Analytics = Analytics.init(context)  override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()}…
  15. 15

    Что такое Baseline Profiles, как они ускоряют запуск и как доказать выигрыш?

    Baseline Profile представляет собой список классов и методов, поставляемый внутри приложения, который ART компилирует заранее при установке, чтобы запуск и первые прокрутки шли на скомпилированном коде, а не интерпретировались и JIT-компилировались на лету.

    @RunWith(AndroidJUnit4::class)class BaselineProfileGenerator {  @get:Rule val rule = BaselineProfileRule()   @Test  fun generate() = rule.collect(…
  16. 16

    Что R8 делает с release-сборкой, что такое keep-правила и что ломается, когда они неверны?

    R8 за один проход сжимает, оптимизирует и обфусцирует ваш код (а с isShrinkResources также и ресурсы) при сборке релиза.

    // app/build.gradle.ktsandroid {  buildTypes {    release {      isMinifyEnabled = true          // R8: shrink + optimize + obfuscate      isShrinkResources = true        // requires isMinifyEnabled…
  17. 17

    Как уменьшить размер загрузки приложения и откуда обычно берётся вес?

    Публикуйте Android App Bundle, включите R8 со сжатием ресурсов и найдите самые тяжёлые части в APK Analyzer: обычно это нативные библиотеки, картинки или одна раздутая зависимость.

  18. 18

    Пользователи жалуются, что приложение сажает батарею. Как найти, что оно делает в фоне, и исправить это?

    Жалобы на батарею почти всегда вызывает работа, которую приложение делает, пока на него никто не смотрит: пробуждения, поллинг, геолокация или wake lock, который так и не отпустили.

    val sync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)  .setConstraints(    Constraints.Builder()      .setRequiredNetworkType(NetworkType.UNMETERED)      .setRequiresBatteryNotLow(true)      .build()…
  19. 19

    Microbenchmark, Macrobenchmark и JankStats: на какой вопрос отвечает каждый, и как добиться, чтобы цифрам можно было доверять?

    Они измеряют три масштаба: одну функцию, один пользовательский сценарий и сессию одного реального пользователя.

    @RunWith(AndroidJUnit4::class)class StartupBenchmark {  @get:Rule val rule = MacrobenchmarkRule()   @Test fun coldStartNoProfile() = measure(CompilationMode.None())        // worst case  @Test fun coldStartBaselineProfile() = measure(CompilationMode.Partial()) // what users get…
  20. 20

    Главный поток укладывается со своей работой над кадром в 5 мс, а пользователи всё равно видят рывки на 120 Гц. Что ещё входит в бюджет кадра?

    Кадр проходит конвейер из главного потока, RenderThread и GPU, и каждая стадия должна уложиться в один интервал vsync (8,3 мс при 120 Гц).

    class MainActivity : ComponentActivity() {  private val frameThread = HandlerThread("frames").apply { start() }   override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContent { AppTheme { Home() } }…
  21. 21

    Найдите баг: список цен иногда замирает на несколько секунд и заканчивается ANR, хотя current() всего лишь возвращает закэшированный список.

    class PriceRepository(private val api: PriceApi) {  private val lock = Any()  private var cached: List<Price> = emptyList()   // Called by a periodic worker on a background thread.  fun refresh() = synchronized(lock) {    cached = api.fetchPrices() // 2-10 s on a slow network  }   // Called on the main thread when the list screen binds.  fun current(): List<Price> = synchronized(lock) { cached }}

    refresh() держит лок на всё время сетевого вызова, а current() нужен тот же лок.

  22. 22

    Play Vitals показывает, что приложение убивают из-за памяти, но Java-куча в профайлере не превышает 40 МБ. Где остальное?

    Low-memory killer смотрит на весь объём памяти процесса, а нативные выделения, графическая память, код и стеки потоков в него входят, хотя ни одно из них не видно на графике Java-кучи.

    // The same summary as `adb shell dumpsys meminfo <package>`, from inside the appfun logFootprint(context: Context) {  val am = context.getSystemService(ActivityManager::class.java)  val info = am.getProcessMemoryInfo(intArrayOf(Process.myPid())).first()   val kb = listOf("java-heap", "native-heap", "graphics", "code", "stack", "total-pss")…
  23. 23

    Тестировщики говорят, что приложение «перезапускается само», когда они в него возвращаются, а в Crashlytics нет крашей. Как выяснить, что произошло?

    Система записывает, как завершился предыдущий процесс, а ApplicationExitInfo (API 30+) позволяет прочитать эту запись при следующем запуске.

    class MyApp : Application() {  override fun onCreate() {    super.onCreate()    reportLastExit()  }…
  24. 24

    Какие метрики производительности вы отслеживаете в проде и какие пороги требует Google Play?

    Отслеживайте то, что чувствует пользователь: доли крашей и ANR, время старта, медленные и замёрзшие кадры, убийства из-за памяти и батарею.

  25. 25

    Что меняет для приложения требование поддержки страниц памяти 16 КБ и как сделать нативный код совместимым?

    Устройства на Android 15+ могут работать со страницами памяти 16 КБ вместо 4 КБ, и с ноября 2025 года Google Play требует, чтобы приложения с target Android 15+ их поддерживали.

    # Are the native libraries 16 KB-aligned inside the APK? (build-tools 35+)zipalign -c -P 16 -v 4 app-release.apk # ELF alignment of one library: every LOAD segment must show align 2**14 (16 KB)unzip -o app-release.apk 'lib/arm64-v8a/*' -d outllvm-objdump -p out/lib/arm64-v8a/libfoo.so | grep LOAD…