
Инъекция зависимостей во 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 или внимательно следить за тем, чтобы во вьюмодели не инджектились другие вьюмодели.
