Тестирование потребления оперативной памяти мобильным приложением
Приложение не крэшится сразу — оно постепенно растёт в памяти. Через 20 минут использования появляется лёгкая задумчивость. Через 40 — системный Memory Pressure убивает фоновые процессы. Через час — SIGKILL от iOS или OOM-киллер Android завершает приложение. Пользователь думает, что приложение «глючит». Firebase Crashlytics ничего не покажет — это не крэш, это убийство системой.
iOS: Instruments Allocations и Leaks
Два шаблона для анализа памяти в Instruments:
Allocations — все выделения памяти, живые и мёртвые объекты. Генерации (кнопка Generation) позволяют сравнить объекты в памяти до и после действия. Если после закрытия экрана объекты этого экрана остались в живой памяти — утечка.
Leaks — автоматический детектор retain-циклов. Красная иконка = найденный цикл. Показывает граф зависимостей с виновниками.
Классический retain-цикл в Swift:
// Утечка: ViewController держит closure, closure захватывает ViewController
class PhotoViewController: UIViewController {
var onPhotoLoaded: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
onPhotoLoaded = {
self.imageView.image = UIImage(named: "photo") // strong capture
}
}
}
// Правильно:
onPhotoLoaded = { [weak self] in
self?.imageView.image = UIImage(named: "photo")
}
[weak self] — стандарт для любых closure, захватывающих self в долгоживущих объектах. Instruments Leaks это найдёт, но часто показывает симптом, а не причину. Следуем по графу в стек, ищем корневой strong reference.
UIImage и память
UIImage(named:) кэширует изображение в системном кэше. Для часто используемых иконок — хорошо. Для больших фото, которые загружаются один раз — нет. Используем UIImage(contentsOfFile:) — не кэширует.
Декодирование изображения происходит при первом отображении, не при создании UIImage. Предварительное декодирование на background thread:
func decodedImage(_ image: UIImage) -> UIImage {
UIGraphicsBeginImageContextWithOptions(image.size, true, 0)
defer { UIGraphicsEndImageContext() }
image.draw(in: CGRect(origin: .zero, size: image.size))
return UIGraphicsGetImageFromCurrentImageContext() ?? image
}
После этого вызова изображение уже декодировано и лежит в памяти как bitmap. Передаём в UI без задержки на декодировку.
Android: Memory Profiler и LeakCanary
Android Studio Memory Profiler показывает Heap в реальном времени: Java Heap, Native Heap, Stack, Code, Graphics. Кнопка Dump Heap сохраняет снимок — анализируем в hprof viewer или конвертируем для Eclipse Memory Analyzer.
Но самый полезный инструмент в бою — LeakCanary. Подключается в debugImplementation, работает автоматически:
// build.gradle.kts
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
При обнаружении утечки LeakCanary показывает notification с полным стеком: что удерживает что, через какую цепочку. Не нужно вручную анализировать heap-dump.
Частые причины утечек на Android:
Context в статических полях или синглтонах:
// Плохо
object ImageCache {
var context: Context? = null // держит Activity
}
// Правильно: applicationContext
object ImageCache {
lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext // не Activity
}
}
Незакрытый Cursor от ContentProvider или SQLiteDatabase:
val cursor = db.query(...)
try {
// работа с курсором
} finally {
cursor.close() // обязательно, даже при исключении
}
Listener, не снятый при onDestroy:
override fun onStart() {
super.onStart()
locationManager.requestLocationUpdates(provider, 0, 0f, this)
}
override fun onStop() {
super.onStop()
locationManager.removeUpdates(this) // иначе Activity не умрёт
}
Flutter: Observatory и DevTools Memory
Flutter DevTools → Memory tab — snapshot-профилировщик. Показывает группы объектов по типу. Dart:core, package:myapp — смотрим на классы с неожиданно большим количеством экземпляров.
Типичная утечка в Flutter — StreamSubscription без cancel():
class MyWidget extends StatefulWidget { ... }
class _MyWidgetState extends State<MyWidget> {
late StreamSubscription _sub;
@override
void initState() {
super.initState();
_sub = someStream.listen((event) { ... });
}
@override
void dispose() {
_sub.cancel(); // обязательно
super.dispose();
}
}
Без _sub.cancel() в dispose() подписка живёт дольше виджета, удерживает замыкание с ссылкой на State.
Что входит в работу
- Профилирование памяти через Instruments Allocations / Memory Profiler / DevTools
- Настройка LeakCanary для Android-проекта
- Анализ heap-dumps и поиск retain-циклов
- Аудит работы с изображениями (кэш, декодирование)
- Проверка паттернов работы с listeners, subscriptions, closures
- Отчёт с конкретными утечками и правками
Сроки
2–3 дня в зависимости от размера приложения и количества найденных проблем. Стоимость рассчитывается индивидуально.







