Рекомендованные практики от Google, как правило, включают использование ViewModel в качестве базового класса для View Models (тех, которые в MVVM). ViewModel — отличная штука для сохранения чего угодно в случае поворота экрана: будь то View Model, Presenter или Router. Но можно ли получить все преимущества выживания при повороте без необходимости наследоваться от ViewModel напрямую?

Почему такой вопрос вообще может прийти кому-то в голову? Для этого может быть несколько причин:

  • ViewModel — это абстрактный класс. Никто не любит наследоваться от чужих классов по всему коду без веской причины.

  • ViewModel завязан на Android. А что если мы захотим использовать View Model слой в Kotlin Multiplatform?

  • Иногда бывает сложно тестировать View Model, если нет контроля над его CoroutineScope, который создается и контролируется внутри библиотеки и не может быть легко заменен на тестовый.

Что хорошего делает ViewModel и чего нам это стоит

Перед тем, как пытаться улучшить или заменить какой-то инструмент, стоит разобраться, что он делает и что он делает не так.

Выживание при повороте экрана. Самое главное и горячо любимое свойство ViewModel. Также ViewModel выживает, когда Fragment, к которому она привязана, отправляется в стек. 

Чего нам это стоит:

Приходится расширять абстрактный класс ViewModel. Даже если то, что мы пытаемся сохранить при повороте, не является View Model из MVVM. Презентер? Наследует ViewModel. Какой-то утилитарный класс, не имеющий ничего общего с MVVM? Все равно наследует ViewModel.

Очистка ресурсов. ViewModel позволяет очистить используемые ресурсы, когда она больше не будет использоваться. Как правило, это происходит, когда экран, к которому привязана VM (активити или фрагмент), закрывается насовсем.

Чего нам это стоит:

ViewModel должна пройти через ViewModelProvider.get(), чтобы метод 'onCleared()' работал как положено. Иначе нужно не забыть о необходимости вызвать его вручную. Кажется, что кейс с неправильным созданием VM редкий, на грани с невозможным, но при некоторых комбинациях DI и универсальных самописных ViewModelProvider.Factory такое может выстрелить в ногу. Очень маловероятно, но все же.

CoroutineScope. ViewModel обеспечивает нас CoroutineScope, если мы используем ktx библиотеку. Это логично следует из двух предыдущих пунктов и может быть сделано самостоятельно, если нам, например, очень хочется запускать корутины на другом диспатчере по умолчанию.

Чего нам это стоит:

Немного нарушает SRP и плохо тестируется, потому что ViewModel внутри решает, как создавать и прибивать CoroutineScope. Мы это совсем не контролируем. Было бы удобнее, если бы CoroutineScope предоставлялся снаружи ViewModel и закрывался так же снаружи. В конце концов, жизненный цикл у всех VM, привязанных к одному активити или фрагменту, будет один и тот же. Почему бы им и не разделить общий CoroutineScope? Это реализовано в Hilt, но не очень очевидно, и не решает проблему с торчащей наружу extension function при использовании KTX-библиотеки.

CoroutineScope доступен снаружи ViewModel. Если мы не прячем View Model за интерфейсом (а мы обычно не прячем), то слой View имеет доступ к этому CoroutineScope: он может на нем что-то запустить и «потечь». Никто в здравом уме так делать не будет, но мы все иногда нанимаем стажеров и забываем посмотреть их коммиты очень внимательно.

SavedStateHandle. SavedStateHandle позволяет сохранить данные в случае «смерти» процесса. Очень полезно, хоть и не всем нужно. SavedStateHandle принимает в качестве сохраняемых данных вообще что угодно. Но в действительности может сохранить далеко не все что угодно.

Что делать, чтобы получать преимущества ViewModel без прилагающихся недостатков

Все просто — нужно перестать наследоваться от VM напрямую. Для начала давайте подумаем о том, какой API нужен для того, чтобы удобно делать объект выживающим при повороте экрана. 

Кажется, вот так будет удобно:

inline fun <reified T: Any> ViewModelStoreOwner.getOrCreatePersisted(create: () -> T): T

И как это реализовать? Очень просто — заставить ViewModel делать всю работу за нас:

class PersistentStorage : ViewModel() {

    private val persisted = mutableMapOf<Class<*>, Any>()

    fun <T: Any> getOrCreate(clazz: Class<T>, create: () -> T) =
            persisted.getOrPut(clazz) { create.invoke() } as T
}

inline fun <reified T : Any> ViewModelStoreOwner.getOrCreatePersisted(noinline create: () -> T): T =
        ViewModelProvider(this).get<PersistentStorage>().getOrCreate(T::class.java, create)

Вот так просто, всего в несколько строк. В результате мы можем сделать что угодно выживающим при повороте. Так же, как раньше выживала ViewModel:

val myVM = getOrCreatePersisted { MyMV(params) }

Очистка ресурсов. Это все прекрасно, но API у ViewModel такой корявый не спроста: нужно научиться очищать ресурсы в том случае, когда VM больше не жилец.

Вместо того, чтобы вешать на живучие классы лишние интерфейсы (которые еще и вызываются только при определенных обстоятельствах), мы можем позволить пользовательскому классу явно зарегистрироваться в качестве владельца ресурсов для их очистки. Так как все ViewModel у одного владельца (фрагмента или активити) имеют один и тот же жизненный цикл, то и ресурсы им нужно вычищать одновременно. 

Можно попробовать так:

interface PersistentLifecycle {
    fun addOnClearResourcesListener(listener: () -> Unit)
}

class PersistentLifecycleImpl : ViewModel(), PersistentLifecycle {

    private val listeners = mutableListOf<() -> Unit>()

    override fun addOnClearResourcesListener(listener: () -> Unit) {
        listeners.add(listener)
    }

    override fun onCleared() {
        super.onCleared()
        for (listener in listeners) {
            listener.invoke()
        }
        listeners.clear()
    }
}

fun ViewModelStoreOwner.persistentLifecycle(): PersistentLifecycle =
        ViewModelProvider(this).get<PersistentLifecycleImpl>()

Может быть, нейминг далек от идеала. Но это все равно лучше, чем называть базовый класс ViewModel, когда он совсем не про MVVM :)

Теперь мы можем подписаться на прибивание ресурсов в живучих классах. А «убийцу» получать в конструкторе, требуя не игнорировать эту функциональность явно публичным API:

class MyViewModel(persistentLifecycle: PersistentLifecycle) {
    init {
        persistentLifecycle.addOnClearResourcesListener {
            // clean resources
        }
    }
}

CoroutineScope. Cамое простое. Нужно просто предоставить CoroutineScope снаружи, чтобы проще тестировать. А еще, чтобы совпал жизненный цикл с циклом VM:

class MyViewModel(
        private val coroutineScope: CoroutineScope
) {

    fun onSomethingClick() {
        coroutineScope.launch { 
            // do something 
        }
    }
}

Можно просто использовать существующий в ViewModel:

class CoroutineScopeViewModel : ViewModel()

fun ViewModelStoreOwner.persistentCoroutineScope() =
        ViewModelProvider(this).get<CoroutineScopeViewModel>().viewModelScope

SavedStateHandle. Самая сложная часть. Нужно придумать удобный API, не зависящий напрямую от Android-библиотек.

Хотелось бы использовать делегаты так:

class MyViewModel(
        stateHelper: SavedStateHelper
) {
    private var screenId: String by stateHelper.savedState("screenId", default = "")
}

В этом случае интерфейс SavedStateHelper будет содержать один метод:

interface SavedStateHelper {
    fun <T: Any, VM : Any> savedState(key: String, default: T): ReadWriteProperty<VM, T>
}

Остается только написать реализацию этого метода:

class SavedStateHelperImpl(
        private val stateHandle: SavedStateHandle
) : ViewModel(), SavedStateHelper {

    override fun <T: Any, VM : Any> savedState(key: String, default: T): ReadWriteProperty<VM, T> {
        return PersistentStateDelegate(stateHandle, key, default)
    }
}

fun Fragment.savedStateHelper(): SavedStateHelper =
        ViewModelProvider(this, SavedStateViewModelFactory(requireActivity().application, this))
                .get<SavedStateHelperImpl>()

private class PersistentStateDelegate<T: Any>(
        private val holder: SavedStateHandle,
        private val key: String,
        private val default: T
) : ReadWriteProperty<Any, T> {

    override fun getValue(thisRef: Any, property: KProperty<*>): T {
        return holder.get<T>(getKey(thisRef))
            ?: default.also { setValue(thisRef, property, it) }
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
        holder.set(getKey(thisRef), value)
    }

    private fun getKey(thisRef: Any) = "${thisRef.javaClass.name}__$key"
}

Обратите внимание на "${thisRef.javaClass.name}__$key". Ключ должен привязываться к живучему классу, иначе ключи в разных местах в пределах одного фрагмента могут оказаться одинаковыми, и мы получим что-то страшное.

Такая реализация будет поддерживать те же типы, что и SavedStateHandle. Но не стоит использовать Bundle или Parcelable в View Model, если мы хотим избавиться от зависимостей на Андроид.

Заключение

Теперь у нас есть простой API для сохранения View Model, презентеров и вообще чего угодно при повороте экрана без необходимости напрямую зависеть в этих классах от чужих библиотек. Плюс у нас появился буфер между нашим кодом и чужими библиотеками.

Теперь View Model можно создать так:

val viewModel = getOrCreatePersisted {
    MyViewModel(savedStateHelper(), persistentCoroutineScope(), persistentLifecycle())
}

Да, в конструктор полезло всякое — это называется Constructor Injection. Можно облегчить жизнь одним из популярных DI-фреймворков, но это уже тема для другой статьи, а то и для целой серии.

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

Пользуясь случаем, хочу рассказать, что мы в Wrike ищем Android-разработчика с релокацией в Прагу. У нас классная маленькая команда, один большой продукт и много задач на любой вкус и цвет. И нет, мы не используем Flutter в мобильной разработке. Думаю, я бы заметил :). Если хотите познакомиться и узнать больше про нашу команду, откликайтесь на вакансию