Одного разу наш клієнт зіткнувся з проблемою: на iPhone 13 додаток показував 58–60 FPS на більшості екранів, але один екран з кастомним UICollectionViewLayout стабільно просідав до 42–45 FPS при скролі. Ми запустили Instruments і виявили, що 14 мс з 16 доступних йшло на layoutAttributesForElementsInRect — метод перераховував всі позиції комірок без кешування. Це класика: UI-рендеринг не гальмує «взагалі», він гальмує в конкретному місці з конкретної причини. Після впровадження простого кешу FPS повернувся до 60, і клієнт отримав плавний інтерфейс за один день роботи. Оптимізація швидкості рендерингу 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.
Як діагностувати проблеми рендерингу?
Діагностику починаємо з профілювання на реальному пристрої. На iOS використовуємо Xcode Instruments (шаблон Core Animation), на Android — GPU Inspector або Android Studio Profiler. Для Flutter — flutter run --profile з DevTools Performance overlay. Збираємо baseline: середній FPS, кількість janky frames, frame time distribution. Якщо середній FPS нижче 55 — шукаємо точки блокування.
Чому main thread — головний ворог плавності? — оптимізація швидкості рендерингу
Кожна операція на main thread понад 16 мс (або 8 мс для 120 Hz) викликає пропуск кадру. Наприклад, виклик sd_setImageWithURL: без прапорця SDWebImageAvoidAutoSetImage жере 8 мс на main thread — ми це бачили на проекті, де FPS впав з 55 до 47. Заміна на правильну опцію повернула плавність.
Що таке overdraw і як його знайти?
Overdraw — коли один піксель малюється кілька разів за кадр. На Android вмикається через «Developer Options → Show GPU Overdraw»: синій — 1×, зелений — 2×, рожевий — 3×, червоний — 4×+. Червоний екран на бюджетному Xiaomi з Adreno 610 — гарантований jank.
Часта причина — вкладені ViewGroup з непрозорими фонами, де кожен шар малює фон поверх попереднього. На iOS аналог — CALayer з opaque = false там, де прозорість не потрібна, або shouldRasterize без явного rasterizationScale.
Оптимізація швидкості рендерингу UI: покроковий план
Що входить в роботу
- Аудит зі звітом: скріншоти профілів, список проблемних місць, baseline-метрики.
- Правки в коді з коментарями для розробників.
- Рекомендації щодо архітектури та інструментів для довгострокового підтримання FPS.
- Моніторинг — інтеграція Firebase Performance або власного FPS-монітора.
- Гарантія на досягнення цільового FPS протягом 30 днів після здачі.
Як ми це робимо: конкретний кейс
Типовий сценарій на 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 — проблема зникла. AsyncListDiffer в 3 рази швидший за синхронний DiffUtil на списках з 500 елементів.
Конкретні інструменти та техніки
iOS:
-
CADisplayLink + кастомний FPS-монітор у debug-збірці для постійного моніторингу
-
UIView.setNeedsLayout() vs UIView.layoutIfNeeded() — розуміння різниці критичне при анімаціях
-
drawRect: майже завжди замінюємо на CALayer sublayers — 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 з помітною різницею у відчуттях. Core Animation Programming Guide рекомендує завжди явно задавати rasterizationScale для растрованих шарів.
Порівняння інструментів профілювання
| Інструмент |
Платформа |
Типове використання |
Складність |
| Xcode Instruments (Core Animation) |
iOS |
Вимір FPS, виявлення overdraw та блокувань main thread |
Середня |
| Android GPU Inspector |
Android |
Кадрова трасування, аналіз навантаження на GPU |
Висока |
| Android Studio Profiler (Rendering) |
Android |
Швидкий перегляд frame time та рекомендації |
Низька |
| Flutter DevTools Performance |
Flutter |
Overlay FPS та шкала часу з ребілдами |
Низька |
Етапи роботи
- Аудит — записуємо сесії в Instruments / Android Profiler, збираємо baseline-метрики FPS, janky frames, frame time.
- Аналіз — виявляємо вузькі місця: main thread блокування, overdraw, зайві layout passes.
- Правки — ітераційно, з виміром після кожної зміни.
- Регресійний прогін — перевіряємо, що правка не зламала сусідні екрани.
- Моніторинг — інтегруємо Firebase Performance або власний FPS-монітор для відстеження в продакшені.
Оцінюємо обсяг після аудиту — іноді проблема вирішується за день, іноді вимагає переписування кастомного layout. Ми маємо 8+ років досвіду в мобільній розробці та виконали понад 50 проектів з оптимізації UI. Наші клієнти економлять до 40% бюджету на доробках у порівнянні з повним переписуванням коду.
Орієнтири за термінами
Точкова правка (один екран, зрозуміла причина) — 1–3 дні. Системний аудит та оптимізація кількох екранів — 1–3 тижні. Якщо проблема в архітектурних рішеннях (неправильне використання main thread по всьому додатку) — закладайте 3–6 тижнів з поетапною міграцією.
Отримайте консультацію по вашому проекту — оцінимо обсяг робіт та запропонуємо оптимальне рішення. Зв'яжіться з нами для старту аудиту.
Оптимізація мобільних додатків: cold start, пам'ять, батарея, FPS, профілювання
Ми стикаємося з додатками, де cold start триває 4+ секунди — користувачі йдуть до конкурентів ще до першого екрану. Android Vitals у Google Play Console безпосередньо впливають на ранжування, Apple аналогічно моніторить crash rate та час запуску через MetricKit. Оптимізація — це не «зробити швидше», а знайти конкретне місце втрати часу та усунути його. Наш досвід 7+ років та понад 50 оптимізованих застосунків дозволяє гарантувати результат: зменшення часу запуску на 30–60% при помірному навантаженні на команду.
Чому cold start критичний для бізнесу?
Cold start — запуск додатку, коли процес не існує в пам'яті. На Android це час від натискання на іконку до Activity.onWindowFocusChanged(hasFocus = true). На iOS — від tap до viewDidAppear першого екрану. При cold start більше 2 секунд 60% користувачів закривають додаток — це прямі втрати конверсії. Наприклад, у фінансовому додатку, який ми оптимізували, cold start зменшився з 4.3 с до 1.2 с, що дало +15% до добової активності.
Як виміряти час холодного старту?
Інструмент для діагностики: Android Studio Profiler → App Startup. Показує граф ініціалізації з часом кожного компонента. Альтернатива — Tracing.beginSection("MyInitTag") в коді + systrace.
Рішення: App Startup Library (Jetpack) з явним графом залежностей ініціалізаторів. Компоненти, потрібні лише в конкретних сценаріях, ініціалізуються ліниво — by lazy {} або initializer з флагом lazyInit. Firebase Analytics, наприклад, не потрібен до першої дії користувача — його ініціалізацію можна відкласти.
ContentProvider-и, що автоматично додаються SDK через AndroidManifest merge, теж запускаються при старті. tools:node="remove" в маніфесті дозволяє вимкнути конкретний провайдер та ініціалізувати SDK вручну в потрібний момент.
Ще одна грабля: Room.databaseBuilder().build() на main thread. Це синхронна операція створення/відкриття файлу БД — на повільних пристроях займає 50–300 мс. Переносимо в coroutine з Dispatchers.IO, в ViewModel через viewModelScope.launch.
На iOS cold start ділиться на pre-main (до виклику main()) та post-main. Pre-main — час завантаження dylib, rebase/binding, Objective-C runtime initialization та виконання +load методів.
Xcode Instruments → App Launch template показує час pre-main та post-main окремо. DYLD_PRINT_STATISTICS=1 в схемі запуску виводить детальний час завантаження в консоль.
Що вбиває pre-main:
- Багато динамічних бібліотек (кожна dylib — накладні витрати на лінковку). CocoaPods додає окрему dylib на кожен pod. Рішення: Swift Package Manager зі статичною лінковкою (
type: .static) або use_frameworks! :linkage => :static в CocoaPods — це зменшує pre-main час на 40% порівняно з динамічним лінкуванням.
-
+load методи в Objective-C — виконуються синхронно при завантаженні класу, до main(). Сторонні SDK можуть зловживати цим. +initialize — лінивий аналог, викликається при першому зверненні до класу.
Post-main — application(_:didFinishLaunchingWithOptions:). Та ж історія що на Android: синхронна ініціалізація всього. lazy var для сервісів, які не потрібні негайно. SwiftUI @StateObject ініціалізує об'єкт тільки коли View з'являється — це вже вбудована лінивість.
Цільові метрики (App Store рекомендації): cold start < 400 мс для простих додатків, < 2 секунди для складних. Warm start (процес в пам'яті, але Activity/Scene перестворюється) — < 1 секунда.
Пам'ять: витоки, OOM, excessive pressure
Витік пам'яті в iOS — retention cycle: об'єкт A тримає посилання на B, B тримає на A, жоден не звільняється. Класика: Timer з self в замиканні без [weak self]. Timer утримує замикання, замикання утримує self (ViewController), ViewController не звільняється при закритті. Instruments → Leaks або Memory Graph Debugger в Xcode — знаходить живі об'єкти, яких не повинно бути.
Що робити при витоках пам'яті?
На Android garbage collector керує пам'яттю, але витоки все одно трапляються. Activity або Fragment, що утримуються через статичне посилання, singleton, або Handler/Runnable після onDestroy — класика. LeakCanary — обов'язковий інструмент в debug-збірці. Додається однією залежністю debugImplementation "com.squareup.leakcanary:leakcanary-android" та автоматично детектує витоки з повним стектрейсом.
OutOfMemoryError найчастіше виникає через завантаження зображень. Bitmap в пам'яті займає ширина × висота × 4 байти. Зображення 4000×3000 px — 48 МБ в пам'яті, незалежно від розміру файлу на диску. Glide / Coil правильно обробляють це: завантажують з даунсемплінгом під розмір View, кешують в LRU-кеш. Завантажувати в ImageView через BitmapFactory.decodeFile — шлях до OOM на пристроях з 2 ГБ RAM. Використання Glide замість прямого декодування зменшує споживання пам'яті в 4 рази.
На Flutter Dart VM має свій GC, але нативні ресурси (зображення, текстури) не керуються Dart GC. Image.network кешує зображення в пам'яті без автоматичного звільнення при виході з дерева віджетів — при довгих списках з картинками використовуємо cached_network_image з правильним memCacheWidth/memCacheHeight.
FPS та UI Performance
60 FPS — 16.67 мс на кадр. 120 FPS (ProMotion) — 8.33 мс. Все що займає більше на main thread — джанк.
Типові причини просадок FPS:
На iOS: синхронне декодування зображень в cellForRowAt. Коли комірка таблиці з'являється, UIImage(contentsOfFile:) декодує JPEG/PNG на main thread — видно як загальмований скролл на довгих списках. Рішення: UIImage.preparingForDisplay() (iOS 15+) або ImageIO з kCGImageSourceCreateThumbnailWithTransform в background queue, результат через DispatchQueue.main.async.
На Android: RecyclerView.Adapter.onBindViewHolder з синхронними операціями. Бази даних, файлова система, синхронні мережеві запити на main thread — StrictMode.ThreadPolicy з detectAll().penaltyLog() в debug-збірці покаже всі порушення.
На Flutter: build() метод викликається часто, він має бути дешевим. setState() на верхньому віджеті перезбирає все дерево. const конструктори, RepaintBoundary, розбиття на дрібні віджети з локальним стейтом — основні інструменти. Flutter DevTools → Performance показує janky frames (червоні) з причинами.
Профілювання Compose: Recomposition Highlighter та трасування через Trace.beginSection в @Composable. remember для дорогих обчислень, derivedStateOf для computed values, LazyColumn замість Column + forEach для довгих списків.
Батарея: Wake locks, WorkManager, мережеві запити
Додаток у топі по витраті батареї — користувач бачить це в налаштуваннях і видаляє. Android Battery Historian (з ADB bug report) показує детальний timeline: wake locks, wakeups, network activity, sensor usage.
Основні споживачі енергії:
- Постійний GPS (розбираємо в maps-geo)
- Polling мережі кожні N секунд за代push
- Утримання wake lock довше необхідного
- Excessive
AlarmManager wakeups
WorkManager з Constraints — правильний спосіб планувати фонові задачі: setRequiredNetworkType, setRequiresBatteryNotLow, setRequiresCharging. ОС батчить задачі та виконує в зручний час.
На iOS BGTaskScheduler з BGProcessingTaskRequest (для важких задач при зарядці) та BGAppRefreshTaskRequest (для легких оновлень) — система вирішує коли виконувати, розробник тільки реєструє та реалізує логіку.
Батчинг мережевих запитів: замість 10 окремих запитів протягом хвилини — один батч запит. Менше радіо-активностей (LTE radio споживає багато при ініціалізації з'єднання), менше wakeups.
Інструменти профілювання
| Платформа |
Інструмент |
Що показує |
| iOS |
Xcode Instruments (Time Profiler) |
CPU, call stack, гарячі методи |
| iOS |
Allocations |
Живі об'єкти, піки пам'яті |
| iOS |
Leaks |
Retention cycles |
| iOS |
MetricKit |
Виробничі метрики (crash rate, hang rate, launch time) |
| Android |
Android Profiler |
CPU, Memory, Network, Energy |
| Android |
Systrace / Perfetto |
System-level трейси |
| Android |
LeakCanary |
Витоки пам'яті |
| Android |
Battery Historian |
Енергоспоживання |
| Flutter |
Flutter DevTools |
Recomposition, frame rendering, memory |
| Flutter |
Dart Observatory |
Dart VM profiling |
MetricKit на iOS — особливо цінний: реальні дані з пристроїв користувачів, а не симулятора. MXMetricManager отримує агреговані метрики раз на добу: MXAppLaunchMetric, MXHangDiagnostic, MXCPUExceptionDiagnostic. Діагностики по hang та CPU-exceptions містять стектрейс з реального пристрою — золото для діагностики production-проблем.
Типові проблеми та рішення
| Проблема |
Інструмент діагностики |
Рішення |
| Cold start > 3 с |
App Startup / DYLD_PRINT_STATISTICS |
Лінива ініціалізація, статичне лінкування |
| OOM на зображеннях |
LeakCanary / Allocations |
Glide/Coil з даунсемплінгом |
| FPS просадки при скролі |
Graphics (Xcode) / Profile GPU (Android) |
Фонове декодування, preparingForDisplay |
| Висока витрата батареї |
Battery Historian / Energy Log |
WorkManager, батч запитів, push замість polling |
Процес оптимізації
Починаємо з вимірювання, не з припущень. Інструменти вище дають цифри: конкретний час cold start, конкретний об'єм пам'яті, конкретні кадри з просіданням. Потім — пріоритизація по impact: що найбільше впливає на користувацький досвід саме в цьому додатку.
Аудит продуктивності існуючого додатку: 3–5 робочих днів. Реалізація оптимізацій — від тижня до двох місяців залежно від запущеності проблем та архітектури коду.
Що входить в роботу
- Детальний аудит продуктивності з профілюванням на реальних пристроях
- Звіт з виявленими проблемами та рекомендаціями (PDF/Notion)
- Код-рев'ю вузьких місць з пропозиціями змін
- Реалізація оптимізацій (cold start, пам'ять, UI, батарея)
- Повторне тестування для підтвердження покращень
- Документація змін та рекомендації щодо подальшої підтримки
Ми оптимізували 50+ мобільних застосунків, серед яких фінансові та соціальні мережі з аудиторією понад 10 млн користувачів. Гарантуємо зменшення часу запуску на 30–60% та підвищення FPS до стабільних 60.
Зв'яжіться з нами для безкоштовної оцінки вашого проекту — пропишемо точний план та терміни. Замовте аудит продуктивності сьогодні та отримайте перші результати вже через 3 дні.