В большинстве случаев сериализация в Андроиде не нужна
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 сервис. Но не думаю, что это правильно.
В любом случае, мне кажется, можно сделать следующий вывод. Прозрачно обработать смерть процесса технически возможно, но в большинстве приложений это нецелесообразно, и имеет смысл сознательно и явно принять решение не поддерживать прозрачную обработку смерти процесса.
Итак, допустим, мы решаем не заморачиваться с обработкой смерти процесса. Если процесс убивают, и пользователь возвращается в приложение, то нас устраивает перезапуск с нуля. В этом случае возникает трудность: Андроид пытается восстанавливать стек активностей, что может приводить к непредсказуемым последствиям.
В качестве иллюстрации я создал 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(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 Bundle?.value(): T =
this?.getBinder(DEFAULT_BUNDLE_KEY)?.let {
if (it is BinderReference<*>) it.value as T else null
} as T
inline fun Fragment.defaultArg() = lazy(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 не страшно уничтожение активности.