Лишние рендеры, изображения и память

Вопрос уровня middle+: как найти причину тормозов и почему приложение падает без единой ошибки в логах.

Пропущенный кадр — это кадр, который не успел отрисоваться за отведённые ~16 мс (при 60 FPS). Пользователь воспринимает такие пропуски как «дёрганый» интерфейс.

Вопрос

«Экран подтормаживает, а на бюджетном Android приложение иногда закрывается само. Как искать причину?»

Что на самом деле проверяет интервьюер

Метод. Есть разница между «я добавляю useMemo, пока не станет быстрее» и «я снимаю профиль, вижу, что 80% времени уходит на рендер одного компонента, и работаю с ним».

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

Два разных «тормозит»

  • JS-поток перегружен: лишние рендеры, тяжёлые вычисления, парсинг больших ответов. Симптом — отклик на нажатия запаздывает, анимации на JS дёргаются.
  • UI-поток перегружен: слишком сложная иерархия view, дорогая отрисовка, тени и прозрачности, огромные картинки. Симптом — дёргается сам скролл, даже когда JS свободен.

Разделять их важно: лечение разное. В dev-меню есть Perf Monitor, который показывает FPS обоих потоков по отдельности — с него и стоит начинать.

Поиск лишних рендеров

React DevTools Profiler записывает коммиты и показывает, какие компоненты обновились, сколько заняли и почему. Быстрая ручная проверка тоже годится:

function Row({ item }) {
  if (__DEV__) console.log('render Row', item.id);
  // ...
}

Частые причины лавины рендеров: нестабильное значение в Context, состояние, поднятое слишком высоко, селекторы, возвращающие новые объекты, и renderItem, объявленный прямо в JSX.

Анимации: не занимать JS-поток

// нативный драйвер: JS не участвует в отрисовке кадров
Animated.spring(translateY, { toValue: 0, useNativeDriver: true }).start();

С useNativeDriver работают трансформации и прозрачность, но не свойства, влияющие на layout (width, height, top). Для жестов и сложных анимаций берут Reanimated: его worklet-функции выполняются прямо на UI-потоке, поэтому анимация не зависит от загруженности JS.

Изображения — главный пожиратель памяти

Декодированная картинка занимает примерно ширина × высота × 4 байта. Фотография 4000 на 3000 — это около 48 МБ в памяти, независимо от того, что на экране она размером с аватарку. Десять таких в списке — и Android убивает процесс, а на iOS приходит предупреждение о нехватке памяти.

  • Запрашивайте с сервера уже уменьшенные версии (thumbnails), а не оригиналы.
  • Используйте библиотеку с кэшем и прогрессивной загрузкой: expo-image или react-native-fast-image.
  • Не держите base64-строки в состоянии — это и память, и нагрузка на сериализацию.
  • Чистите кэш картинок при предупреждении о нехватке памяти.

Другие источники утечек

useEffect(() => {
  const id = setInterval(poll, 5000);
  const sub = eventEmitter.addListener('update', onUpdate);
  return () => {
    clearInterval(id);   // без очистки таймер живёт после ухода с экрана
    sub.remove();
  };
}, [poll, onUpdate]);

Классика: незакрытые таймеры и подписки, слушатели навигации без remove, растущие массивы в глобальном store, кэш без ограничения размера.

Про размер бандла и старт

Холодный старт — тоже метрика производительности, и о ней спрашивают. Помогают Hermes (байт-код заранее), ленивые импорты тяжёлых экранов, отказ от «библиотеки ради одной функции», отложенная инициализация аналитики и SDK после первого кадра.

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

  • Начинают с расстановки useMemo вместо измерения.
  • Не разделяют JS-поток и UI-поток, поэтому лечат не ту проблему.
  • Считают, что маленький размер файла картинки означает малое потребление памяти — важен размер в пикселях после декодирования.
  • Не помнят про ограничения useNativeDriver и обещают анимировать им height.

Как ответить кратко

«Сначала измеряю: Perf Monitor показывает, какой поток просел — JS или UI. Лишние рендеры ищу профайлером React DevTools; типичные причины — нестабильное значение Context, слишком высоко поднятое состояние, селекторы с новыми объектами и renderItem в JSX. Анимации увожу с JS-потока: useNativeDriver для трансформаций и прозрачности, Reanimated для жестов. Падения по памяти почти всегда из-за картинок: декодированное изображение занимает ширину на высоту на четыре байта, поэтому беру уменьшенные версии с сервера и библиотеку с кэшем. Плюс слежу за очисткой таймеров и подписок и за временем холодного старта.»

Проверьте себя
1. Что означает useNativeDriver: true в Animated?
AАнимация выполняется в отдельном JS-потоке
BОписание анимации один раз уходит на нативную сторону и проигрывается там, не требуя JS в каждом кадре
CАнимация становится синхронной
DВключается аппаратное ускорение видеокарты для всего экрана
2. Почему изображение 4000 на 3000 пикселей опасно для мобильного приложения, даже если показать его в аватарке 48 на 48?
AПотому что оно долго скачивается, но памяти не занимает
BПотому что в памяти оно разворачивается в несжатый bitmap на десятки мегабайт независимо от размера на экране
CПотому что iOS запрещает картинки больше 1000 пикселей
DПотому что JPEG нельзя масштабировать без потери качества
3. Какой инструмент показывает, какие компоненты перерисовались и сколько это заняло?
AMetro bundler
BReact DevTools Profiler
CFlipper Network plugin
DHermes bytecode inspector