Как мы добились 6-кратного сокращения ANR - Часть 2: Исправление ANR

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

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

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

В целом почти каждая группа сбоев имела заголовок «Передача намерения {act = com.google.android.c2dm.intent.RECEIVE}…». Проверяя фактические трассировки стека, которые мы собрали с помощью нашего парсера, мы заметили, что около 60% отчетов находились в методе Application.onCreate в момент срабатывания ANR.

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

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

  • Когда пользователь вручную запускает приложение с помощью приложения запуска, ANR не будет сообщаться о блокировке основного потока в Application.onCreate, даже если он заблокирован на несколько минут.
  • Когда приложение запускается с помощью широковещательного приемника, ANR не будет сообщаться о блокировке основного потока менее 10 секунд. Сроки не очень строгие и имеют некоторое пространство для маневра, но они намного строже, чем в предыдущем случае.

Во втором случае есть важное замечание: по умолчанию, если ваше приложение находится в фоновом режиме, Android не отображает никаких диалогов, и об этом ANR будет сообщаться без уведомления. Есть способ включить отображение таких диалогов с помощью параметра «Включить фоновые диалоги ANR» в меню разработчика. Фактически это означает, что ваши пользователи, скорее всего, ничего не заметят, и это не окажет существенного влияния на ваше приложение.

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

Мы проверили нашу внутреннюю аналитику, где мы записываем время, прошедшее между созданием класса Application и концом метода Application.onCreate: мы называем это «время холодного запуска приложения». Эта часть процесса запуска приложения является самой большой для нашего приложения и включает в себя инициализацию всех поставщиков контента и метод onCreate.

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

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

Сокращение времени запуска приложения

Самый простой способ определить потенциальные области для улучшения критического пути запуска приложения - это сделать дамп трассировки ЦП. В Android Studio доступны отличные инструменты, которые могут помочь вам в этом.

В профилировщике Android Studio есть кнопки для запуска приложения с профилированием или для запуска и остановки его вручную, но есть также специальные системные методы, которые могут запускать и останавливать профилирование из кода. Эти методы могут помочь вам надежно получить последовательные дампы ЦП:

  • startMethodTracingSampling
  • startMethodTracing
  • stopMethodTracing

Запуск приложения можно зафиксировать, начав трассировку в блоке статического инициализатора класса Application и завершив в конце Application.onCreate или onResume первого действия. Это может выглядеть так:

После загрузки файла трассировки и открытия его в Android Studio вы увидите что-то вроде этого:

При анализе результатов важно учитывать следующее:

  • Разница между трассировкой на основе выборки и трассировкой на основе метода. Трассировка на основе методов более склонна к искажению зафиксированного времени, затрачиваемого на выполнение определенных методов, в то время как подход на основе выборки более точен, но может привести к потере некоторых вызовов методов. Вы не можете использовать абсолютные значения времени из обоих методов, но трассировка на основе выборки дает более полезные данные для выполнения относительного сравнения.
  • Разница между производственным и отладочным приложением. Если вы хотите зафиксировать трассировку ЦП в среде, максимально приближенной к производственной, вам необходимо отключить все инструменты отладки, такие как утечка канарейки, или скомпилировать приложение с использованием типа сборки выпуска.

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

Правильно определите масштаб компонентов

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

Фоновая и отложенная инициализация

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

Сторонние поставщики контента

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

Если вам не нужна автоматическая инициализация поставщиков контента для определенных библиотек, вы можете изменить манифест, добавив в свой манифест следующую запись:

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

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

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

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

SharedPreferences и метод apply ()

Еще одна вещь, которую мы заметили во время анализа трассировок стека ANR в Google Play и изучения наших внутренних отчетов, - это следующие странные группы:

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

Мы проверили нашу кодовую базу и не обнаружили использования метода фиксации (который выполняет блокировку записи общих настроек на диск). Все наши модификации были сделаны с использованием метода apply. Итак, как возможно, что у нас возникают такие проблемы, когда мы используем неблокирующий API?

Более глубокое изучение исходного кода Android поможет нам увидеть, что происходит. Реализация общих настроек по умолчанию находится в SharedPreferencesImpl.java. Во время редактирования общих предпочтений с помощью интерфейса редактора все изменения сохраняются во временной хэш-карте, которая затем применяется к основному кешу в памяти при фиксации или применении вызова. Когда он применяет карту к кэшу в памяти, он также вычисляет, какие ключи были изменены, чтобы использовать его в дальнейшем, когда нам нужно записать эти изменения на диск и уведомить слушателей общих предпочтений. Эта информация хранится в MemoryCommitResult.

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

При более внимательном рассмотрении мы видим, что сначала он создает Runnable, который ожидает записи в синхронном режиме. Этот исполняемый файл добавлен в QueuedWork. Когда мы проверяем JavaDoc на наличие этих классов, мы видим следующее:

Внутренний служебный класс для отслеживания незавершенных и еще не завершенных глобальных процессов.

Он был создан для асинхронной записи изменений SharedPreference, чтобы у нас был механизм ожидания записи в Activity.onPause и подобных местах, но мы можем использовать этот механизм для других вещей в будущем.

Этот класс содержит все ожидающие асинхронные операции в списке с возможностью синхронного их выполнения для нескольких событий, таких как Activity.onStop, Service.onStartCommand и Service.onDestroy. Скорее всего, это было сделано для снижения вероятности потери данных при неожиданном завершении процесса.

Мы видели, что если вы просто используете стандартные компоненты Android, это может иногда приводить к запуску всех ожидающих операций записи на диск общих настроек в основном потоке. Если мы выполним любую из этих операций сразу после вызова метода apply, он фактически станет методом синхронной фиксации.

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

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

Теперь у нас есть контроль над реализацией общих предпочтений в нашем приложении, и мы можем создать альтернативный вариант. Мы создали простой класс, который делегирует все исходной реализации, кроме метода apply: здесь мы вместо этого вызываем метод фиксации и планируем его выполнение в фоновом потоке. Реализация очень похожа на библиотеку, которая предоставляет альтернативу общим настройкам: двоичные настройки, за исключением того, что мы не меняем механизм сериализации / десериализации, чтобы облегчить обратную миграцию в случае проблем.

Итак, представив новую реализацию, мы можем использовать ее в тесте A / B:

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

В результате мы не обнаружили проблем с новой реализацией и добились снижения общего числа ANR примерно на 4% по сравнению с контрольной группой. Это неплохо, но мы все равно превысили требуемый порог.

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

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

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

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

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

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

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

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

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

Есть много способов проверить это. Вы можете проверить одну из реализаций здесь.

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

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

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

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

  • Показатель ANR Badoo упал с 0,80% до 0,41% и в конечном итоге упал ниже, чем у наших аналогов, до 0,28%.

  • Абсолютное количество ANR за день снизилось более чем в 2 раза

Несмотря на то, что эти изменения существенно повлияли на скорость ANR, вам необходимо учесть несколько вещей, прежде чем внедрять их в свое приложение:

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

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

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

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

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

Сталкивались ли вы с любыми интересными ошибками ANR? Поделитесь своим опытом в комментариях ниже :)

Полезные ссылки

  1. Https://developer.android.com/reference/android/os/Debug
  2. Https://eng.snap.com/dont-rewrite-your-app-unless-you-have-to/
  3. Https://programmer.ink/think/count-the-slots-in-shared-preferences.html
  4. Https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/app/SharedPreferencesImpl.java
  5. Https://github.com/int02h/primaree