RecyclerView и DiffUtil, утечки памяти и ANR

Финальный блок собеседования — про то, почему приложение тормозит, течёт и падает. Здесь ценят конкретику: инструменты, цифры, реальные истории.

ANR (Application Not Responding) — системный диалог «приложение не отвечает». Возникает, когда главный поток заблокирован дольше порога: примерно 5 секунд для реакции на ввод, 10 секунд для широковещательного приёмника на переднем плане, 20 секунд для сервиса.

Вопрос 1: «Как работает RecyclerView и зачем DiffUtil?»

Что проверяет интервьюер

Понимаете ли вы, что RecyclerView экономит не память под данные, а создание View — самую дорогую операцию в списке.

Развёрнутый ответ

RecyclerView держит пул переиспользуемых ViewHolder. Когда элемент уезжает за границу экрана, его holder не уничтожается, а возвращается в пул и затем привязывается к новым данным через onBindViewHolder. Инфляция разметки (onCreateViewHolder) происходит лишь для нескольких элементов, помещающихся на экран, плюс небольшой запас.

Отсюда важное практическое следствие: onBindViewHolder обязан быть дешёвым. Он вызывается на каждый кадр скролла. Загрузка изображений, форматирование дат, создание новых объектов и слушателей там — прямая дорога к рывкам (jank).

notifyDataSetChanged() сообщает «изменилось всё»: RecyclerView перепривязывает все видимые элементы и не может показать анимации вставки, удаления и перемещения. DiffUtil вычисляет минимальный набор изменений между старым и новым списком, а ListAdapter делает это в фоновом потоке:

class ArticleAdapter :
    ListAdapter<Article, ArticleViewHolder>(DIFF) {

    companion object {
        private val DIFF = object : DiffUtil.ItemCallback<Article>() {
            // тот же ли это объект по смыслу
            override fun areItemsTheSame(old: Article, new: Article) = old.id == new.id

            // изменилось ли содержимое (для data class достаточно ==)
            override fun areContentsTheSame(old: Article, new: Article) = old == new
        }
    }
}

// обновление списка: адаптер сам посчитает разницу в фоне
adapter.submitList(newArticles)

Два уточняющих вопроса, которые задают следом:

  • Почему список нужно передавать новый, а не мутировать старый? DiffUtil сравнивает две ссылки. Если изменить тот же самый список, старая и новая версии совпадут, и разницы не найдётся — список «не обновится».
  • Что такое payload? Третий метод getChangePayload позволяет сказать «изменилось только количество лайков» и перерисовать одно поле вместо всего holder.

Вопрос 2: «Откуда берутся утечки памяти в Android?»

Общий принцип один: объект с долгим временем жизни держит ссылку на объект с коротким. В Android «короткий» — это почти всегда Activity, Fragment или View, за которыми тянется вся иерархия и битмапы.

ПричинаКак выглядитЛечение
Context в синглтонеobject Manager { var context: Context }только applicationContext
Нестатический внутренний классHandler, Runnable внутри Activityстатический класс + WeakReference, removeCallbacks
Неснятая подпискаслушатель, BroadcastReceiver, callback библиотекиотписка в парном колбэке
binding во фрагментессылка на View после onDestroyView_binding = null
корутина вне scopeGlobalScope.launch с ссылкой на UIviewModelScope, lifecycleScope
незавершённая анимациябесконечный ValueAnimatorостановка в onStop
// Классика: отложенная задача переживает экран
class MainActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())
    private val task = Runnable { updateUi() }   // держит ссылку на Activity

    override fun onStart() {
        super.onStart()
        handler.postDelayed(task, 60_000)
    }

    override fun onStop() {
        super.onStop()
        handler.removeCallbacks(task)   // без этой строки — утечка на минуту
    }
}

Инструменты: LeakCanary в debug-сборке ловит утечки автоматически и показывает цепочку удержания; Memory Profiler в Android Studio даёт снимок кучи, где после закрытия экрана видно несколько живых экземпляров одной Activity — самый наглядный симптом.

Вопрос 3: «Что такое ANR и как его расследовать?»

ANR — следствие блокировки главного потока. Причины: синхронная сеть или база в UI-потоке, тяжёлые вычисления, взаимная блокировка (deadlock), долгая работа в onCreate приложения, ожидание Binder-вызова к чужому зависшему процессу.

Порядок расследования, который хотят услышать:

  1. Android vitals в Google Play Console — реальная частота ANR у пользователей и стеки главного потока.
  2. Трейс ANR из /data/anr/traces.txt: показывает, на какой строке стоял главный поток.
  3. StrictMode в debug-сборке — ловит дисковые и сетевые операции в главном потоке ещё на этапе разработки.
  4. Perfetto / System Trace и Macrobenchmark — для jank и медленных кадров.
// включаем StrictMode только в debug: падает при I/O на главном потоке
if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .build()
    )
}

Рядом с ANR обычно спрашивают про jank: чтобы держать 60 кадров в секунду, на кадр есть примерно 16 миллисекунд (на экране 120 Гц — около 8). Всё, что не уложилось, — пропущенный кадр и заметный рывок при скролле.

Типичные ошибки кандидатов

  • Считают, что notifyDataSetChanged() «просто чуть менее оптимален» — на деле он убивает анимации и перепривязывает всё видимое.
  • Мутируют список и вызывают submitList с той же ссылкой — обновления не происходит.
  • Грузят картинки и форматируют даты в onBindViewHolder.
  • Называют утечкой любое высокое потребление памяти. Утечка — это конкретно недостижимый для GC объект, который должен был умереть.
  • На вопрос про ANR отвечают «делать всё в фоне», не называя ни одного инструмента диагностики.

Как ответить кратко (20–30 секунд)

«RecyclerView переиспользует ViewHolder через пул, поэтому инфляция происходит только для видимых элементов, а onBindViewHolder обязан быть дешёвым — он вызывается на каждый кадр скролла. Вместо notifyDataSetChanged использую ListAdapter с DiffUtil: он считает минимальный набор изменений в фоне и даёт корректные анимации; список при этом обязательно передаю новый, иначе разницы не найдётся. Утечки — это когда долгоживущий объект держит короткоживущий: Context в синглтоне, необнулённый binding во фрагменте, неснятые колбэки Handler, корутина вне scope. Ищу их LeakCanary и Memory Profiler. ANR — блокировка главного потока дольше пяти секунд; предотвращаю StrictMode в debug, а расследую по Android vitals и трейсам ANR.»

Проверьте себя
1. Почему при использовании ListAdapter нужно передавать в submitList новый список, а не изменять существующий?
AListAdapter кэширует размер списка и не пересчитывает его
BDiffUtil сравнивает старую и новую версии: при мутации того же объекта разницы не будет и UI не обновится
Cмутация списка вызывает ConcurrentModificationException
DsubmitList работает только с неизменяемыми коллекциями из kotlinx
2. Что из перечисленного действительно является утечкой памяти?
Aприложение занимает 200 МБ из-за кэша изображений
Bобъект Activity остаётся достижимым из статического поля после закрытия экрана
Cво время скролла память кратковременно растёт и падает после GC
DRoom держит открытое соединение с базой данных
3. Какой инструмент поможет ещё на этапе разработки обнаружить чтение с диска в главном потоке?
ALeakCanary
BDiffUtil
CStrictMode с detectDiskReads в debug-сборке
DLayout Inspector