View Model не обязательно наследоваться от ViewModel
Рекомендованные практики от 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 ViewModelStoreOwner.getOrCreatePersisted(create: () -> T): T
И как это реализовать? Очень просто — заставить ViewModel делать всю работу за нас:
class PersistentStorage : ViewModel() {
private val persisted = mutableMapOf, Any>()
fun getOrCreate(clazz: Class, create: () -> T) =
persisted.getOrPut(clazz) { create.invoke() } as T
}
inline fun ViewModelStoreOwner.getOrCreatePersisted(noinline create: () -> T): T =
ViewModelProvider(this).get().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()
Может быть, нейминг далек от идеала. Но это все равно лучше, чем называть базовый класс 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().viewModelScope
SavedStateHandle. Самая сложная часть. Нужно придумать удобный API, не зависящий напрямую от Android-библиотек.
Хотелось бы использовать делегаты так:
class MyViewModel(
stateHelper: SavedStateHelper
) {
private var screenId: String by stateHelper.savedState("screenId", default = "")
}
В этом случае интерфейс SavedStateHelper будет содержать один метод:
interface SavedStateHelper {
fun savedState(key: String, default: T): ReadWriteProperty
}
Остается только написать реализацию этого метода:
class SavedStateHelperImpl(
private val stateHandle: SavedStateHandle
) : ViewModel(), SavedStateHelper {
override fun savedState(key: String, default: T): ReadWriteProperty {
return PersistentStateDelegate(stateHandle, key, default)
}
}
fun Fragment.savedStateHelper(): SavedStateHelper =
ViewModelProvider(this, SavedStateViewModelFactory(requireActivity().application, this))
.get()
private class PersistentStateDelegate(
private val holder: SavedStateHandle,
private val key: String,
private val default: T
) : ReadWriteProperty {
override fun getValue(thisRef: Any, property: KProperty<*>): T {
return holder.get(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 в мобильной разработке. Думаю, я бы заметил :). Если хотите познакомиться и узнать больше про нашу команду, откликайтесь на вакансию.