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 Автор
Если найдёте способ воспроизвести, это может оказаться очень интересным, иметь мировую значимость.