October 29

Секрет долголетия вашего приложения

Интро

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

Оказалось, что процесс приложения продолжает работать. И теперь, чтобы точно перезапустить приложение, нужно останавливать его в настройках. В первую очередь подумал, что это новая секретная оптимизацией недавних обновлений системы, так как при живом процессе onCreate у Application не вызывается повторно, а скорость запуска приложения ускоряется в разы. И оптимизацию скорее всего сделали в Android 14 и в свежих релизах. У меня на руках два устройства: Pixel 7 и Pixel 6 Pro. Оба с Android 14, но на Pixel 6 Pro нет такой оптимизации, — приложение умирает при удалении из недавних — но и сборка там февральская, когда как на Pixel 7 сентябрьская сборка и оптимизация присутствует.

Исследуем

Решил проверить не только наше приложение, и оказалось, что оптимизация работает не для всех. Например, сработала для Telegram и ряда банков, но не сработала, например, для Tooba. Для последнего в логах вывелось сообщение Killing 6505:site.tooba.android/u0a410 (adj 905): remove task, которого не было для других.

Стремясь понять всю цепочку событий начал исследование издалека, а именно с момента смахивания приложения из списка недавних. Происходит это в RecentsView, в котором в момент завершения анимации вызывается метод removeTaskInternal. Он делегирует вызов в ActivityManagerWrapper.

Делегирование на этом не заканчивается - обертка делегирует вызов в сервис IActivityTaskManager.

Единственной реализацией этого сервиса является ActivityTaskManagerService, который делегирует вызов в один из методов ActivityTaskSupervisor.

Тут не сильно важно, что вызовется в итоге, — removeTask или removeRootTask — потому что removeRootTask для простого случая начнет в цикле вызывать removeTask для всех листьев. Он же останавливает запущенные Activity и далее вызывает метод cleanUpRemovedTask. В этом методе выполняются три интересных действия:

  • Очистка сервисов в cleanUpServices
  • Проверка, что Activity завершились. Если нет, то убийство откладывается на 1000ms. После чего будет вызван метод killTaskProcessesIfPossible
  • Убийство, если это возможно, в методе killTaskProcessesIfPossible

В killTaskProcessesIfPossible, происходит несколько проверок, к которым есть описание в виде комментариев и убийство снова делегируется в ActivityManagerInternal путем отправки сообщения.

ActivityManagerInternal реализует внутренний класс LocalService, который лежит внутри ActivityManagerService (не путать с ActivityTaskManagerService). И вот наконец мы пришли к месту, где начинает формироваться наш лог с сообщением "remove task".

Но, кроме этого, видно, что процесс будет убит, если выполняются условия:

  • Процесс в фоне
  • Нет работающих BroadcastReceiver
  • Нет работающих Service

В ином случае мы откладываем убийство установкой флага в ProcessRecord. Теперь возникает вопрос: кто убьет приложение позже, так как никакое отложенного сообщение тут не отправляется?

Знакомьтесь, OomAdjuster. В задачи OomAdjuster входит определение состояния процесса, оценка oom_adj с предварительной сортировкой в CacheOomRanker, а так же он умеет замораживать и размораживать процессы, используя CachedAppOptimizer. Все эти процессы запускаются по различным триггерам по типу запуска какого-то компонента или его завершение. Даже смерть процесса - это триггер, который прилетает в OomAdjuster из ActivityManagerService и инициируется в слушателе состояния процесса AppDeathRecipient.

Убивать OomAdjuster умеет в случаях если приложение долго остается замороженным/закешированным или в случае, когда установлен флаг waitingToKill, который мы ранее видели в ActivityManagerService#LocalService, и выполнены те же самые условия. Поэтому получается, что флаг waitingToKill отложит убийство до возникновения любого триггера у OomAdjuster.

Теперь осталось понять, что именно изменилось в этих классах за последнее время. Долго искать не пришлось - нашелся такой коммит, который попал в основную ветку в феврале 2024, а сделан был еще в ноябре в 2023.

Изменения в коммите минимальные - перед убийством добавили проверку того, что имеются started сервисы в ActivityManagerService#LocalService и OomAdjuster.

Выше - ActivityManagerService#LocalService. Ниже - OomAdjuster

Забавно, что коммент перед установкой флага ожидания, не обновили.

Сначала, изменение показалось странным, ведь ранее мы встречали такой метод как cleanUpService, где должна была происходить остановка сервисов аналогично тому, что рядом происходит остановка активностей. Оказалось, что метод делегируется в ActivityServices, который останавливает сервисы только в том случае, если у них в AndroidManifest.xml указан android:stopWithTask="true". В ином случае запускается большая цепочка событий определения того, когда стоит перезапустить сервис и с какими аргументами, но остановки сервиса не будет, следовательно, и остановки процесса приложения.

Эксплуатируем

Мы можем проверить гипотезы, попыткой воспроизвести проблему в приложении с одним сервисом. И, о чудо, приложение не умирает. Иногда 2 минуты, а иногда все 10.

При этом сам сервис OomAdjuster и ActivityServices уже убили ввиду его бездействия.

А вот с февральской сборкой на Pixel 6 Pro поведение не получается повторить - туда этот коммит не попал. Так же не получается повторить с флагом stopWithTask=true.

Теперь стало интересно как долго можно сохранять приложение живым. Для этого сделал приложение, в котором сервис стартует и периодически делает какую-то работу. Но есть еще вариант заставить приложение жить еще дольше, выкинув в onDestroy сервиса ошибку, чтобы процесс завершения сервиса не был успешным никогда. А затем эту ошибку можно поймать с помощью Thread.setDefaultUncaughtExceptionHandler, чтобы у нас не упало приложение. Но есть проблема, и она заключается в том, что из-за того, что мы выкинули ошибку в onDestroy, пользователь через некоторое время увидит диалог об ANR. И, если он не нажмет "Close App", то мы продолжим жить. Правда он будет получать этот диалог с определенной периодичностью.

Кроме этого, если пользователь зайдет в приложение, то оно у него не запустится дальше Splash Screen. Эту проблему можно исправить, перезапустив Looper в Exception Handler, но это все равно не поможет отвязаться от диалога с ANR.

Тут можно вспомнить, что кроме Service, который предотвращает убийство на некоторых версиях Android 14, есть еще BroadcastReceiver. Поэтому попробовал отправлять сообщение в моменте закрытия приложения и получил такой же эффект - приложение не будет убито. Кроме этого, мы, конечно, не можем запускать сервис из фона, но никто не запрещал отправлять широковещательное сообщение, поэтому вполне рабочий вариант отправлять новое сообщение в том же самом BroadcastReceiver.

Но если отправлять сообщения слишком часто, то система в OomAdjuster нас пометит как "excessive processes" и прибьет, потому что мы выполняем слишком много транзакций через Binder(хотя это не так уж часто возникает). И чтобы решить эту проблему, можно делать какую-либо работу или подобрать такой таймаут, чтобы и система нас не прибила, и ANR не вылез.

Осторожно. Псевдокод

Так же можно поступить для Service в Android 15 - выполнять долгую работу в onDestroy и отправлять в конце сообщение BroadcastReceiver. Но работу в этом случае мы можем выполнять до 200 секунд и не получать ANR.

Осторожно. Псевдокод

Ну и вишенкой будет запуск фонового сервиса при получении сообщения BOOT_COMPLETED в BroadcastReceiver. Экспериментально получилось убедиться, что Android такое позволяет делать.

Осторожно. Псевдокод

Итог

Изменение, найденное случайным образом, привело к тому, что был найден способ поддерживать живым приложение очень долго. Благодаря этому приложение будет готово к запуску всегда, а его запуск будет приравниваться к запуску обычной Activity. В моем случае, приложение работало несколько часов до момента, пока устройство не ушло в глубокий сон. И все это без Activity, без Foreground Service и без permission'а на работу в фоне.

Я вынес код в отдельную библиотеку "Philosopher's Stone" и рядом добавил пример приложения, которое можно скачать в виде apk и протестировать сколько оно проживет на вашем устройстве. Для этого запустите приложение, а затем смахните его из списка недавних. Позже, периодически открывая и закрывая активность, вы будете видеть как долго работает приложение.

Опубликовано в Полуночные Зарисовки