Привет! Меня зовут Абакар, я работаю главным техническим лидером разработки в Альфа-Банке.

Если вы Андроид-разработчик, думаю, вам часто приходилось сталкиваться с ситуациями, когда код вашего приложения выбрасывает необрабатываемое исключение и ваше приложение закрывается. На сленге можно сказать, что «приложение крашится».

Сегодня мы с вами разберёмся, почему это происходит и какие механизмы лежат в основе такого поведения. Итак, в путь!

Простой пример: деление на ноль

Для начала соберем небольшой примерчик. Представим, что у нас есть активити и в её методе onCreate располагается следующий код:

Handler(Looper.getMainLooper()).postDelayed({ val k = 5 / 0 }, 200)

Итак знатоки, вопрос: Что мы получим при запуске приложения ? Ответ прост, как никогда - мы получим краш и закрытие приложения, а в LogCat увидим следующий стектрейс:

А почему оно закрылось?

Все логично: попытались поделить на ноль, получили бесконечность ArithmeticException и наше приложение благополучно закрылось.

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

Давайте еще раз посмотрим на стектрейс, который мы получили при возникновении краша:

Пойдём старым дедовским способом снизу вверх и будем искать ответ в нашем расследовании, кто же всё-таки является убийцей нашего процесса (думаю, это точно не садовник). Пристегиваемся и летим в ZygoteInit!

Zygote и запуск приложений

Zygote — это фундаментальный и ключевой компонент архитектуры Android, отвечающий за запуск приложений. По сути, это «шаблон» процесса, от которого отпочковываются все новые приложения в системе.

Мы увидим следующий код:

Обратите внимание, что в самом начале метода, если у нас режим RuntimeInit дебажный, то логируется форк приложения от Zygote. После этого идёт работа с трейсом и redirectLogStreams(). Последний нужен, чтобы все System.out and System.err шли в андроидовский логкат.

А дальше у нас идёт RuntimeInit.commonInit().

Обратите внимание: ссылка выше переводит на код в GitHub. По сути, это бэкап исходников для api-34, я дебажил и смотрел именно на ней. В зависимости от версии SDK могут меняться исходники, которые мы будем просматривать ниже. В рамках статьи это не сильно критично, а вот если вы будете изучать вопрос дальше, может стать важным.

Заглянем, что происходит там.

Как Android обрабатывает непойманные исключения?

А тут наш взгляд сразу ложится на конструкцию, в рамках которой нашему потоку проставляется UncaughtExceptionHandler.

Комментарий говорит о том, что этот обработчик присваивается всем потокам в рамках нашей VM. Причем preHandler переопределить нельзя, а вот defaultHandler можно (что мы также проверим).

UncaughtExceptionHandler в Java — это интерфейс, который позволяет перехватывать и обрабатывать исключения, которые не были пойманы в коде приложения и привели к аварийному завершению потока.

А теперь отправляемся в KillApplicationHandler и посмотрим, что это за зверь такой. Внутри этого класса мы увидим метод uncaughtException().

uncaughtException() как раз то, что нам нужно. Будем изучать его по строчкам, в одной из которых заметим вызов метода ensureLogging: нужен для того, чтобы удостовериться, что LoggingHandler отработал. А если не отработал — то ensureLoggingзаставит это сделать, чтобы информация о краше не пропала:

После идет проверка на if(mCrashing). Нужна, чтобы мы не ушли в бесконечный цикл, например если при обработке краша произошел еще один краш :)

Кто сообщает о краше?

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

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

ActivityThread — это один из фундаментальных компонентов Android SDK. Если упростить, то это «точка входа» и «менеджер главного потока» для вашего приложения. Также в ActivityThread находится заветный метод main, с которого стартует наше приложение.

Затем мы видим, как через ActivityManager мы показываем пользователю диалог о том, что приложение завершилось аварийно, a.k.a крашнулось. А именно вызывается метод handleApplicationCrash().

ActivityManager — это системный сервис Android, который управляет жизненным циклом приложений и их компонентов. Если ActivityThread работает внутри процесса вашего приложения, то ActivityManager работает на уровне всей системы.

Идём дальше:

Заметили интересную проверку на DeadObjectException в catch?

DeadObjectException — это исключение в Android, которое возникает при попытке взаимодействия с объектом, чей процесс уже умер.

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

Далее мы видим, что если условие на DeadObjectException не выполнено, то мы пытаемся залогировать тот факт, что мы получили exception во время обработки краша приложения.

Ну а если и это не получилось, то нас просто повеселит комментарий в catch: «Even Clog_e() fails! Oh well» — ну что тут уже поделаешь, можно только развести руками.

Почему в итоге приложение закрывается?

Идем дальше. У нас перед глазами есть такой код:

Ба! Да, это же то, что мы искали! Именно из-за этого происходит закрытие нашего приложения: старая добрая пара killProcess, который убивает процесс на уровне Linux и System.exit(), который завершает работу виртуальной машины.

Хороший вопрос, почему константа это 10.

Возможно, чтобы не пересекаться со стандартными кодами системы? Если у вас идеи почему именно 10 — оставляйте комментарий!

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

А можно ли сделать так, чтобы оно не закрывалось?

Давайте представим, что у нас есть экран с кнопкой и текстом, который выводит счетчик. Когда мы нажимаем на кнопку — счетчик увеличивается:

А теперь мы в нашу активити добавим следующий код:

Запускаем выполнение деления на ноль с задержкой, и сразу вешаем на текущий поток (aka главный поток) деление на ноль. Заметим, что в хендлере мы не завершаем текущий процесс. Если после этого запустим приложение, то оно не закроется после краша :)

Ну и как — добились своего? Добились, конечно, но какой ценой?

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

В таком случае лучше просто завершить приложение, потому что мы будем получать непредсказуемое поведение нашей системы. Мне в свое время приходилось с таким сталкиваться при написании десктопного приложения на JavaFX: там необработанный краш по дефолту не приводил к закрытию приложения и оно продолжало работать. Но работало с багами. Причём каждый раз с новыми :)

Изменится ли что-нибудь, если я поменяю код таким образом: просто вынесу деление на другой поток?

Поведение действительно поменяется. Теперь при краше наше приложение закрывается. Но это и логично, так как мы переопределили обработчик только для главного потока, а вот все остальные потоки продолжают использовать KillApplicationHandler, который и закрывает приложение.

Выводы

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

Также я веду YouTube-канал, на котором выпускаю видео по разного рода темам из IT. Подписывайся, если интересно!

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