Обнаружение и исправление утечек памяти (Memory Leaks) мобильного приложения
Приложение убивает iOS, когда пользователь открывает и закрывает экран с картой 20 раз подряд. Crash log содержит Terminated due to memory pressure. В Instruments → Allocations видно: каждое открытие MapViewController добавляет ~12 MB в heap и эти 12 MB никогда не освобождаются. После 20 открытий — 240 MB только от экрана карты. Это утечка памяти — не «возможно», а точно.
Механика утечек: почему объекты не освобождаются
Retain cycles на iOS (ARC)
ARC считает сильные ссылки. Если A держит B, а B держит A — ни один не достигнет нулевого счётчика и никогда не освободится. Самые частые паттерны:
Closure без [weak self]:
// УТЕЧКА
viewModel.onDataLoaded = {
self.tableView.reloadData() // сильная ссылка на self
}
// ПРАВИЛЬНО
viewModel.onDataLoaded = { [weak self] in
self?.tableView.reloadData()
}
Timer:
// УТЕЧКА — Timer удерживает target сильно
timer = Timer.scheduledTimer(timeInterval: 1.0, target: self,
selector: #selector(tick), userInfo: nil, repeats: true)
При закрытии ViewController timer продолжает работать и удерживает ViewController. Решение — Timer.scheduledTimer(withTimeInterval:repeats:block:) с [weak self] в block, и обязательный timer.invalidate() в deinit.
Delegate без weak:
// УТЕЧКА
protocol DataDelegate: AnyObject { func didLoad() }
class DataService {
var delegate: DataDelegate? // должен быть weak!
}
Если DataService живёт дольше делегата или оба держат друг друга — утечка. weak var delegate: DataDelegate? — обязательно.
Утечки на Android
Context leak — самая распространённая:
// УТЕЧКА — Activity Context в singleton
object AppRepository {
private var context: Context? = null
fun init(ctx: Context) { context = ctx } // ctx — Activity
}
Activity не освобождается пока живёт Repository. Используем только applicationContext в singleton-объектах.
Anonymous inner class + Handler:
// УТЕЧКА — анонимный класс неявно держит ссылку на Activity
private val handler = Handler(Looper.getMainLooper())
handler.postDelayed({
updateUI() // this — неявная ссылка на Activity
}, 5000)
Решение: WeakReference<MyActivity> или lifecycleScope.launch { delay(5000); updateUI() } — coroutine отменяется вместе с lifecycle.
LiveData observers без removeObserver:
В Fragment подписываемся на ViewModel.liveData.observe(this, ...). Если this — не viewLifecycleOwner а сам Fragment — observer живёт весь lifecycle Fragment-а, включая периоды когда View разрушена. При пересоздании View — добавляется второй observer. Итого N observers после N пересозданий.
Диагностика: инструменты
LeakCanary (Android) — de facto стандарт. Добавляем одну зависимость в debug build, он автоматически отслеживает Activity, Fragment, View, ViewModel. При обнаружении утечки — уведомление с полным retain tree. Обязателен в любом Android-проекте.
Instruments → Leaks (iOS) — строит граф объектов и ищет циклы. Запускаем сценарий 10–15 раз, ждём когда Leaks покраснеет. Клик на утечку — полный стек объектов с retain-путём.
Instruments → Allocations, Generation Analysis — для logical leaks (объекты без цикличных ссылок, но которые накапливаются). Mark generation перед действием → выполнить действие → посмотреть что добавилось и не ушло.
Android Studio Memory Profiler → Heap Dump — снимок heap с путём до GC root для каждого объекта. Ищем Activity экземпляры — их не должно быть более одного (активного).
Кейс: RxJava Disposable без dispose
Flutter-разработчик перешёл на Android и написал RxJava-код с Observable.interval. Подписка создавалась в onCreate, Disposable нигде не сохранялся. При каждой ротации экрана создавался новый Observer, старый продолжал работать. Через 10 ротаций — 10 активных потоков. LeakCanary нашёл это за 2 минуты: retained Activity через Observable → Observer → Activity reference.
Решение: CompositeDisposable, добавляем все подписки, вызываем disposables.clear() в onStop() или onDestroy().
Что делаем в рамках услуги
- Добавляем LeakCanary в debug-сборку Android, настраиваем прогон тестовых сценариев
- Проводим сессии Instruments Leaks + Allocations для iOS по всем ключевым экранам
- Анализируем все retain-пути найденных утечек
- Исправляем: weak references, timer invalidation, proper closure capture, observer lifecycle
- Добавляем
deinit/onDestroyлогирование для регрессионного контроля
Сроки
Диагностика утечек памяти — 1–3 дня. Исправление выявленных утечек — 2–7 дней в зависимости от количества и запущенности проблемы.







