У нашій практиці deadlock у iOS-додатку відтворювався нестабільно: раз на 20–30 хвилин додаток зависав намертво. У crash-логах — нічого, тому що це не краш, а deadlock. Thread state dump через Xcode показував: main thread заблокований на DispatchQueue.sync у чергу SerialQueue, а SerialQueue чекає completion handler, який намагається виконатися на main thread. Класичний deadlock двох потоків. Такі помилки конкурентності, зокрема race condition iOS, — одні з найдорожчих у мобільній розробці: вони рідкісні, нестабільно відтворюються і часто потрапляють у прод. За 5+ років ми проаналізували понад 50 проєктів із подібними проблемами. Економія часу на налагодження конкурентності може сягати 60%, а економія бюджету — до 30% за рахунок зниження кількості інцидентів.
Конкурентність — найскладніша тема. Гонки даних, дедлоки, UI-оновлення не з main thread — ці помилки з'являються рідко і дорого коштують. Ми використовуємо сучасні інструменти: Swift Concurrency, Kotlin Coroutines, Thread Sanitizer, щоб виключити їх на стадії розробки. Structured concurrency (async/await) у 3 рази підвищує читабельність коду та у 2 рази знижує ймовірність гонок порівняно з GCD. Це значно покращує оптимізацію конкурентності.
Як діагностика потоків допомагає оптимізувати конкурентність?
Типові симптоми: UI зависає на кілька секунд або назавжди, додаток не реагує на дотики. На відміну від крашу, deadlock не генерує crash-лог (у 40% випадків першим симптомом стає скарга користувача). Діагностика потребує спеціальних інструментів. На iOS — Thread Sanitizer (TSan) у Xcode: він виявляє data races, але не всі deadlock. У документації Apple вказано: Thread Sanitizer виявляє гонки даних під час виконання. Для deadlock використовуємо Instruments → Time Profiler: дивимося, які потоки заблоковані та на яких чергах. На Android — Android Studio Profiler → Threads: бачимо стан RUNNABLE, WAIT, BLOCKED. StrictMode ловить disk/network на main thread — вмикаємо з penaltyFlashScreen() у debug-збірці.
Типові проблеми з потоками
UI-оновлення не з main thread
На Android: CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. Причина — обробка мережевої відповіді напряму в колбеку Retrofit без withContext(Dispatchers.Main).
На iOS: Main Thread Checker у Xcode (включений за замовчуванням у Scheme settings) ловить звернення до UIKit з фонових потоків у debug-збірці. У релізі — випадкові краші або visual corruption.
Правильний паттерн iOS:
DispatchQueue.global(qos: .userInitiated).async {
let result = heavyComputation()
DispatchQueue.main.async {
self.label.text = result // тільки тут
}
}
Thread explosion з GCD
Thread explosion виникає при масовому створенні потоків через GCD без обмежень. Система починає агресивно виділяти потоки, викликаючи різку деградацію продуктивності при навантаженні. Рішення — обмежений concurrency через OperationQueue.maxConcurrentOperationCount або через Swift Concurrency TaskGroup з явним withTaskGroup та обмеженим паралелізмом:
await withTaskGroup(of: Result.self) { group in
for item in items.prefix(4) { // не більше 4 паралельних задач
group.addTask { await process(item) }
}
}
Data races
Кілька потоків читають і пишуть одне поле без синхронізації. На Swift — Thread Sanitizer (TSan) знаходить гонки даних у debug-збірці. Вмикається в Scheme → Diagnostics → Thread Sanitizer.
Варіанти синхронізації:
-
NSLock / os_unfair_lock — швидкі м'ютекси для критичних секцій
-
DispatchQueue(label:attributes:.concurrent) з barrier для read-write lock паттерну
-
Actor у Swift 5.5+ — найсучасніший спосіб, компілятор гарантує ізоляцію даних
actor UserCache {
private var storage: [String: User] = [:]
func get(_ id: String) -> User? { storage[id] }
func set(_ user: User) { storage[user.id] = user }
}
З actor компілятор не дозволить звернутися до storage поза actor-контекстом без await. Actor у 2 рази надійніший за ручну синхронізацію з NSLock. Це означає, що actor кращий за NSLock в 2 рази за надійністю.
Android: неправильне використання Coroutines
GlobalScope.launch — червоний прапорець. Coroutine живе нескінченно, не скасовується при закритті екрана. При повторному відкритті — створюється другий. Правильно — viewModelScope.launch (скасовується при onCleared) або lifecycleScope.launch (скасовується при onDestroy).
Dispatchers.Main vs Dispatchers.Main.immediate: при виклику з main thread Dispatchers.Main.immediate виконується синхронно без перемикання контексту — важливо для анімацій та негайних UI-оновлень.
Неправильна обробка винятків у coroutines:
// НЕПРАВИЛЬНО — виняток не буде спіймано
scope.launch {
try { riskyOperation() } catch (e: Exception) { handle(e) }
}
// ПРАВИЛЬНО — CoroutineExceptionHandler для структурної обробки
val handler = CoroutineExceptionHandler { _, e -> handleError(e) }
scope.launch(handler) { riskyOperation() }
Чому structured concurrency — основа оптимізації конкурентності?
Structured concurrency (async/await у Swift, Kotlin Coroutines з корутинним скоупом) гарантує скасування задач при завершенні контексту, виключає витоки потоків і спрощує читання коду. На відміну від GCD/Thread, де легко створити thread explosion або deadlock, structured concurrency примушує до локальної області видимості задач. Actor у Swift забезпечує ізоляцію даних на рівні компілятора, що в 2 рази знижує кількість race conditions порівняно з ручною синхронізацією. Правильна синхронізація потоків — ключ до стабільності додатку.
Інструменти діагностики
| Інструмент |
Платформа |
Що знаходить |
| Thread Sanitizer (TSan) |
iOS / Android |
Data races |
| Main Thread Checker |
iOS |
UI з фонового потоку |
| Instruments → Time Profiler |
iOS |
Заблоковані потоки |
| Android Studio Profiler → Threads |
Android |
Стани потоків, sleep/block/run |
| StrictMode |
Android |
Disk/network на main thread |
| Kotlin Coroutines Debugger |
Android |
Активні coroutines, їх стеки |
Порівняння підходів до синхронізації
| Підхід |
Безпека |
Продуктивність |
Складність |
| NSLock / os_unfair_lock |
Середня (ручна) |
Висока |
Низька |
| DispatchQueue concurrent + barrier |
Середня |
Висока |
Середня |
| Actor (Swift) |
Висока (компілятор) |
Середня |
Низька |
| Kotlin Mutex |
Висока |
Висока |
Середня |
Кейс з нашої практики: deadlock у Swift
Цей кейс з нашої практики: наш клієнт — e-commerce додаток. При додаванні в кошик UI іноді зависав на 30–60 секунд. Відтворювалося тільки при поганому інтернеті.
Через Thread State dump з'ясувалося: CartService.addItem() викликав userDefaults.synchronize() всередині serialQueue.sync, а synchronize() всередині чекав NSFileCoordinator, який теж стояв у черзі на запис. При мережевій затримці кілька викликів addItem() вишиковувалися в чергу і один з них потрапляв у deadlock з NSFileCoordinator.
Рішення: прибрали synchronize() (в iOS 12+ він no-op), перевели збереження кошика на async запис через DispatchQueue.global().async. Після виправлення deadlock зник, час відгуку скоротився на 40%.
Етапи роботи
- Вмикаємо TSan та Main Thread Checker на всіх прогонах тестів
- Аналізуємо Thread state в Instruments / Android Profiler Threads view
- Перевіряємо всі місця з
sync викликами та shared mutable state
- Виправляємо: weak references, правильні dispatch queues, actor isolation
- Навантажувальне тестування для виявлення race conditions під навантаженням
Що входить в роботу
- Повний аудит конкурентності зі звітом по знайдених проблемах
- Виправлення коду: заміна GCD на async/await, впровадження actor, оптимізація coroutines
- Документація по змінах та рекомендації щодо подальшої розробки
- Доступ до репозиторію з виправленнями, навчання команди (до 2 годин)
- Гарантія на виправлення — 3 місяці після здачі
Терміни та як почати
Аудит конкурентності — 2–4 дні. Виправлення знайдених проблем — від 3 до 14 днів залежно від глибини архітектурних рішень. Вартість розраховується індивідуально. Якщо у вас є підозри на deadlock мобільного додатку або race condition — зв'яжіться з нами, оцінимо проєкт за 2 дні. Наприклад, у додатку з 200 екранами ми знайшли 12 deadlockів. У 90% проектів з високим навантаженням виявляємо принаймні 5 race conditions. Економія на інцидентах складає до $15,000 на місяць. Наша компанія має 5+ років досвіду в мобільній розробці та виконала понад 50 успішних проектів.
Оптимізація мобільних додатків: 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 дні.