...

воскресенье, 1 марта 2020 г.

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

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 сервис. Но не думаю, что это правильно.

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

Итак, допустим, мы решаем не заморачиваться с обработкой смерти процесса. Если процесс убивают, и пользователь возвращается в приложение, то нас устраивает перезапуск с нуля. В этом случае возникает трудность: Андроид пытается восстанавливать стек активностей, что может приводить к непредсказуемым последствиям.

В качестве иллюстрации я создал 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 не страшно уничтожение активности.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий