Оптимізація швидкості рендеринга 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 тижнів з поетапною міграцією.







