Способы инжектить ViewModel с помощью Dagger: что может пойти не так

от автора

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

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

  1. Фабрика становится сервис-локатором. Это значит, что если мы забываем добавить несколько строк в фабрику (или модуль, если используем мультибайндинг) для новой вьюмодели, то получаем исключение во время выполнения приложения без какой-либо индикации во время компиляции. Обнаружение проблем во время компиляции — это одно из главных преимуществ Dagger, и не хотелось бы его терять.

  2. Мы не можем передавать параметры во вьюмодель из фрагмента или активити. Этот подход не позволяет использовать @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 или внимательно следить за тем, чтобы во вьюмодели не инджектились другие вьюмодели.


ссылка на оригинал статьи https://habr.com/ru/articles/569918/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *