Инъекция зависимостей во ViewModel — очень популярная тема для статей по всему интернету. Давайте посмотрим, какие проблемы могут скрывать популярные подходы, и разберемся, есть ли способ инжектить ViewModel с помощью Dagger без огромного количества кода или потерь валидации графа зависимостей во время компиляции.
Disclaimer: чтобы разобраться в содержании этой статьи, вам потребуется знание Dagger.
Основная сложность использования DI с ViewModel заключается в том, что при создании ViewModel должна так или иначе проходить через ‘ViewModelProvider(this, factory).get(YourViewModel::class.java)’. Этот метод может быть скрыт внутри делегата ‘by viewModels { factory }’ или вызван напрямую. Без этого ViewModel не будет сохраняться при повороте экрана, а метод onCleared() не будет вызываться, когда ViewModel больше не нужна.
Чтобы сделать примеры как можно проще, я предположу, что у нас есть один компонент AppComponent. Но почти все примеры можно адаптировать к архитектуре с Subcomponent для каждой ViewModel или одним Subcomponent на все вьюмодели.
@Component(modules = [...])
interface AppComponent {
...
fun myViewModel(): MyViewModel
}
В большинстве примеров мы будем использовать такую ViewModel:
class MyViewModel @Inject constructor(
val repository: Repository
) : ViewModel() {
...
}
Repository предоставляется одним из модулей в AppComponent или просто имеет конструктор с аннотацией @Inject.
Также я предполагаю, что мы можем легко получить AppComponent внутри фрагмента, используя метод:
fun Fragment.getAppComponent(): AppComponent =
(requireContext() as MyApplication).appComponent
Теперь давайте посмотрим на существующие подходы и разберемся, какие проблемы они скрывают.
1. Map<Class<*>, Provider<out ViewModel>> в ViewModelProvider.Factory (с мультибиндингом или без)
Есть несколько вариантов реализации такого подхода. Самый простой — инжектить провайдеры в фабрику вьюмоделей и там собирать их в Map вручную:
class ViewModelFactory @Inject constructor
myViewModelProvider: Provider<MyViewModel>
) : ViewModelProvider.Factory {
private val providers = mapOf<Class<*>, Provider<out ViewModel>>(
MyViewModel::class.java to myViewModelProvider
)
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return providers[modelClass]!!.get() as T
}
}
Добавляем фабрику в компонент:
@Component
interface AppComponent {
fun viewModelsFactory(): ViewModelFactory
}
Теперь мы можем создать вьюмодель внутри фрагмента или активити:
private val viewModel: MyViewModel by viewModels {
getAppComponent().viewModelsFactory()
}
Этот же подход можно реализовать, используя аннотации @IntoMap и @ClassKey(VM::class) для мультибайндинга, но суть будет та же.
Такой подход работает и позволяет заинжектить вьюмодель в пару строк, но у него есть и определенные ограничения:
Фабрика становится сервис-локатором. Это значит, что если мы забываем добавить несколько строк в фабрику (или модуль, если используем мультибайндинг) для новой вьюмодели, то получаем исключение во время выполнения приложения без какой-либо индикации во время компиляции. Обнаружение проблем во время компиляции — это одно из главных преимуществ Dagger, и не хотелось бы его терять.
Мы не можем передавать параметры во вьюмодель из фрагмента или активити. Этот подход не позволяет использовать @AssistedInject, хотя во всех вьюмоделях для однообразных параметров вроде SavedStateHandle можно использовать Subcomponent.
2. Используем Hilt
Hilt — это отличный инструмент от Google. С ним можно обойтись меньшим количеством кода. Пока мы не передаем никаких дополнительных параметров из фрагмента во вьюмодель, этот инструмент работает как часы:
@HiltViewModel
class MyViewModel @Inject constructor
savedStateHandle: SavedStateHandle
private val repository: Repository,
) : ViewModel()
Во фрагменте нам нужна будет только одна строчка (кроме необходимой аннотации):
private val viewModel: MyViewModel by viewModels()
Само собой это будет работать только в том случае, если корректно настроить Hilt, но для этого есть множество статей и официальная инструкция. Обратите внимание: если забыть @HiltViewModel, то приложение «упадет» во время выполнения, а не во время компиляции.
Но если мы хотим передать что-то из фрагмента во вьюмодель, то придется инжектить AssistedFactory во фрагмент и создавать фабрику вьюмоделей. ViewModel в этом случае может выглядеть примерно так:
class MyViewModel @AssistedInject constructor(
@Assisted savedStateHandle: SavedStateHandle,
private val repository: Repository,
@Assisted private val screenId: String,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun build(stateHandle: SavedStateHandle, screenId: String): MyViewMode
}
}
Нам также понадобится универсальная фабрика вьюмоделей, просто чтобы избежать повторяющегося кода:
class LambdaFactory<T: ViewModel>(
savedStateRegistryOwner: SavedStateRegistryOwner
private val create: (handle: SavedStateHandle) -> T
): AbstractSavedStateViewModelFactory(savedStateRegistryOwner, null) {
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
return create.invoke(handle) as T
}
}
Теперь мы можем инжектить фабрику во фрагмент и использовать ее для создания вьюмодели:
@Inject
lateinit var factory: MyViewModel.Factory
private val viewModel: MyViewModel by viewModels {
LambdaFactory(this) { stateHandle ->
factory.build(stateHandle, screenId = "something")
}
}
В этом случае мы теряем некоторые преимущества Hilt и получаем что-то больше похожее на старый добрый Dagger с дополнительными шагами. Если это вас устраивает или вам не нужно ничего передавать во вьюмодель из фрагмента, то Hilt будет для вас отличным решением.
3. Получаем ViewModel из DI и передаем ссылку в фабрику во viewModels-делегате
private val viewModel: MainViewModel by viewModels {
Factory(getAppComponent().myViewModel())
}
class Factory<T: ViewModel>(private val viewModel: T) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return viewModel as T
}
}
Такой подход не будет работать, потому что лямбда, переданная во viewModels, будет вызываться при каждом повороте экрана и создавать новые экземпляры вьюмодели. Как ни странно, я видел такой подход в статье где-то на просторах интернета.
Метод viewModelComponent().myViewModel(), который вызывается при каждом повороте экрана, приведет к тому, что вьюмодели будут множиться. Если мы используем какие-то ресурсы внутри вьюмодели или запускаем корутины в конструкторе, то эти ресурсы и контекст для корутин не будут чиститься для всех вьюмоделей, кроме первой.
Даже если бы этот подход работал, есть шанс, что кто-нибудь вызовет ViewModelProvider().get() напрямую и получит тот же самый результат.
4. Передаем лямбду для создания ViewModel в фабрику
private val viewModel: MainViewModel by viewModels {
Factory {
getAppComponent().myViewModel()
}
}
class Factory<T: ViewModel>(private val create: () -> T) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T
return create.invoke() as T
}
}
В принципе, этот подход работает, но в нем есть скрытая опасность. Допустим, мы используем ViewModel для сохранения какого-то утилитарного класса при повороте экрана (например, Router), а этот класс имеет конструктор, помеченный аннотацией @Inject, и наследует от ViewModel. Тогда мы можем, не глядя в код Router, добавить его как параметр в конструктор вьюмодели:
// Without reading a file with Router we wouldn’t know that it extends ViewModel.
class Router @Inject constructor() : ViewModel()
class MyViewModel @Inject constructor(
private val router: Router
) : ViewModel()
Что произойдет в таком случае? Router будет создан вместе с вьюмоделью и заинжекчен в ее конструктор, ничего необычного. Но когда будет вызван метод onCleared() вьюмодели, этот же метод не будет вызван для Router. Это может потенциально привести к утечке памяти или еще более неприятным и сложным к поимке багам.
Обратите внимание, что то же самое может произойти, если заинжектить и более очевидную вьюмодель внутрь вьюмодели. Не очень корректно использовать их таким образом, но лучший подход не оставляет места для ошибки, иначе мы бы использовали Koin или другой сервис-локатор вместо Dagger и не беспокоились бы о таких вещах.
Как же избежать этой проблемы? Например, использовать @AssistedInject.
5. Используем @AssistedInject
Если мы договоримся всегда использовать @AssistedInject для классов, наследующих от ViewModel, то указанная выше проблема не возникнет, а также у нас будет возможность передавать во вьюмодель дополнительные параметры.
Давайте немного подкорректируем нашу вьюмодель, чтобы поддержать assisted injection:
class MyViewModel @AssistedInject constructor(
@Assisted savedStateHandle: SavedStateHandle
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(savedStateHandle: SavedStateHandle): MyViewModel
}
}
Подготовим фабрику, аналогичную предыдущему примеру:
class Factory<T: ViewModel>(
savedStateRegistryOwner: SavedStateRegistryOwner,
private val create: (stateHandle: SavedStateHandle) -> T
) : AbstractSavedStateViewModelFactory(savedStateRegistryOwner, null) {
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
return create.invoke(handle) as T
}
}
И один метод, чтобы создавать «ленивый» делегат с фабрикой:
inline fun <reified T : ViewModel> Fragment.lazyViewModel(
noinline create: (stateHandle: SavedStateHandle) -> T
) = viewModels<T> {
Factory(this, create)
}
Поменяем AppComponent:
@Component
interface AppComponent {
fun myViewModel(): MyViewModel.Factory
}
И, наконец, мы можем получить вьюмодель во фрагменте:
private val viewModel: MyViewModel by lazyViewModel { stateHandle ->
appComponent().myViewModel().create(stateHandle)
}
Этот подход немного сложнее предыдущих, но позволяет избежать редких проблем и передавать во вьюмодель дополнительные параметры, влючая SavedStateHandle, параметры страницы и прочее.
6. Бонус
В моей предыдущей статье я предложил подход, который освобождает от необходимости наследовать классы от ViewModel, а также альтернативный способ очистки ресурсов вьюмоделей. Похожий подход используется в этой библиотеке, так что я не один до этого додумался.
Если вы читали статью, то могли заметить, что lazyViewModel чем-то похож на getOrCreatePersisted из той статьи, хотя последний и не возвращает делегат.
Мы могли бы упаковать все зависимости из статьи в один Subcomponent примерно так:
@Subcomponent
interface ViewModelsComponent {
fun myViewModel(): MyViewModel
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance coroutineScope: CoroutineScope,
@BindsInstance savedStateHelper: SavedStateHelper,
@BindsInstance presistentLifecycle: PersistentLifecycle,
): ViewModelComponen
}
}
Добавим функцию для создания сабкомпонента во фрагменте:
private fun Fragment.viewModelsComponent() = getAppComponent().viewModelsSubcomponent().
.create(persistentCoroutineScope(), savedStateHelper(), persistentLifecycle())
Добавим зависимостей и уберем наследование от ViewModel из нашей вьюмодели:
class MyViewModel @Inject constructor(
private val coroutineScope: CoroutineScope,
private val savedStateHelper: SavedStateHelper,
private val lifecycle: PersistentLifecycle,
)
Упакуем lazy и getOrCreatePersisted в один метод:
inline fun <reified T : Any> ViewModelStoreOwner.lazyPersisted(noinline create: () -> T) = lazy { getOrCreatePersisted(create) }
И теперь можем легко создать нашу вьюмодель во фрагменте:
val viewModel by lazyPersisted {
viewModelsComponent().myViewModel()
}
Таким образом, у нас не будет необходимости использовать @AssistedInject в том случае, когда он не нужен. Все зависимости вьюмодели, которые требуют очищения ресурсов, могут сами разобраться с ними, приняв в конструктор PersistentLifecycle в качестве параметра. Также наша вьюмодель больше не зависит напрямую от фреймворка, хотя уйти в Kotlin Multiplatform нам пока не позволит Dagger.
Заключение
Хотя это и не всегда очевидно, есть способы инжектить вьюмодель с помощью Dagger, не терять при этом валидацию графа зависимостей при компиляции и не использовать огромное количества кода. Особенно если избавиться от наследования ViewModel, всегда создавать вьюмодель через @AssistedInject или внимательно следить за тем, чтобы во вьюмодели не инджектились другие вьюмодели.