Оптимизация скорости рендеринга UI мобильного приложения
На iPhone 13 приложение показывало 58–60 FPS в большинстве экранов, но один экран с кастомным UICollectionViewLayout стабильно проседал до 42–45 FPS при скролле. Instruments показывал, что 14 мс из 16 доступных уходило на layoutAttributesForElementsInRect — метод пересчитывал все позиции ячеек при каждом вызове без кэширования. Это классика: UI-рендеринг не тормозит «вообще», он тормозит в конкретном месте по конкретной причине.
Тормоза рендеринга — одна из самых коварных проблем, потому что они видны пользователю немедленно, но диагностируются медленно. FPS-метрика говорит «плохо», а где именно плохо — нужно раскапывать.
Где реально теряются кадры
Main thread — главный враг плавности
Золотое правило — 16 мс на кадр (60 FPS) или 8 мс (120 Hz на Pro-устройствах). Всё, что выполняется на main thread сверх этого, блокирует рендер. Типичные виновники:
На iOS: синхронная работа с CoreData через viewContext прямо в cellForItemAt, декодирование UIImage без preparingForDisplay(), NSAttributedString с вычислением размера в sizeForItemAt без кэша.
На Android: блокирующий I/O в onBindViewHolder, Bitmap.decodeResource() на main thread, тяжёлые Drawable анимации через AnimationDrawable на дешёвых устройствах с Mali GPU.
Особняком стоит проблема с measure/layout pass. На Android Jetpack Compose ConstraintLayout внутри LazyColumn с глубокой вложенностью запускает два полных прохода measure на каждую ячейку. На сложном списке с 50+ элементами это заметно даже на Pixel 7.
GPU overdraw
Overdraw — это когда один пиксель рисуется несколько раз за кадр. На Android включается через «Developer Options → Show GPU Overdraw»: синий — 1x, зелёный — 2x, розовый — 3x, красный — 4x+. Красный экран на бюджетном Xiaomi с Adreno 610 — гарантированный jank.
Частая причина — вложенные ViewGroup с непрозрачными фонами, где каждый слой рисует фон поверх предыдущего. На iOS аналог — CALayer с opaque = false там, где прозрачность не нужна, или shouldRasterize без явного rasterizationScale.
Как мы диагностируем и правим
Работа начинается с Xcode Instruments → Core Animation и Android GPU Inspector или встроенного Android Studio Profiler → Rendering. Не с догадок — с данных.
Типичный сценарий на iOS-проекте: клиент жалуется на «тормоза в ленте». Открываем Time Profiler, записываем скролл 5 секунд. В call tree сразу видно: [SDWebImage sd_setImageWithURL:] жрёт 8 мс на main thread потому что кто-то убрал options:SDWebImageAvoidAutoSetImage и изображения применяются синхронно после загрузки. Один флаг — и FPS вырос с 47 до 59.
На Android был кейс с RecyclerView + DiffUtil: разработчик вызывал submitList() из ViewModel, но DiffUtil работал на main thread (использовался ListAdapter без AsyncListDiffer). На списке из 200 элементов diff занимал ~18 мс. Перевели вычисление diff на фоновый поток через AsyncListDiffer — проблема исчезла.
Конкретные инструменты и техники
iOS:
-
CADisplayLink+ кастомный FPS-монитор в debug-сборке для постоянного мониторинга -
UIView.setNeedsLayout()vsUIView.layoutIfNeeded()— понимание разницы критично при анимациях -
drawRect:почти всегда заменяем наCALayersublayers — Core Animation рендерит их на GPU без участия CPU -
UIGraphicsImageRendererвместо устаревшегоUIGraphicsBeginImageContextWithOptionsдля offscreen rendering - Prefetching через
UICollectionViewDataSourcePrefetching— декодируем изображения до того, как ячейка появится на экране
Android / Compose:
-
Modifier.graphicsLayer {}для аппаратного ускорения трансформаций вместо программного -
remember {}иderivedStateOf {}— предотвращают лишние рекомпозиции -
key()вLazyColumn— без него Compose не может переиспользовать ноды при изменении списка -
Bitmap.Config.RGB_565вместоARGB_8888там, где альфа-канал не нужен — вдвое меньше памяти GPU
Flutter:
-
RepaintBoundaryвокруг виджетов, которые часто перерисовываются независимо -
constконструкторы — виджет не пересоздаётся при rebuild родителя -
flutter run --profile+ DevTools → Performance overlay — обязательный инструмент перед релизом
Кейс: 120 Hz на iPad Pro
Клиент сделал кастомную анимацию через UIViewPropertyAnimator с preferredFrameRateRange. Анимация работала на 60 FPS вместо 120. Оказалось — один CALayer с shouldRasterize = true без явного указания rasterizationScale = UIScreen.main.scale * 2. Core Animation ограничивал весь subtree до 60 FPS из-за несоответствия масштаба растеризации. После правки анимация заработала на 120 FPS с заметной разницей в ощущениях.
Этапы работы
- Аудит — записываем сессии в Instruments / Android Profiler, собираем baseline-метрики FPS, janky frames, frame time
- Анализ — выявляем узкие места: main thread блокировки, overdraw, лишние layout passes
- Правки — итерационно, с замером после каждого изменения
- Регрессионный прогон — проверяем, что правка не сломала соседние экраны
- Мониторинг — интегрируем Firebase Performance или собственный FPS-монитор для отслеживания в продакшене
Оцениваем объём после аудита — иногда проблема решается за день, иногда требует переписывания кастомного layout.
Ориентиры по срокам
Точечная правка (один экран, понятная причина) — 1–3 дня. Системный аудит и оптимизация нескольких экранов — 1–3 недели. Если проблема в архитектурных решениях (неправильное использование main thread по всему приложению) — закладывайте 3–6 недель с поэтапной миграцией.







