Зависання (висне) — це стан, при якому мобільний застосунок перестає реагувати на будь-які дії користувача на тривалий час. На відміну від лагів (уповільнення роботи) і глюків (некоректна поведінка), зависання повністю блокує UI: дотики не обробляються, анімація зупиняється, екран «застигає». Причина — блокування головного потоку синхронною операцією, deadlock у багатопотоковому коді або аномально довге збирання сміття. За даними документації Apple Main Thread Checker, понад 40% звітів про збої iOS пов'язані з блокуванням головного потоку. На Android аналогічна ситуація призводить до ANR — системного діалогу «Застосунок не відповідає».
Головне
Зависання (freeze, hang) у мобільному застосунку — це стан, при якому застосунок перестає обробляти події введення та оновлювати інтерфейс протягом кількох секунд і більше. Технічно це означає, що головний потік заблоковано і він не може виконати черговий цикл раннера.
Лаг — це затримка до 500 мс, при якій користувач помічає уповільнення, але застосунок продовжує працювати. Зависання триває від 1 секунди до десятків секунд. ANR на Android — це окремий випадок зависання, яке тривало понад 5 секунд і було виявлено системою. Не кожне зависання призводить до ANR, але кожен ANR — це задокументоване системою зависання.
На Android зависання понад 5 секунд викликає ANR-діалог із пропозицією закрити застосунок. На iOS система має watchdog — якщо застосунок не реагує на події протягом 10–20 секунд, Watchdog завершує процес із кодом 0x8badf00d (ate bad food). Користувач бачить лише раптове закриття застосунку на головний екран.
Будь-яка операція, яка виконується довше 100 мс і запущена в головному потоці, потенційно викликає зависання. Розглянемо основні джерела блокувань.
Читання великого файлу, мережевий запит без асинхронності, збереження даних у SharedPreferences синхронним методом apply з наступним commit — усі ці операції блокують головний потік. На Android синхронне читання файлу розміром 10 МБ може зайняти 200–500 мс залежно від швидкості флеш-пам'яті. На iOS синхронне завантаження URLSession без completionHandler блокує UI на час відповіді сервера.
Коли два потоки очікують звільнення ресурсів, утримуваних один одним, виникає deadlock. У мобільних застосунках типовий сценарій — потік A блокує Lock1 і чекає Lock2, а потік B блокує Lock2 і чекає Lock1. Обидва потоки зависають назавжди. Якщо один із них — головний потік, застосунок зависає повністю.
Помилка в логіці — наприклад, while(true) без умови виходу або рекурсія без базового випадку — призводить до нескінченного виконання на головному потоці. Android виявляє це через ANR через 5 секунд, iOS — через Stackshot, який фіксує нескінченно повторюваний стек викликів.
Діагностика зависань потребує інструментів, здатних зафіксувати стан усіх потоків у момент блокування.
При кожному ANR система Android зберігає файл /data/anr/traces.txt, який містить дамп стеку кожного потоку застосунку. Аналіз цього файлу — основний метод діагностики: потрібно знайти потік main і побачити, на якому методі він зупинився. Якщо стек закінчується на Thread.sleep, InputStream.read або Lock.lock — причину знайдено.
Xcode при зависанні застосунку (сигнал SIGSTOP) може зняти Stackshot — знімок стеків усіх потоків. Увімкніть у схемі «Logging» → «Include Stackshot Logs». При падінні з кодом 0x8badf00d витягніть crash-лог із Devices & Simulators і знайдіть потік com.apple.main-thread із застиглим стеком.
Main Thread Checker автоматично виявляє виклики UIKit із фонових потоків під час роботи застосунку. Увімкніть його в схемі (Diagnostics → Main Thread Checker). Кожне попередження — потенційна причина зависання, особливо якщо воно відбувається в замиканні completionHandler мережевого запиту.
Приклад виявлення блокування через StrictMode на Android:
class App : Application() {
override fun onCreate() {
super.onCreate()
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.penaltyLog()
.build())
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyDeath()
.build())
}
}
Усунення зависань починається з переведення всіх потенційно довгих операцій у фонові потоки. Розглянемо конкретні техніки для кожної платформи.
Kotlin Coroutines з viewModelScope.launch(Dispatchers.IO) гарантують, що мережева операція або читання з БД виконуються у фоновому потоці. Dispatchers.Main використовується лише для оновлення UI. Важливо: усі suspend-функції мають бути структуровані — дочірні корутини скасовуються при скасуванні батьківської, запобігаючи витоку потоків.
Grand Central Dispatch з DispatchQueue.global(qos: .userInitiated) для фонових завдань і DispatchQueue.main.async для оновлення UI — стандартний патерн. Уникайте sync() на головній черзі — це гарантований deadlock. Використовуйте async/await (Swift 5.5+) для більш читабельного асинхронного коду з автоматичним поверненням на головний потік через MainActor.
Блоки synchronized у Kotlin та @synchronized у Swift на головному потоці небезпечні: якщо інший потік уже захопив це блокування, головний потік зависне в очікуванні. Використовуйте атомарні типи (AtomicInteger, атомарні властивості в Swift) або послідовні черги замість блокувань.
Приклад асинхронного завантаження даних з корутинами на Android:
class DataViewModel : ViewModel() {
private val _data = MutableStateFlow<List<Item>>(emptyList())
val data: StateFlow<List<Item>> = _data.asStateFlow()
fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
val result = fetchFromNetwork()
_data.emit(result)
}
}
}
Запобігти зависанням системно допомагає комбінація інструментів, архітектурних принципів і процесів код-рев'ю.
Налаштуйте StrictMode з penaltyDeath для потокових політик — це призведе до негайного падіння застосунку при виявленні мережевого виклику або дискового введення-виведення на головному потоці. Розробник не зможе проігнорувати проблему. У продакшн-збірці використовуйте penaltyLog для збору статистики без падінь.
На iOS увімкніть Main Thread Checker у схемі Debug і налаштуйте CI на запуск тестів із цією опцією. Якщо тест містить виклик UIKit із фонового потоку — він має падати. Це єдиний надійний спосіб виявити проблему до відправлення в TestFlight.
Додайте в процес код-рев'ю обов'язковий пункт: перевірка, що будь-який мережевий виклик, робота з файлами, БД або важкі обчислення виконуються у фоновому потоці. Deadlock можна виявити статичним аналізатором: Infer від Facebook і Thread Safety Checker від Xcode знаходять потенційні блокування до запуску.
Часті запитання
ANR (Application Not Responding) — це системне повідомлення Android, яке з'являється при зависанні головного потоку більше 5 секунд. Зависання — більш широке поняття: будь-яке блокування UI будь-якої тривалості. На iOS ANR немає, але є Watchdog з таймаутом 10–20 секунд.
Файл знаходиться в /data/anr/traces.txt. Для доступу потрібен root-доступ або adb shell: виконайте adb shell cat /data/anr/traces.txt \> traces.txt з правами root. У стеку знайдіть потік «main» — останній викликаний метод вказує на причину блокування.
Якщо зависання триває менше 10 секунд, Watchdog не спрацьовує, і застосунок просто «висить» до завершення блокуючої операції. Користувач не бачить crash, але відчуває фрустрацію. Для виявлення таких випадків використовуйте MetricKit з кастомними трейсами часу виконання.
Використовуйте UI-тести з перевіркою того, що екран відкривається за < 1 секунди. Додайте в CI замір часу між tap і появою наступного екрану. На Android використовуйте Espresso з IdlingResource для очікування асинхронних операцій. На iOS XCTest з XCTWaiter для перевірки часу завантаження.
SwiftUI сам по собі не викликає зависань, але складні обчислення у властивості body — так. Якщо body обчислюється протягом 500 мс через важкі операції, UI зависає. Рішення — вивантажити обчислення в Task.detached і оновлювати @State асинхронно на головному акторі.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також