Утечки памяти: как поймать баг, который появляется только через час
Самые неприятные баги, которые я ловил, никогда не воспроизводились за пять минут. Тест-кейс пройден, смок зелёный, релиз ушёл. А через неделю в отзывах: «через час игры всё начинает тормозить», «вылетает, если долго листать ленту», «телефон греется, приложение закрывается само». Открываешь приложение, проверяешь — всё летает. Потому что ты открыл его на две минуты, а пользователь сидит в нём сорок.
Почти всегда за такими отзывами стоит утечка памяти. И почти всегда её можно было найти до релиза, если знать, где искать.
Что такое утечка, если смотреть глазами тестировщика
Разработчик скажет: объект больше не нужен, но на него осталась ссылка, и сборщик мусора не может его освободить. Для QA полезнее другое определение: память растёт и не возвращается, когда пользователь повторяет одно и то же действие.
Нормальное приложение «дышит»: открыл экран — память выросла, закрыл — вернулась примерно туда же. Приложение с утечкой поднимается лесенкой: открыл-закрыл, открыл-закрыл, и после каждого круга нижняя точка чуть выше предыдущей. Сто кругов — и система убивает процесс.
Отсюда главное правило: утечку не видно на одном прохождении. Её видно только на повторении.
Как утечка выглядит со стороны пользователя
Симптомы почти никогда не говорят «память» прямым текстом. Вот что обычно попадает в отзывы и в тикеты поддержки:
- Постепенные тормоза. Чем дольше сессия, тем ниже FPS и тем дольше открываются экраны. Сборщик мусора работает всё чаще и всё дольше.
- Вылеты без стектрейса. На Android система убивает процесс через Low Memory Killer, на iOS — через jetsam. Приложение просто исчезает, и в Crashlytics часто нет нормального краша, потому что это не исключение, а убийство процесса извне.
- Сброс состояния после сворачивания. Пользователь свернул приложение на минуту, вернулся — а оно стартует заново. В фоне процесс с раздутой памятью убивают первым, об этом прямо пишет документация Android про жизненный цикл процесса.
- Нагрев и разряд батареи. Постоянная работа сборщика мусора — это CPU.
- «На старом телефоне не работает». На флагмане с 12 ГБ утечка проживёт два часа, на бюджетнике с 3 ГБ — двадцать минут. Поэтому жалобы приходят неравномерно и выглядят как «проблема конкретной модели».
Если видишь в отзывах «долго», «через какое-то время», «если не закрывать» — это повод проверить память, даже если никто не сказал слово «утечка».
Сценарии, которые проявляют утечки
Утечки живут в местах, где что-то создаётся и уничтожается много раз. Вот сценарии, которые я прогоняю в первую очередь.
- Открыть-закрыть экран 20–50 раз подряд. Карточка товара, профиль, настройки, попап магазина. Самый частый источник — экран, который при закрытии не отписался от событий или оставил живой таймер.
- Повороты экрана. На Android поворот пересоздаёт Activity. Если что-то держит ссылку на старую, после каждого поворота в памяти остаётся целый экран.
- Бесконечная лента и скролл. Листать ленту с картинками пять минут. Если картинки не выгружаются, память растёт линейно.
- Свернуть-развернуть. 20 раз уйти в фон и вернуться. Ловит подписки, которые создаются заново при каждом возврате.
- Повтор игровой сессии. Для игр: уровень → проигрыш → рестарт, 30 раз подряд. Объекты прошлого уровня, которые не уничтожились, копятся.
- Смена аккаунта и логаут. Данные прошлого пользователя иногда остаются в памяти навсегда, а это заодно и проблема безопасности.
- Плохая сеть. Запросы, которые отменились по таймауту, но их колбэки продолжают держать экран.
- Долгая сессия без действий. Просто оставить приложение открытым на час. Ловит утечки в таймерах, анимациях, поллинге и рекламных SDK.
Отдельно про рекламу и SDK: утечки в сторонних библиотеках — обычное дело. Если память растёт только при показе интерстишела, это тоже баг, и его тоже надо репортить, даже если «это не наш код».
Как мерить: быстрый способ и правильный способ
Не нужно быть разработчиком, чтобы увидеть утечку. Нужно уметь снять две цифры: память до и после повторения.
Быстрый способ — без профайлера. На Android хватает adb:
adb shell dumpsys meminfo com.example.app
Смотри на строку TOTAL PSS. Сними её после холодного старта, потом прогони сценарий 30 раз, вернись на тот же экран, подожди 10–20 секунд и сними снова. Если цифра выросла на десятки мегабайт и не опускается — это кандидат на утечку. На iOS похожую грубую картину дают Xcode → Debug Navigator → Memory при запуске с Mac.
Правильный способ — профайлер.
- Android: Memory Profiler в Android Studio показывает график памяти в реальном времени, умеет снимать heap dump и сам подсвечивает утекшие Activity и Fragment. Если у команды есть debug-сборка, попросите встроить LeakCanary: он сам ловит утечки во время обычного ручного тестирования и показывает уведомление с цепочкой ссылок. Для QA это почти бесплатный детектор.
- iOS: Xcode Instruments, инструменты Allocations и Leaks, плюс Memory Graph в отладчике. Apple подробно описывает это в гайде про измерение памяти.
- Unity: встроенный Memory-модуль Profiler и пакет Memory Profiler. Главный приём — снять снапшот в меню, сыграть пять уровней, вернуться в меню, снять второй и сравнить. Всё, что осталось от уровней, — утечка.
- Веб: Chrome DevTools → Memory, heap snapshot до и после сценария. У Google есть хороший разбор поиска утечек с методикой «трёх снапшотов».
Принцип у всех инструментов одинаковый: снапшот → повторение → снапшот → сравнение. Не нужно понимать весь граф объектов. Достаточно увидеть, что после 30 открытий экрана в памяти живут 30 копий этого экрана.
Как не обмануть себя
Пара ловушек, на которых я сам спотыкался.
- Рост — ещё не утечка. Кэши картинок, пулы объектов, прогретые шейдеры законно увеличивают память после первых действий. Утечка — это когда рост продолжается при каждом повторении и не упирается в потолок. Поэтому важна не одна цифра, а тренд: 5, 10, 20, 30 повторений.
- Дай сборщику мусора отработать. Снимай цифру не сразу после действия, а через 10–20 секунд простоя. В профайлерах есть кнопка принудительного GC — жми её перед снапшотом.
- Тестируй release-сборку. Debug-сборки едят больше памяти и ведут себя иначе. Финальную проверку делай на том, что пойдёт пользователям.
- Тестируй на слабом устройстве. Утечка, которая на флагмане даёт плюс 200 МБ за час, на бюджетнике убьёт процесс через 15 минут. Для мобильных проектов имеет смысл держать в матрице устройств хотя бы один телефон с 3–4 ГБ памяти.
Что приложить к баг-репорту
Баг «приложение тормозит через час» разработчик может искать неделю. Баг с данными — за час. Вот что я прикладываю:
- Точный сценарий повторения: «открыть карточку товара и вернуться назад, 30 раз».
- Цифры: память после старта, после 10, 20 и 30 повторений. Лучше таблицей или скриншотом графика из профайлера, где видна лесенка.
- Устройство, ОС и тип сборки (release или debug).
- Heap dump или снапшот, если снял: файл .hprof для Android, .memgraph для iOS, .snap для Unity.
- Отчёт LeakCanary, если он сработал: там уже есть цепочка ссылок, и это почти готовый ответ.
- Что не течёт: «тот же сценарий на экране профиля память не растит». Это сильно сужает поиск.
Как встроить это в процесс
Утечки не нужно искать вручную перед каждым релизом. Достаточно трёх привычек:
- Soak-сценарий в регрессе. Один длинный сценарий на 30–60 минут: основные экраны по кругу. Прогоняется раз в релиз, лучше автоматически, с записью памяти в начале и в конце.
- Порог в CI для критичных экранов. Если есть UI-автотесты, добавьте шаг: прогнать экран 20 раз и сравнить PSS. Рост больше заданного порога — тест красный.
- LeakCanary в debug-сборках QA. Самый дешёвый способ: тестировщики находят утечки как побочный эффект обычной работы.
Чек-лист: проверка на утечки памяти
- Сними память после холодного старта на эталонном экране.
- Прогони открыть-закрыть ключевые экраны 20–50 раз.
- Прогони повороты экрана, если приложение их поддерживает.
- Свернуть-развернуть приложение 20 раз.
- Для игр: рестарт уровня 30 раз, переход меню → уровень → меню 10 раз.
- Поскролль ленты и списки с картинками 5 минут.
- Оставь приложение открытым на 30–60 минут без действий.
- Проверь показ рекламы и других SDK несколько раз подряд.
- Каждый раз снимай цифру через 10–20 секунд после действия, лучше после принудительного GC.
- Смотри на тренд по 5, 10, 20, 30 повторениям, а не на одну цифру.
- Финальную проверку делай на release-сборке и на слабом устройстве.
- В баг-репорт: сценарий, цифры по повторениям, устройство, heap dump.
Утечка памяти — это баг, который наказывает самых преданных пользователей: тех, кто сидит в приложении дольше всех. Короткие тест-кейсы его не видят по определению. Видит только тестировщик, который догадался повторить одно и то же действие тридцать раз и посмотреть на цифру.