В этой статье наш опытный разработчик Михаил поделится своими знаниями и советами по оптимизации Android приложений.

Работа с утечками памяти

Не так давно, в процессе разработки приложения, я столкнулся с ситуацией: на первый взгляд код был хорошо написан, однако, на этапе тестирования стали заметны зависания. Во время использования приложения и многочисленных переходов по разным экранам, производительность заметно ухудшалась с каждым разом. При анализе ситуации стало очевидно, что есть проблемы с оптимизацией. В данной статье я расскажу об основных шагах по оптимизации android приложений.

Одна из самых распространенных проблем оптимизации, с которой сталкиваются разработчики Android, это утечки памяти. Утечка памяти в Android-приложениях происходит, когда объекты, которые больше не используются, по какой-то причине не удаляются сборщиком мусора и продолжают занимать память. Это может привести к избыточному потреблению ресурсов и снижению производительности приложения.

Leak Canary

Leak Canary — это открытая библиотека для Android, которая помогает обнаруживать утечки памяти в вашем приложении. Как только утечка обнаружена, Leak Canary предоставляет уведомление с отчетом, который помогает разработчикам быстро найти и устранить причину утечки. Вся суть работы Leak Canary заключается в следующем: при добавлении зависимости в проект, в процессе его сборки интегрируется отдельный модуль. Этот модуль отслеживает жизненный цикл ссылок основных компонентов Android (и не только). Основная цель этого инструмента — обнаруживать утечки памяти, связанные с долгоживущими объектами, такими как активности, фрагменты и другие компоненты Android, которые не были корректно очищены.

Подключение библиотеки

В build.gradle (app) необходимо добавить зависимость:

debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'

Ссылка на библиотеку - https://square.github.io/leakcanary/

* Сигналом о том, что библиотека подключена верно, будет сообщение в logcat: "D LeakCanary: LeakCanary is running and ready to detect leaks"

Работа с библиотекой

Leak Canary автоматически обнаруживает утечки в следующих объектах:

  • destroyed Activity instances

  • destroyed Fragment instances

  • destroyed fragment View instances

  • cleared ViewModel instances

  • destroyed Service instance

Основной способ работы с библиотекой следующий: после запуска приложения и при взаимодействии с ним, (в частности навигация по разным экранам) мы в логах начинаем видеть сообщения от Leak Canary. Когда библиотека обнаруживает утечку, она выдает сообщение, подобное следующему:

На данном примере указано, что обнаружено 4 объекта, которые являются утечкой. В целом, библиотека собирает до 5 утечек, и после этого автоматически делает дамп. Под капотом запускается WorkManager, который собирает стек утечки, формирует отчет и, по завершению, выдает нам в логах информацию об утечке.

Также будет висеть уведомление о текущем состоянии утечек: 


* По умолчанию установленный порог равен 5 объектам в случае, когда приложение активно, и 1 объекту, когда оно работает в фоновом режиме. Если вы увидите уведомление о таких объектах и переведете приложение в фон (например, нажав на кнопку "Домой"), то порог снизится с 5 до 1, после чего LeakCanary произведёт дамп памяти в течение 5 секунд. Касание уведомления инициирует незамедлительное создание дампа.

После завершенного анализа в логах IDEI мы видим отчет примерно в следующем виде:

* Волнистой линией (~) обозначены опасные ссылки (LeakCanary выделяет все ссылки, предположительно вызвавшие эту утечку, с помощью ~~~ подчеркивания.)

  • LeakCanary анализирует состояние и жизненный цикл объектов при трассировке утечки. В Android-приложении экземпляр Application является синглтоном и никогда не подвергается сборке мусора. Поэтому он никогда не утекает (Leaking: NO (Application is a singleton)). На основе этого LeakCanary делает вывод, что утечка не связана с FontsContract.sContext.

  • Экземпляр TextView ссылается на уничтоженный экземпляр MainActivity через его поле mContext. Представления не должны существовать дольше жизненного цикла своего контекста. Исходя из этого, LeakCanary определяет, что данный экземпляр TextView утекает (Leaking: YES (View.mContext references a destroyed activity)). Таким образом, утечка не вызвана полем TextView.mContext.

  • ArrayList.elementData и Object[].[0] являются деталями реализации ArrayList. Маловероятно, что в реализации ArrayList есть ошибка. Таким образом, ссылка, вызывающая утечку, является единственной оставшейся: ExampleApplication.leakedViews.

Приведенный пример является обобщенным. Если описать ситуацию кратко, то данный инструмент помогает сузить область поиска утечки. В более простых случаях на вершине стека указывается непосредственная причина утечки. Однако, в общем, инструмент в конце стека указывает на конкретный участок кода, где утечка была обнаружена (в нашем случае видно, что в этом замешан instance Main Activity). Получив эту информацию и проанализировав её, можно приступить к поиску и устранению возможных причин утечки.

Основные распространенные причины возможных утечек:

  • Анимации:

    Если AnimatorSet или другие аниматоры не останавливаются и не обнуляются, они могут удерживать ссылку на View, что может привести к утечке памяти. 

    * В моем случае я столкнулся со следующей утечкой памяти. На одном из экранов для отображения анимации был создан отдельный объект, отвечающий за настройку и запуск анимации. Однако, проблема заключалась в том, что после уничтожения фрагмента, который инициировал эту анимацию, сам объект анимации продолжал существовать. Это происходило из-за того, что в нем сохранялись ссылки на viewAnimatorSet, который удерживал ссылку на этот view, что, в свою очередь, вызывало утечку памяти. Способ решения: я добавил отдельную функцию, которую привязал к жизненному циклу фрагмента и в которой явно занулял все ссылки на view и AnimatorSet

  • Внутренние и анонимные классы: Если вы создаете внутренний или анонимный класс (например, слушатели), который имеет ссылку на внешний класс, это может вызвать утечку, если внешний класс (часто Activity или Fragment) уничтожен, а внутренний класс еще существует.

  • Статические переменные: Статические переменные живут в течение всего времени жизни приложения. Если статическая переменная держит ссылку на Activity, это может вызвать утечку всей активности.

  • Handler и сообщения: Если Handler создан в главном потоке, и вы отправляете сообщение с задержкой, то до выполнения этого сообщения может произойти утечка, особенно если Handler содержит ссылку на Activity или Fragment

  • Singleton с контекстом: Если синглтон держит ссылку на контекст активности вместо контекста приложения, это может вызвать утечку.

  • Bitmaps: Большие изображения, особенно те, которые загружены в ImageView, могут занимать много памяти и вызывать утечки, если они не обрабатываются правильно.

  • Kotlin Coroutines: Для меня это один из самых загадочных и не всегда очевидных моментов. Однако, если вы разбираетесь в принципах работы асинхронного программирования и, в частности, корутин, то проблема становится более понятной. Суть проблемы заключается в том, что долгоживущая корутина может сохранять ссылку на объект (например, Activity или Fragment), даже после его уничтожения. Поэтому рекомендуется максимально тесно привязывать корутину к жизненному циклу того компонента, в котором она запущена. К тому же, на мой взгляд, хорошей практикой в некоторых случаях является привязка корутины к определенной Job и её последующая отмена в нужные моменты.

* Также я столкнулся с похожей проблемой утечки памяти. Из-за того, что при переходах между экранами все они сохранялись в стеке, корутина продолжала функционировать до тех пор, пока активен был жизненный цикл, удерживающий её. Чтобы решить эту проблему, я решил отменять все корутины при наступлении определенного события в жизненном цикле экрана.

* Хорошей практикой в профилактике утечек памяти является использование слабых ссылок вместо сильных. Например, вместо того, чтобы удерживать сильную ссылку на объект анимации, рекомендуется заменить её на слабую ссылку (WeakReference), которая будет очищена сборщиком мусора, как только это станет необходимым.

Оптимизация приложения по потреблению ОЗУ

После устранения утечек памяти производительность приложения заметно улучшилась. Однако в процессе тестирования выявилась проблема: при длительной работе и активной навигации приложение все еще завершало свою работу с ошибкой OutOfMemoryError. Эта ошибка, знакомая многим разработчикам, указывает на переполнение стека памяти, что приводит к принудительному завершению работы приложения. В связи с этим было принято решение провести анализ потребления оперативной памяти приложением. Для этой цели был выбран инструмент Android Studio Profiler.

* Android Studio Profiler - это инструмент для мониторинга производительности приложений.

Запуск и работа с Profiler

1) В нижней панели выбираем вкладку Profiler.

2) В открывшейся вкладке жмем на плюс и выбираем процесс, который будем анализировать.

3) После запуска Profiler выдаст общий экран, на котором будут отображены затраты процессора, ОЗУ и энергии с динамикой по времени.

4) В данном контексте, когда наша цель — оптимизация по памяти, мы выбираем раздел "Memory" и переходим к детализированному графику, который предоставляет подробную информацию о потреблении памяти.

В данном разделе мы видим сразу несколько показателей потребления памяти: - Total: Общее количество памяти, используемое приложением. Это включает в себя все нижеперечисленные категории

  • Java: Память, используемая объектами, созданными в Java или Kotlin. Это включает в себя большую часть вашего приложения, если оно написано на одном из этих языков.

  • Native: Память, используемая объектами, созданными на языках программирования C или C++. Это часто связано с библиотеками или фреймворками, которые используются в вашем приложении.

  • Graphics: Память, используемая графическими ресурсами, такими как текстуры, битмапы и так далее.

  • Stack: Память, выделенная для стека вызовов вашего приложения. Каждый поток имеет свой собственный стек, который содержит информацию о текущем состоянии выполнения.

  • Code: Память, используемая исполняемым кодом вашего приложения. Это включает в себя байт-код Java, нативный код и другие ресурсы.

  • Other: Память, используемая различными другими ресурсами, которые не попадают ни в одну из вышеуказанных категорий.

  • Allocated: Общее количество памяти, которое было выделено вашим приложением. Это может отличаться от общего использования из-за того, что часть памяти может быть освобождена сборщиком мусора.

* В ходе проведения тестирования, описанного ранее, я выявил интересную закономерность: при каждом новом действии навигации объем памяти, занимаемый Java, увеличивался. При достижении порога в 500-700 МБ приложение неожиданно завершало свою работу. Анализ показал, что основной рост памяти связан с областью Java, что указывало на проблемы с хранимыми объектами в стеке. Детальное изучение кода позволило определить, что стек экранов сохранял каждый новый экран в памяти в состоянии "Stop". Это приводило к тому, что при каждой навигации создавался новый экран, в то время как предыдущий оставался в памяти, функционируя как "мертвый груз". Оптимизировав стек таким образом, чтобы он хранил лишь определенное количество экранов, и проведя повторное тестирование, я добился значительного улучшения стабильности работы. Теперь даже при интенсивном использовании приложение потребляло всего 200-300 МБ памяти. Этот показатель стал динамичным благодаря новой логике, которая своевременно освобождала старые экраны в стеке.

Комментарии (0)