Можно ли использовать Hilt на Android с помощью ViewModels для инициализации абстрактного поля ViewModel?

#android #android-viewmodel #dagger-hilt

Вопрос:

Я пытаюсь разобраться в Хилте и в том, как он работает с моделями просмотра. Я бы хотел, чтобы мои фрагменты зависели от абстрактных моделей представления, чтобы я мог легко издеваться над ними во время тестов пользовательского интерфейса. Экс:

 @AndroidEntryPoint
class MainFragment : Fragment() {
    private val vm : AbsViewModel by viewModels()

    /*
    ...
    */
}

@HiltViewModel
class MainViewModel(private val dependency: DependencyInterface) : AbsViewModel()

abstract class AbsViewModel : ViewModel()
 

Есть ли способ настроить ViewModels() таким образом, чтобы он мог сопоставлять конкретные реализации с абстрактными моделями представлений? Или передать пользовательский завод-производитель в ViewModels (), который может сопоставлять конкретные экземпляры моделей представления с абстрактными классами?

Точный вопрос также доступен здесь, но он довольно старый, учитывая, что хилт тогда еще был в альфе: https://github.com/google/dagger/issues/1972 Однако предлагаемое там решение не очень желательно, поскольку в нем используется строка, указывающая на путь конкретной модели представления. Я думаю, что это не переживет запутывания или перемещения файлов, и это может быстро превратиться в кошмар для обслуживания. В ответе также предлагается ввести конкретную модель представления во фрагмент во время тестов со всеми насмешливыми зависимостями модели представления, тем самым получив возможность контролировать то, что происходит в тесте. Это автоматически делает мой тест пользовательского интерфейса зависимым от реализации указанной модели представления, чего я бы очень хотел избежать.

Невозможность использовать абстрактные модели представления в своих фрагментах заставляет меня думать, что я нарушаю принципы D в ТВЕРДЫХ принципах, чего я также хотел бы избежать.

Ответ №1:

Не самое чистое решение, но вот что мне удалось сделать.

Сначала создайте ViewModelClassesMapper, который поможет сопоставить абстрактный класс с конкретным. В моем случае я использую пользовательскую модель AbsViewModel, но ее можно заменить обычной моделью просмотра. Затем создайте поставщика пользовательских моделей представлений, который зависит от указанного выше сопоставителя.

 class VMClassMapper @Inject constructor (private val vmClassesMap: MutableMap<Class<out AbsViewModel>, Provider<KClass<out AbsViewModel>>>) : VMClassMapperInterface {
    @Suppress("TYPE_INFERENCE_ONLY_INPUT_TYPES_WARNING")
    override fun getConcreteVMClass(vmClass: Class<out AbsViewModel>): KClass<out AbsViewModel> {
        return vmClassesMap[vmClass]?.get() ?: throw Exception("Concrete implementation for ${vmClass.canonicalName} not found! Provide one by using the @ViewModelKey")
    }
}

interface VMClassMapperInterface {
    fun getConcreteVMClass(vmClass: Class<out AbsViewModel>) : KClass<out AbsViewModel>
}

interface VMDependant<VM : AbsViewModel> : ViewModelStoreOwner {
    fun getVMClass() : KClass<VM>
}

class VMProvider @Inject constructor(private val vmMapper: VMClassMapperInterface) : VMProviderInterface {
    @Suppress("UNCHECKED_CAST")
    override fun <VM : AbsViewModel> provideVM(dependant: VMDependant<VM>): VM {
        val concreteClass = vmMapper.getConcreteVMClass(dependant.getVMClass().java)
        return ViewModelProvider(dependant).get(concreteClass.java) as VM
    }
}

interface VMProviderInterface {
    fun <VM :AbsViewModel> provideVM(dependant: VMDependant<VM>) : VM
}

@Module
@InstallIn(SingletonComponent::class)
abstract class ViewModelProviderModule {

    @Binds
    abstract fun bindViewModelClassesMapper(mapper: VMClassMapper) : VMClassMapperInterface

    @Binds
    @Singleton
    abstract fun bindVMProvider(provider: VMProvider) : VMProviderInterface

}
 

Затем сопоставьте свои конкретные классы с помощью пользовательской аннотации ViewModelKey.

 @Target(
        AnnotationTarget.FUNCTION,
        AnnotationTarget.PROPERTY_GETTER,
        AnnotationTarget.PROPERTY_SETTER
)
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
@MapKey
annotation class ViewModelKey(val value: KClass<out AbsViewModel>)

@Module
@InstallIn(SingletonComponent::class)
abstract class ViewModelsDI {

    companion object {

        @Provides
        @IntoMap
        @ViewModelKey(MainContracts.VM::class) 
        fun provideConcreteClassForMainVM() : KClass<out AbsViewModel> = MainViewModel::class

        @Provides
        @IntoMap
        @ViewModelKey(SecondContracts.VM::class)
        fun provideConcreteClassForSecondVM() : KClass<out AbsViewModel> = SecondViewModel::class
    }

}

interface MainContracts {

    abstract class VM : AbsViewModel() {
        abstract val textLiveData : LiveData<String>
        abstract fun onUpdateTextClicked()
        abstract fun onPerformActionClicked()
    }

}

interface SecondContracts {

    abstract class VM : AbsViewModel()

}
 

Наконец, ваш фрагмент, использующий модель абстрактного представления, выглядит следующим образом:

 @AndroidEntryPoint
class MainFragment : Fragment(), VMDependant<MainContracts.VM> {

    @Inject lateinit var vmProvider: VMProviderInterface

    protected lateinit var vm : MainContracts.VM

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        vm = vmProvider.provideVM(this)
    }

    override fun getVMClass(): KClass<MainContracts.VM> = MainContracts.VM::class

}
 

Это долгий путь, но после завершения начальной настройки все, что вам нужно сделать для отдельных фрагментов, — это сделать их зависимыми от виртуальной машины и предоставить конкретный класс для вашей модели просмотра в Hilt с помощью клавиши @ViewModel.

В тестах vmProvider можно легко высмеять и заставить выполнять ваши требования.