Pull to refresh

В большинстве случаев сериализация в Андроиде не нужна

Reading time 6 min
Views 8.9K

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

Если решаем обрабатывать смерть процесса, то можно отметить следующие накладные расходы.


  1. Усложнение кода.


    • Если не заботимся о смерти, то можно в любом месте воткнуть статическое поле, и хранить там состояние приложения, ни о чём не беспокоясь. Если заботимся, то нужно аккуратно следить за состоянием, чтобы не забыть что-нибудь сохранить. Но в чём-то это даже плюс: дисциплинирует, может положительно повлиять на архитектуру, помочь избежать утечки памяти.
    • Всё состояние должно быть честно Serializable/Parcelable.
    • Восстановление состояния — это не только десериализация, но и приведение к консистентному виду. Например, до смерти процесса был флажок loading == true и запущенный поток. Так как после смерти процесса поток умер, нужно либо этот поток перезапустить, либо сбросить loading в false. То же самое с открытыми TCP-соединениями, которые после смерти процесса закрываются.
    • Нужно следить за кодом, который вызывается до Activity#onCreate() — например, за статикой и инициализацией полей — так как в этот момент глобальное состояние может быть ешё не восстановлено.

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


  2. Приходится в каждом Activity#onSaveInstanceState() сохранять полностью всё состояние приложения. (Наверное, если Activity#isChangingConfigurations == true, то можно сэкономить, не сохранять.) Но не думаю, что это может сказаться на производительности, так как более-менее современные смартфоны достаточно мощные.


  3. Случай смерти процесса нужно тестировать. Иначе нет смысла вкладываться в пункт 1, а в итоге всё равно иметь неработающую фичу. В случае большого приложения с сотней активностей/фрагментов тестирование может вылиться в копеечку.


  4. Безопасность. Я в эту тему не углублялся, но, наверное, если чувствительная информация хранится только в оперативной памяти, то украсть её сложнее, чем если она в случае смерти процесса дампится куда-то на жёсткий диск.
    На практике, думаю, никакой разницы нет: если смогли украсть данные из дампа, то и без дампа украдут. Непреодолимой трудностью может оказаться убедить в этом надзорные органы, когда речь идёт, например, о мобильном банкинге.



Отдельного исследования заслуживает вопрос о том, насколько вообще вероятно убийство процесса приложения в реальной жизни. Как вариант, чтобы снизить вероятность, можно запускать 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 можно смело использовать.

Tags:
Hubs:
+6
Comments 52
Comments Comments 52

Articles