TL;DR: В большинстве приложений имеет смысл принять явное осознанное архитектурное решение, что в случае смерти процесса приложение просто перезапускается с нуля, не пытаясь восстанавливать состояние. И в этом случае Serializable
, Parcelable
и прочие Bundle
не нужны.
Если хотя бы одна активность приложения находится между onStart()
и onStop()
, то гарантируется, что активность, а следовательно, и процесс, в котором активность живёт, находятся в безопасности. В остальных случаях операционная система может в любой момент убить процесс приложения.
Мне не приходилось реализовывать прозрачную (то есть чтобы было незаметно для пользователя) обработку смерти процесса в реальном приложении. Но вроде бы это вполне реализуемо, набросал рабочий пример: https://github.com/mychka/resurrection.
Идея состоит в том, чтобы в каждой активности в onSaveInstanceState()
сохранять всё состояние приложения, а в onCreate()
, если процесс был убит, восстанавливать:
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ResurrectionApp.ensureState(savedInstanceState)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
ResurrectionApp.STATE.save(outState)
}
}
Для того, чтобы сэмулировать убийство процесса, можно свернуть приложение и использовать команду
adb shell am kill org.resurrection
Если решаем обрабатывать смерть процесса, то можно отметить следующие накладные расходы.
Усложнение кода.
- Если не заботимся о смерти, то можно в любом месте воткнуть статическое поле, и хранить там состояние приложения, ни о чём не беспокоясь. Если заботимся, то нужно аккуратно следить за состоянием, чтобы не забыть что-нибудь сохранить. Но в чём-то это даже плюс: дисциплинирует, может положительно повлиять на архитектуру, помочь избежать утечки памяти.
- Всё состояние должно быть честно
Serializable
/Parcelable
. - Восстановление состояния — это не только десериализация, но и приведение к консистентному виду. Например, до смерти процесса был флажок
loading == true
и запущенный поток. Так как после смерти процесса поток умер, нужно либо этот поток перезапустить, либо сброситьloading
вfalse
. То же самое с открытыми TCP-соединениями, которые после смерти процесса закрываются. - Нужно следить за кодом, который вызывается до
Activity#onCreate()
— например, за статикой и инициализацией полей — так как в этот момент глобальное состояние может быть ешё не восстановлено.
В целом, мне кажется, описанные выше проблемы вполне решаемы так, чтобы полностью оградить основной код. Нужно немного дисциплины, а современные инструменты позволят всё спрятать в базовые классы и аннотации, в крайнем случае помогут рефлексия, манипуляция байт-кодом и кодогенерация.
Приходится в каждом
Activity#onSaveInstanceState()
сохранять полностью всё состояние приложения. (Наверное, еслиActivity#isChangingConfigurations == true
, то можно сэкономить, не сохранять.) Но не думаю, что это может сказаться на производительности, так как более-менее современные смартфоны достаточно мощные.
Случай смерти процесса нужно тестировать. Иначе нет смысла вкладываться в пункт 1, а в итоге всё равно иметь неработающую фичу. В случае большого приложения с сотней активностей/фрагментов тестирование может вылиться в копеечку.
Безопасность. Я в эту тему не углублялся, но, наверное, если чувствительная информация хранится только в оперативной памяти, то украсть её сложнее, чем если она в случае смерти процесса дампится куда-то на жёсткий диск.
На практике, думаю, никакой разницы нет: если смогли украсть данные из дампа, то и без дампа украдут. Непреодолимой трудностью может оказаться убедить в этом надзорные органы, когда речь идёт, например, о мобильном банкинге.
Отдельного исследования заслуживает вопрос о том, насколько вообще вероятно убийство процесса приложения в реальной жизни. Как вариант, чтобы снизить вероятность, можно запускать foreground сервис. Но не думаю, что это правильно.
По моему мнению (с которым многие в комментариях не согласились) прозрачно обработать смерть процесса технически возможно, но в большинстве приложений это нецелесообразно, и имеет смысл сознательно и явно принять решение не поддерживать прозрачную обработку смерти процесса.
Для интереса проверил несколько приложений. На сегодня (02.03.2020) прозрачно обрабатывают смерть процесса: Facebook, Twitter, WhatsApp, Chrome, Gmail, Yandex.Maps. Перезапускают приложение: Yandex.Navigator, YouTube, мобильный банкинг от Сбербанка и от Альфа-Банка, Skype.
Итак, допустим, мы решаем не заморачиваться с обработкой смерти процесса. Если процесс убивают, и пользователь возвращается в приложение, то нас устраивает перезапуск с нуля. В этом случае возникает трудность: Андроид пытается восстанавливать стек активностей, что может приводить к непредсказуемым последствиям.
В качестве иллюстрации создал https://github.com/mychka/life-from-scratch
Закомментируем код BaseActivity
и запустим приложение. Открывается LoginActivity
. Нажимаем кнопку "NEXT". Поверх открывается DashboardActivity
. Сворачиваем приложение. Для эмуляции убийства процесса вызываем
adb shell am kill org.lifefromscratch
Возвращаемся в приложение. Приложение крашится, так как DashboardActivity
обращается к полю LoginActivity#loginShownAt
, которое в случае смерти процесса оказывается непроинициализированным.
Красивого и универсального решения проблемы я не знаю. Предлагаю в статическом блоке базового класса активностей делать проверку, не было ли перезапуска приложения. Если обнаруживаем перезапуск процесса, то отправляем интент на перезапуск приложения с нуля и самоубиваемся:
abstract class BaseActivity : AppCompatActivity() {
companion object {
init {
if (!appStartedNormally) {
APP.startActivity(
APP.getPackageManager().getLaunchIntentForPackage(
APP.getPackageName()
)
);
System.exit(0)
}
}
}
}
Решение кривое. Но оно вроде бы достаточно надёжное, проверено годами в серьёзном интернет-банкинге.
Теперь пришла пора пожинать плоды. Коль скоро мы всегда остаёмся в рамках одного процесса, то и заморачиваться с сериализацией нет резона. Создаём класс
class BinderReference<T>(val value: T?) : Binder()
И гоняем через Parcel
любые объекты по ссылке. Например,
class MyNonSerializableData(val os: OutputStream)
val parcel: Parcel = Parcel.obtain()
val obj = MyNonSerializableData(ByteArrayOutputStream())
parcel.writeStrongBinder(BinderReference(obj))
parcel.setDataPosition(0)
val obj2 = (parcel.readStrongBinder() as BinderReference<*>).value
assert(obj === obj2)
Темы использования android.os.Binder
в качестве транспорта объектов я касался в статье https://habr.com/ru/post/274635/
Такой подход имеет большой потенциал. Можно красиво обернуть и использовать во многих местах. Например, для передачи произвольных аргументов во фрагменты. Добавим несколько утилит:
const val DEFAULT_BUNDLE_KEY = "com.example.DEFAULT_BUNDLE_KEY.cr5?Yq+&Jr@rnH5j"
val Any?.bundle: Bundle?
get() = if (this == null) null else Bundle().also { it.putBinder(DEFAULT_BUNDLE_KEY, BinderReference(this)) }
inline fun <reified T> Bundle?.value(): T =
this?.getBinder(DEFAULT_BUNDLE_KEY)?.let {
if (it is BinderReference<*>) it.value as T else null
} as T
inline fun <reified Arg> Fragment.defaultArg() = lazy<Arg>(LazyThreadSafetyMode.NONE) {
arguments.value()
}
И наслаждаемся комфортом. Запуск фрагмента:
findNavController(R.id.nav_host_fragment).navigate(
R.id.bbbFragment,
MyNonSerializableData(ByteArrayOutputStream()).bundle
)
Во фрагменте добавляем поле
val data: MyNonSerializableData by defaultArg()
Другой пример — androidx.lifecycle.ViewModel
. Этот класс бесполезен чуть менее, чем полностью, так как не переживает destroy активности, а обрабатывает только configuration change, являясь обёрткой над https://developer.android.com/reference/androidx/activity/ComponentActivity.html#onRetainCustomNonConfigurationInstance()
Допустим, что одна активность приложения открывается поверх другой, или пользователь кратковременно переключается на другое приложение, и операционная система решает уничтожить активность. В этом случае ViewModel
умирает.
Используя BinderReference
, несложно сделать аналог androidx.lifecycle.ViewModel
, основывающийся на обычном механизме сохранения состояния onSaveInstanceState(outState)
/onCreate(savedInstanceState)
. Такому view model не страшно уничтожение активности.
UPD: В комментариях kamer и Dimezis меня поправили, что активности уничтожались без удаления активитирекорда из бэкстека только в старых версиях Андроида, в районе версии 2.3 (API 9). Затем это убрали. Теперь активность может быть уничтожена либо насовсем (например, как результат вызова finish()
), либо в процессе configuration change. Комментарии по теме от разработчиков Андроида:
https://stackoverflow.com/questions/7536988/android-app-out-of-memory-issues-tried-everything-and-still-at-a-loss/7576275#7576275
https://stackoverflow.com/questions/11616575/android-not-killing-activities-from-stack-when-memory-is-low/11616829#11616829
Тикет на улучшение документации:
https://issuetracker.google.com/issues/36934944
Чтобы сказать наверняка, нужно погружаться в исходники Андроида. Раз есть опция "Don't keep activities", значит есть и код в ядре Андроида, умеющий "нежно" уничтожать активности. С большой вероятностью можно придумать хитрый сценарий, когда этот код будет вызван, и, соответственно, androidx.lifecycle.ViewModel
приведёт к крашу, в отличие от onSaveInstanceState()
. Но в реальной жизни, видимо, всё же androidx.lifecycle.ViewModel
можно смело использовать.
kamer
Согласно официальной документации:
Получается, что не нужно рассматривать вариант со смертью активити и тогда ViewModel становится удобным инструментом для хранения данных/соединений не зависящим от смены конфигурации.
mychka Автор
"The system never kills an activity" — означает, что активность не может просто так без предупреждения исчезнуть, у активности обязательно будут вызваны коллбэки
onStop()
,onDestroy()
(хотя на практике в некоторых случаяхonDestroy()
не вызывается).Активность, если не находится внутри
[onStart(), onStop()]
, может быть в любой момент уничтожена системой. В этом случае, если это не configuration change, данные внутриandroidx.lifecycle.ViewModel
будет потеряны.Идея с MVVM хорошая, но для гугловой реализации
ViewModel
сложно найти применение. Поэтому меня удивляет поднятый вокруг этого класса хайп, куча примеров в сети. Вспоминается песня Тараканов "Кто-то из нас двоих" )kamer
Утверждаете ли вы, что процесс при этом (при смерти активити) останется жить?
mychka Автор
Да, конечно.
Чтобы это сэмулировать, в Developer Options есть опция "Don't keep activities".
kamer
Пытался недавно прояснить для себя этот момент и пришел к тому, что в андроид не убивает активити отдельно от процесса. Окончательно меня в этом убедила документация.
Есть ли у вас прямые доказательства обратного?
mychka Автор
Думаю, что вы правы. Большое спасибо, что открыли мне глаза ) Добавил обновление в статью.
Dimezis
Don't keep activities уже давно бесполезна чуть менее, чем полностью, потому что эмулирует несуществующуее поведение.
Начиная с 4.0 (по-моему), Android никогда не убивает Activity по одиночку, только вместе с процессом целиком.
Последний раз обратное видел на Android 2.3.
Можете провести хоть миллион экспериментов, вы никогда не добъетесь такой ситуации даже на дохлых телефонах/эмуляторах.
Edit: в качестве теста можете сделать Activity, которая открывает такую же, и так стотыщ раз. На 2.3 старые Activity начнут умирать начиная с какого-то момента, а на 4.0+ вы просто поймаете OOM.
mychka Автор
Похоже, что всё так и есть, большое спасибо за комментарий! Добавил обновление в статью.
ldss
Ну так сделайте простой тест
1. Включите флаг,
2. запустите приложение на 8ке, например,
4. поставьте break на onDestroy, и
5. попереключайтесь между другими запущенными приложениями.
Выпадете в onDestroy, а приложение, вот сюрприз-то, останется в памяти
mychka Автор
Да, всё верно. Но как выяснилось, без включённой опции "Don't keep activities" воспроизвести аналогичный сценарий (чтобы вызвался
onDestroy()
) не удастся.ldss
Мы уже столько багов словили на этом, словами не передать:)
Так что вполне воспроизводится, по крайней мере клиентами. Но у нас и приложение тяжелое в плане использования памяти, так что может поэтому.
Ну и к вопросу о статье — я, имхо, не очень понимаю, зачем хранить состояние приложения, если прибился сам процесс. Разве что какие-то уже введенные и сохраненные данные (сохраненные по ходу, или по кнопке save, грубо говоря)
В этом случае ни о каких parcelable и речи не идет, это в базу/облако/етц
Dimezis
Вы, скорее всего, просто неправильно интерпретируете то, что видите в логах у клиентов.
Смерть процесса и потом восстановление на том же скрине — это самый обыденный сценарий, который происходит много раз на день. И это нужно поддерживать обязательно, чтобы обеспечить хороший UX. Это так же источник кучи крашей и багов, если это не тестировать.
Что вероятнее всего у вас и происходит, а не смерть отдельной Activity.
ldss
?
У меня есть баг в трекере. Запускаем отладку, с включенным флагом. Делаем, как я описал выше. Активити уходит в onDestroy, процесс остается в памяти, в отладчике это прекрасно видно. Какие логи я неправильно интерпретирую?
Кроме того, если процесс прибивается, где хранится savedInstance?
Dimezis
Saved instance хранится в системном сервисе Activity Manager, который отвечает за создание и восстановление Activity.
То что вы можете воспроизвести это с флагом, не значит, что без флага в реальной ситуации убивается только активити. И я могу поспорить, что убивается весь процесс. И вы можете это воспроизвести кучей других способов, вроде kill через adb, лимита фоновых процессов в дев сеттингах и банальным запуском других тяжелых приложений
ldss
А можно ссылочку, где говорится, что ActivityManager держит инстансы для убитых процессов? Или шаги, которые надо сделать, чтоб получить в тестовом приложении подобное поведение. Сиречь, запустить приложение, убить процесс, запустить его снова и получить валидный savedInstanceState
Я утверждаю простую вещь — система может прибить активити без убиения процесса. Собственно, об этом в activity lifecycle написано, секция onDestroy:
developer.android.com/guide/components/activities/activity-lifecycle#ondestroy
И для этих случаях ноленс воленс нужно сохранять состояние.
Я не выдумываю эти проблемы, я, повторюсь, смотрю на баги, которые идут от клиентов. При помощи флага они отлично отлаживаются. Т.е., у клиентов возникают ситуации, когда активити уходит в destroy, и потом восстанавливается в том же процессе из saved instance state.
Подозреваю, подобное можно получить, например, создав активити с 2-мя лейаутами, для портрета и ландскейпа, и потом просто поворачивать телефон. Сам не пробовал, да:)
UPD. Ага, почитал. Действительно, система не убивает отдельные активитиз из-за недостатка памяти. Спасибо за наводку
Тем не менее, активити таки может быть уничтожена, и потом восстановлена, так что, вуходит, от savedInstance все равно никуда не деться
mychka Автор
Если найдёте способ воспроизвести, это может оказаться очень интересным, иметь мировую значимость.