[Разработка мобильных приложений, Разработка под Android, Kotlin] View Model не обязательно наследоваться от ViewModel

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
30-Июн-2021 17:39


Рекомендованные практики от 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 в мобильной разработке. Думаю, я бы заметил :). Если хотите познакомиться и узнать больше про нашу команду, откликайтесь на вакансию
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_pod_android (Разработка под Android), #_kotlin, #_viewmodel, #_android_development, #_mvvm, #_android_architecture, #_blog_kompanii_wrike (
Блог компании Wrike
)
, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
)
, #_razrabotka_pod_android (
Разработка под Android
)
, #_kotlin
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 16-Май 14:12
Часовой пояс: UTC + 5