Оптимізація анімацій для досягнення 60 FPS у мобільній програмі
60 FPS — це 16.67 мс на кадр. Якщо хоч один кадр займе 17+ мс, Instruments показує dropped frame. На 120 Гц-дисплеях (ProMotion) поріг ще жорсткіший: 8.3 мс. Користувачі iPhone 13 Pro та Pixel 8 це фізично відчувають.
Де теряються кадри: діагностика спочатку
Перше, ніж що-то оптимізувати — відкрийте Xcode Instruments з шаблоном Core Animation. Запустіть на реальному пристрої (симулятор не рахується: інший GPU). Дивіться на два графіки: FPS та CPU Usage. Червоні стовпці на FPS-графіку — dropped frames.
На Android — Android Profiler у режимі GPU/CPU + Systrace для детального трейса. В Developer Options включаємо Profile GPU Rendering (відображає стовпці на екрані): якщо оранжева зона (Draw) та червона (Sync/Upload) регулярно перевищують 16ms-лінію — є проблема.
Типова знахідка: анімація тені (shadowRadius, shadowOffset на CALayer) перераховує Gaussian blur на CPU кожен кадр. На iPhone SE 2nd gen це може давати 8–10 мс тільки на тінь.
Головний принцип: GPU-шари проти CPU-рендеру
Анімуйте лише transform та opacity — не рекомендація, це закон продуктивності. Ці властивості обробляються Compositor thread напрямки, без участі main thread та без виклику drawRect:.
Все інше запускає Layout → Display → Prepare → Commit цикл:
| Властивість | Де малюється | Dropped frames |
|---|---|---|
transform, opacity |
GPU Compositor | Ні |
backgroundColor |
GPU (CALayer) | Рідко |
bounds, frame |
CPU → GPU | Часто |
cornerRadius + masksToBounds |
CPU (offscreen) | Часто |
shadowPath (статична) |
GPU | Ні |
shadowRadius (динамічна) |
CPU | Дуже часто |
cornerRadius з masksToBounds = true — offscreen rendering. Для кожного такого шару Core Animation робить додатковий render pass. В Instruments: Debug → Color Offscreen-Rendered фарбує їх жовтим. Виправлення: встановити layer.shadowPath статично або використовувати маску з векторного зображення.
UIKit: конкретні прийоми
shouldRasterize — обережно
layer.shouldRasterize = true кешує шар як bitmap. Допомагає якщо контент не змінюється. Вбиває, якщо змінюється: кеш інвалідується кожен кадр та перерисовується дорожче, ніж без нього. Перевіряємо через Instruments → Color Hits Green and Misses Red: червоне = інвалідація, допомоги нема.
drawRect vs CALayer
Переопределення drawRect: — ядерний варіант. Якщо виклик відбувається під час анімації (наприклад, змінюється bounds), main thread зайнятий рисуванням. Альтернатива: виносити статичний контент до окремого CALayer з contents = image.cgImage, анімувати лише transform.
CADisplayLink для користувацьких анімацій
Якщо пишемо користувацьку анімацію на CADisplayLink — прив'язуємося до preferredFramesPerSecond:
let displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 60,
maximum: 120,
preferred: 120
)
displayLink.add(to: .main, forMode: .common)
На ProMotion пристроях це дозволяє анімаціям працювати на 120 FPS. Без вказання range система може зафіксувати 60 навіть на 120 Гц дисплеї.
Lottie: часті проблеми з продуктивністю
Lottie за умовчанням використовує .automatic render mode. На складних анімаціях з масками та trim paths це часто означає CPU-рендер. Примусово переключаємо:
animationView.renderingEngine = .coreAnimation
Core Animation engine (.coreAnimation) рендерить через CALayers — без участі main thread. Обмеження: не підтримує деякі складні ефекти (gradients через trim paths, деякі blending modes). Перевіряємо в Lottie Diagnostics.
Compose: рекомендації
Modifier.graphicsLayer замість прямого змінення layout-параметрів:
// Погано: викликає relayout на кожен кадр
Box(modifier = Modifier.size(animatedSize))
// Добре: лише GPU transform, layout стабільна
Box(modifier = Modifier
.size(100.dp)
.graphicsLayer { scaleX = animatedScale; scaleY = animatedScale }
)
graphicsLayer працює аналогічно layer.transform у UIKit — поза layout pass.
Уникайте remember { mutableStateOf() } всередині анімаційної лямбди. Кожне оновлення стану через mutableStateOf викликає recomposition. Використовуйте Animatable напрямки, або animateFloatAsState який оновлює лише graphicsLayer без recompose екрана.
Типові випадки оптимізації
В програмі одного клієнта список з користувацькими ячейками падав до 40 FPS при скролі. Причина: кожна ячейка мала layer.cornerRadius = 12 з masksToBounds = true та layer.shadowRadius = 8. Подвійний offscreen render pass на кожну ячейку. Рішення: corner radius через UIBezierPath маску (один GPU pass), тінь через shadowPath з заздалегідь розрахованим CGPath. FPS повернувся на 60 стабільно.
Процес оптимізації
Профілювання в Instruments / Android Profiler на реальних пристроях (мінімум один повільний девайс з цільової аудиторії). Ідентифікація offscreen rendering, expensive draw calls, CPU-анімацій. Послідовне усунення з вимірюванням після кожної зміни. Регресійне тестування на пристроях різних класів.
Орієнтири по часу
Аудит та виправлення анімацій у існуючій програмі — 2–5 днів залежно від масштабу та числа проблемних місць.







