В первой части статьи мы поговорили о том, что такое ANR (Application Not Responding), и рассмотрели несколько способов сбора информации об этих ошибках. А сегодня я расскажу о проблемах, которые мы обнаружили в нашем приложении, о том, как мы их исправляли и что из этого в итоге получилось.

Время запуска приложения

Первое, что можно сделать, чтобы уменьшить количество ANR-ошибок, — попробовать найти самые частые причины их возникновения. Начать можно с консоли Google Play — проанализировать самые большие группы ошибок. Наша консоль выглядела примерно так:

Почти каждая группа содержала заголовок “Broadcast of Intent { act=com.google.android.c2dm.intent.RECEIVE } …”, но на первый взгляд все они были разные, так как стек-трейс основного потока каждой группы указывал на разные места в приложении. Мы попытались найти что-то общее между этими группами и в итоге, воспользовавшись локальными отчётами, которые мы получили с помощью скрапера, выяснили, что около 60% ANR-ошибок возникало во время исполнения метода Application.onCreate.

Этот метод является частью критического пути запуска приложения, то есть любые задержки в его исполнении будут влиять на время холодного старта. Мы хотели понять, могут ли эти задержки как-то влиять на ANR.

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

  • когда пользователь вручную запускает приложение через лаунчер, то ANR-диалог не появляется и приложение стартует в нормальном режиме, даже если мы добавляем задержку на несколько минут;

  • когда процесс приложения запускается через BroadcastReceiver, то ошибка ANR возникает при задержке от 10 секунд. Это не очень жёсткое ограничение, но в любом случае оно гораздо строже, чем в предыдущем сценарии.

Важное примечание ко второму сценарию: по умолчанию, если приложение находится в фоновом режиме во время появления ANR-ошибки, Android не показывает ANR-диалог (его отображение можно включить с помощью опции “Enable background ANR dialogs” в настройках для разработчиков). Фактически это означает, что пользователи, скорее всего, ничего не заметят и ошибка не будет заметно влиять на пользовательский опыт.

Поэтому мы пришли к выводу, что основной причиной возникновения наших ANR-ошибок, вероятно, является длительное время выполнения метода Application.onCreate. И судя по информации из консоли Google Play, в большинстве случаев это происходило во время исполнения Firebase Cloud Messaging BroadcastReceiver: иногда мы выходили за 10-секундный лимит, что приводило к фоновым ANR-ошибкам.

Мы изучили нашу внутреннюю аналитику, в которой отслеживаем время, прошедшее с момента создания класса Application до завершения метода Application.onCreate (мы называем это холодным запуском). В нашем приложении эта часть процесса запуска самая большая, она включает в себя инициализацию всех ContentProvider’ов и, собственно, сам метод onCreate.

Также мы разделили данные на две части: фоновый холодный запуск и не фоновый. Фоновый запуск мы определяли простым путём: проверяли, был включён экран или нет. В Android, к сожалению, нет простых способов определить причину запуска процесса при исполнении метода Application.onCreate, но в любом случае, скорее всего, этот метод определения фонового запуска даёт более или менее правдивую картину. Вот что мы получили:

Длительность фонового холодного запуска приложения
Длительность фонового холодного запуска приложения
Длительность холодного запуска приложения
Длительность холодного запуска приложения

Можно заметить, что обычный запуск занимал в среднем 2,2 секунды, а фоновый — 5 секунд. Около 3% фоновых запусков длилось дольше 10 секунд. Это говорит о том, что вполне вероятно, в этих случаях могли возникать ANR-ошибки. Чтобы подтвердить или опровергнуть эту гипотезу, мы решили попытаться ускорить запуск приложения.

Ускорение запуска приложения

Чтобы найти потенциальные места для оптимизации на критическом пути запуска приложения, проще всего начать с анализа трассировки вызовов методов. Для этого в Android Studio есть неплохие встроенные инструменты.

В профилировщике Android Studio есть кнопки для запуска приложения с профилированием и для запуска/остановки профилирования во время работы приложения. Кроме того, есть специальные методы, вызвав которые можно начинать или прекращать профилирование из кода. Для получения консистентных результатов между запусками нам лучше подходит второй способ. Можно воспользоваться вот этими методами:

  • startMethodTracingSampling

  • startMethodTracing

  • stopMethodTracing

Начать трассировку можно в статическом инициализаторе класса Application (или в конструкторе), а завершить её либо в конце метода Application.onCreate, либо в onResume первого Activity. Например, класс Application может выглядеть как-то так:

class Application {
    constructor() {
        Debug.startMethodTracingSampling("/sdcard/startup.trace", 64 * 1024 * 1024, 50)
    }

    override fun onCreate() {
        // …
        Debug.stopMethodTracing()
    }
} 

После этого файл с трассировкой можно открыть в Android Studio:

https://habrastorage.org/webt/sv/ze/jv/svzejva_ajvhkc_nhukxcjkhfiq.png
https://habrastorage.org/webt/sv/ze/jv/svzejva_ajvhkc_nhukxcjkhfiq.png

При анализе результатов важно помнить о паре важных моментов.

  • Разница между sample-based- и method-based-трассировкой. При использовании второго подхода можно столкнуться с достаточно большими искажениями в тех случаях, когда есть большое количество вызовов довольно быстрых методов. Sample-based-трассировка работает точнее, но при её использовании могут теряться некоторые вызовы методов. В обоих случаях нет смысла использовать абсолютные значения времени, но sample-based-трассировка даёт более подходящие результаты для относительного анализа.

  • Разница между debug- и release-сборкой приложения. При профайлинге запуска приложения имеет смысл выполнять тестирование в окружении, которое как можно ближе к продакшен-версии. Для этого желательно отключить все отладочные инструменты (такие как Leak Canary) либо собрать релизную сборку приложения. 

Проанализировав трассировку, мы нашли несколько участков кода, которые выполнялись довольно долго. У нас не было ресурсов на то, чтобы переписать всё приложение для ускорения его запуска (хотя иногда это бывает необходимо ?), поэтому мы попытались получить максимальный результат, приложив минимум усилий. Мы использовали несколько подходов. Я расскажу о некоторых из них, которые вы тоже можете применять в своих приложениях.

Правильный выбор жизненного цикла для компонентов

Одним из простейших способов уменьшения времени запуска приложения является ленивая инициализация всех компонентов. Довольно часто может возникать соблазн создавать различные компоненты и структуры данных в application scope, так как это удобно в том плане, что они будут доступны в течение всего жизненного цикла приложения. Но по возможности лучше этого избегать. Правильный выбор набора компонентов в application scope может не только ускорить запуск, но и уменьшить потребление памяти, потому что вы не будете хранить ссылки на «лишние» объекты в течение всего времени работы приложения.

Фоновая инициализация и инициализация с задержкой

Если при запуске приложения вам нужно инициализировать какие-либо структуры или объекты, подумайте, возможно ли это сделать немного позже и не на основном потоке. Если же код обязательно должен быть выполнен на основном потоке, то может помочь обычная задержка в несколько секунд (Handler.postDelayed). Распределение задач по времени также снижает вероятность блокирования главного потока.

Сторонние ContentProvider’ы

Некоторые сторонние библиотеки могут инициализировать код с использованием ContentProvider. Всегда важно смотреть, что у вас содержится в итоговом AndroidManifest.xml, поскольку по умолчанию инструменты сборки Android будут автоматически добавлять ContentProvider из всех сторонних библиотек, что может повлиять на время запуска приложения.

Если для каких-то библиотек вам не нужна автоматическая инициализация ContentProvider, вы можете отключить её, добавив в AndroidManifest.xml такую запись:

<provider
    android:name="some.ContentProvider"
    tools:node="remove" /> 

Это бывает полезно, если вы хотите сами контролировать процесс инициализации. Например, вы можете инициализировать библиотеку, вызвав тот же код, что и в ContentProvider, но только тогда, когда вам действительно нужно использовать предоставляемую библиотекой функциональность. Однако надо проявить осторожность и провести тщательное тестирование после внесения этих изменений, так как библиотека может ожидать инициализации в Application.onCreate.

Когда мы выпустили обновление с нашими оптимизациями, 95-й перцентиль холодного запуска приложения уменьшился примерно на 50% (с ~10 секунд до ~5 секунд):

А что насчёт количества ANR-ошибок? Вот что мы увидели в консоли Google Play:

Источник: сериал «Офис»
Источник: сериал «Офис»

Итак, мы выяснили, что в нашем случае скорость запуска приложения напрямую влияет на количество ANR-ошибок. Но порог “Bad Behaviour” всё равно был превышен, так что мы продолжили искать способы решения этой проблемы.

SharedPreferences и метод apply()

При анализе стек-трейсов в Google Play и изучении внутренних отчётов мы заметили вот такие странные группы:

Это говорит о том, что есть много случаев, когда главный поток блокируется где-то в недрах Android, в месте, связанном с SharedPreferences. 

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

Мы проверили свою кодовую базу и не обнаружили использования метода commit, который выполняет запись на диск в блокирующем режиме. Все операции записи в SharedPreferences были сделаны с применением метода apply. Почему же тогда мы столкнулись с этой проблемой, если мы используем неблокирующий API?

Найти причину поможет внимательное изучение исходного кода Android. Стандартная реализация SharedPreferences находится в SharedPreferencesImpl.java. При внесении изменений в SharedPreferences записи о них сохраняются во временную HashMap, которая затем при вызове commit или apply применяется к кешу, хранящемуся в оперативной памяти. Во время обновления кеша также вычисляется, какие ключи были изменены, чтобы потом, когда нам нужно будет записать эти изменения на диск, мы не делали лишнюю работу. Информация об изменениях хранится в MemoryCommitResult.

Если посмотреть на тело метода apply(), то мы увидим, что он просто отправляет лямбду записи на диск в метод enqueueDiskWrite(), что выглядит довольно безобидно и намекает на то, что эта лямбда будет выполнена на фоновом потоке. Вот упрощённая реализация метода apply():

@Override
public void apply() {
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = () -> mcr.writtenToDiskLatch.await();

    QueuedWork.addFinisher(awaitCommit);

    Runnable postWriteRunnable = () -> {
        awaitCommit.run();
        QueuedWork.removeFinisher(awaitCommit);
    }

    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
} 

Здесь сначала создаётся объект Runnable — лямбда, которая может синхронно выполнить запись. Этот Runnable добавляется в QueuedWork. Если мы посмотрим JavaDoc для QueueWork класса, то увидим примерно следующее:

Internal utility class to keep track of process-global work that's outstanding and hasn't been finished yet.

This was created for writing SharedPreference edits out asynchronously so we'd have a mechanism to wait for the writes in Activity.onPause and similar places, but we may use this mechanism for other things in the future.

Этот класс хранит в себе список всех отложенных асинхронных операций с возможностью исполнить их синхронно в случае наступления таких событий, как Activity.onStop, Service.onStartCommand или Service.onDestroy.

Это означает, что простое использование стандартных компонентов Android может приводить к выполнению всех отложенных дисковых операций на главном потоке. И если любая из этих операций выполняется сразу после вызова метода apply(), то он фактически становится синхронным методом commit().

Мы подозреваем, что это было сделано для снижения риска потери данных, когда процесс приложения убивается по разным причинам. Но насколько вероятна такая ситуация? Обычно мы используем SharedPreferences, когда приложение находится на переднем плане и у него мало шансов быть остановленным системой. Более того, часто мы используем SharedPreferences для кеширования значений, которые можно легко восстановить с сервера, так что потеря таких данных не особо страшна.

Чтобы узнать, как SharedPreferences влияют на количество ANR-ошибок и полезна ли эта синхронная логика apply, мы решили провести A/B-тест, который меняет поведение этого метода. Для этого мы заменили все операции создания SharedPreferences на функцию-фабрику:

fun Context.createPreferences(name: String, mode: Int = Context.MODE_PRIVATE): SharedPreferences = getSharedPreferences(name, mode) 

Теперь мы можем управлять реализацией SharedPreferences в нашем приложении и использовать любую другую альтернативную реализацию вместо системной. Мы создали простой класс-имплементацию SharedPreferences, который делегирует в настоящую реализацию всё, кроме метода apply(): вместо него мы вызываем метод commit() на фоновом потоке. Эта реализация очень похожа на то, что сделано в другой библиотеке-альтернативе SharedPreferences — Binary Preferences, только в нашем случае мы не меняем механизм сериализации-десериализации для упрощения обратной миграции в случае возникновения проблем.

В итоге код с новой реализацией в A/B-тесте выглядел примерно так:

fun Context.createPreferences(name: String, mode: Int = Context.MODE_PRIVATE): SharedPreferences =
    if (isAsyncCommitAbTestEnabled()) {
        getAsyncCommitSharedPrefs(this, name, mode)
    } else {
        getSharedPreferences(name, mode)
    } 

Затем мы начали медленно раскатывать A/B-тест, следя за основными показателями. У нас достаточно хорошее покрытие продуктовыми и техническими метриками и есть инструменты, которые уведомляют разработчиков о значительных отклонениях в любой из них.

https://habrastorage.org/webt/pz/kx/hm/pzkxhmwesuucvusye6qlix_2ffa.png
https://habrastorage.org/webt/pz/kx/hm/pzkxhmwesuucvusye6qlix_2ffa.png

В результате мы не обнаружили никаких проблем, а общее количество ANR-ошибок уменьшилось примерно на 4% по сравнению с контрольной группой A/B теста. Неплохо, но это всё ещё выше порогового значения.

Обработка push-уведомлений

Поскольку мы не достигли своей цели, нам требовалось найти способы дальнейшего снижения ANR rate. 

До этого мы выяснили, что существует практически линейная корреляция между длительностью запуска приложения и ANR rate. Скорее всего, дело в том, что большинство ANR-ошибок в нашем приложении возникало при обработке BroadcastReceiver, на стадии Application.onCreate. К сожалению, у нас больше не осталось простых способов ускорения запуска — всё остальное требовало серьёзного рефакторинга и большого объёма работ.

В ходе анализа репортов мы также заметили, что большинство запусков нашего процесса выполнялось при обработке BroadcastReceiver для push-уведомлений. И у нас возникла идея: может быть, можно обрабатывать все уведомления в отдельном процессе, который не нуждается в инициализации компонентов, необходимых при обычном запуске приложения?

По умолчанию в Android ваше приложение выполняется внутри одного процесса. Когда вы кликаете по иконке, система создаёт для приложения процесс. В случае получения BroadcastReceiver-интента система запустит процесс, только если он ещё не запущен. Каждый раз при запуске процесса приложения Android вызывает метод Application.onCreate:

Но есть способы запустить некоторые части приложения в отдельных процессах. В таких случаях будут создаваться отдельные экземпляры класса Application для каждого процесса. Это может помочь нам в решении задачи, так как даёт возможность убрать практически всё содержимое метода Application.onCreate, перенеся в отдельный процесс только код, связанный с обработкой push-уведомлений. Это должно значительно ускорить обработку BroadcastReceiver и снизить вероятность возникновения ANR-ошибок:

Вы можете контролировать процесс, в котором будет запускаться компонент, с помощью AndroidManifest.xml. Например, для запуска BroadcastReceiver в нестандартном процессе нужно добавить название процесса в тег “receiver” с помощью атрибута “android:process”. Но как это сделать, если мы используем внешнюю библиотеку вроде Firebase Cloud Messaging?

Есть специальные теги, которые контролируют процесс объединения манифестов из библиотек. Можно пропатчить исходное объявление манифеста FCM BroadcastReceiver с помощью атрибута tools:node=”replace”. Помимо FCM BroadcastReceiver, за обработку push-уведомлений отвечает ещё и FirebaseMessagingService, и нам нужно запускать его в том же процессе. В итоге нужно добавить в манифест следующие записи:

<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
android:exported="false"
android:process=":light"
tools:node="replace">
  <intent-filter android:priority="-500">
  <action android:name="com.google.firebase.MESSAGING_EVENT" />
  </intent-filter>
</service>

<receiver
android:name="com.google.firebase.iid.FirebaseInstanceIdReceiver"
android:exported="true"
android:permission="com.google.android.c2dm.permission.SEND"
android:process=":light"
tools:node="replace">
  <intent-filter>
  <action android:name="com.google.android.c2dm.intent.RECEIVE" />
  </intent-filter>
</receiver> 

Теперь, когда сервисы Google Play отправляют broadcast с новым сообщением, мы можем выключить обычную инициализацию в Application.onCreate, проверив, находимся ли мы в главном процессе:

class Application {
	override fun onCreate() {
		if (isMainProcess) {
			// perform usual initialization
		}
	}
}

Есть много способов имплементации этой проверки. Один из них можно посмотреть здесь.

Если вы не делаете ничего особенного с push-уведомлениями, то, вероятнее всего, этого будет достаточно и всё будет работать из коробки. Ваш код, отвечающий за отображение уведомлений, будет исполняться в отдельном процессе, а Activity из PendingIntent будут запускаться в основном.

После того как мы это реализовали, уведомления стали отображаться значительно быстрее:

Мы выпустили обновление с этими изменениями и начали постепенно его раскатывать, следя за основными метриками.

Примерно через неделю большинство наших пользователей обновили приложение — и вот что мы получили:

  • доля ANR-ошибок в Badoo уменьшилась с 0,8% до 0,41% и в конце концов стала ниже, чем у пиров*, достигнув отметки в 0,28%:

*в Google Play имеется возможность выбрать произвольный набор приложений-пиров и выполнять сравнение ваших метрик с медианой пиров.

  • абсолютное количество ANR-ошибок в день уменьшилось более чем вдвое.

Хотя в нашем случае эти изменения сильно повлияли на ANR rate, стоит иметь в виду несколько вещей, прежде чем реализовывать что-то похожее в своём приложении:

  • Разработчики FCM библиотек могли не предусматривать изменение её поведения с помощью патчинга манифеста и работу в отдельном процессе. Это не описано в официальной документации, так что есть риск, что в любой момент этот функционал может быть сломан, поэтому он требует тщательного тестирования после каждого обновления библиотек.

  • Обновлять FCM-библиотеки нужно крайне осторожно, так как при каждом обновлении необходимо проверять, соответствуют ли записи пропатченного XML-манифеста исходным.

  • Добавление дополнительного процесса требует больше ресурсов памяти и процессора

Учитывая всё это, я рекомендую использовать этот подход только в качестве крайней меры или временного решения. Если у вас есть возможность ускорить запуск приложения, то лучше сосредоточиться на этом, потому что это поможет не только снизить ANR rate, но и уменьшить длительность холодного запуска.

Общие результаты

В результате всех изменений мы уменьшили ANR rate и общее количество ANR-ошибок примерно в шесть раз:

Надеюсь, наш опыт поможет вам снизить ANR rate и повысить качество вашего приложения. 

Кто сталкивался с интересными багами, связанными с ANR? Расскажите о своём опыте в комментариях.

Ссылки

  1. Методы управления трассировщиком

  2. Статья Snap Engineering про переписывание приложения

  3. Описание внутреннего устройства SharedPreferences

  4. Исходный код SharedPreferences

  5. Библиотека для выполнения инициализации в разных процессах