Как стать автором
Обновить
0
Wrike
Мы делаем совместную работу проще

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

Время на прочтение8 мин
Количество просмотров21K

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

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии6

Публикации

Информация

Сайт
www.wrike.com
Дата регистрации
Дата основания
2006
Численность
1 001–5 000 человек
Местоположение
США
Представитель
Wriketeam

Истории