androidkotlininheritancedependency-injection

Inheritance between `HiltViewModels`


In my Android Kotlin App, I notice that I now have, in my code base, two view models that are HiltViewModels, that take care of some search feature concerns. Here's one of them:


package com.mikewarren.speakify.viewsAndViewModels.pages.importantApps

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mikewarren.speakify.data.AppsRepository
import com.mikewarren.speakify.data.UserAppModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class AddAppMenuViewModel @Inject constructor(
    private val repository: AppsRepository // Replace with your actual repository
) : ViewModel() {
    private val _appsToAdd = MutableStateFlow<List<AppListItemViewModel>>(emptyList())

    private val _searchText = MutableStateFlow("")
    var searchText: StateFlow<String> = _searchText.asStateFlow()

    private val _filteredApps = MutableStateFlow<List<AppListItemViewModel>>(_appsToAdd.value)
    val filteredApps: StateFlow<List<AppListItemViewModel>> = _filteredApps.asStateFlow()

    init {
        viewModelScope.launch {
            repository.otherApps.collect { userAppModels: List<UserAppModel> ->
                _appsToAdd.value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
                applySearchFilter()
            }
        }
    }

    fun onSearchTextChange(text: String) {
        _searchText.value = text
        applySearchFilter()
    }

    private fun applySearchFilter() {
        val text = _searchText.value
        val appViewModels = _appsToAdd.value
        if (text.isBlank()) {
            _filteredApps.value = appViewModels
            return;
        }
        _filteredApps.value = appViewModels.filter { vm: AppListItemViewModel ->
            vm.model.appName.contains(
                text,
                ignoreCase = true
            )
        }
    }

    fun onAppSelected(model: UserAppModel, onDone: (UserAppModel) -> Any) {
        _appsToAdd.update { appVMs : List<AppListItemViewModel> ->
            appVMs.filter({ vm: AppListItemViewModel -> vm.model.packageName != model.packageName })
        }

        applySearchFilter()

        onDone(model)
    }
}

Yes, they both have that repository dependency-injected. But they have so many of the same fields and methods:

I want to move all those concerns to a BaseSearchableViewModel, and have my two view models that take on these concerns extend that base view model ...

But how?

My attempt

I tried via BaseSearchableViewModel:

package com.mikewarren.speakify.viewsAndViewModels.pages.importantApps

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mikewarren.speakify.data.AppsRepository
import com.mikewarren.speakify.data.UserAppModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
abstract class BaseSearchableViewModel @Inject constructor(
    protected val repository: AppsRepository
) : ViewModel() {
    protected abstract fun getMainMutableStateFlow(): MutableStateFlow<List<AppListItemViewModel>>
    protected abstract fun getRepositoryStateFlow(): StateFlow<List<UserAppModel>>

    private val _searchText = MutableStateFlow("")
    var searchText: StateFlow<String> = _searchText.asStateFlow()

    private val _filteredApps = MutableStateFlow<List<AppListItemViewModel>>(getMainMutableStateFlow().value)
    val filteredApps: StateFlow<List<AppListItemViewModel>> = _filteredApps.asStateFlow()

    init {
        viewModelScope.launch {
            getRepositoryStateFlow().collect { userAppModels: List<UserAppModel> ->
                getMainMutableStateFlow().value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
                applySearchFilter()
            }
        }
    }

    fun onSearchTextChange(text: String) {
        _searchText.value = text
        applySearchFilter()
    }

    private fun applySearchFilter() {
        val text = _searchText.value
        val appViewModels = getMainMutableStateFlow().value
        if (text.isBlank()) {
            _filteredApps.value = appViewModels
            return;
        }
        _filteredApps.value = appViewModels.filter { vm: AppListItemViewModel ->
            vm.model.appName.contains(
                text,
                ignoreCase = true
            )
        }
    }
}

but have no idea how to use it.


Solution

  • The obvious answer, provided in comment by @Mike M. was the main thing I had to do, but introduced a pitfall:

    init {} was in the BaseSearchableViewModel , which caused race condition with the repository and everything that was using it.

    We had to replace that block with an

    protected fun onInit() { 
    
        viewModelScope.launch {
            getRepositoryStateFlow().collect { userAppModels: List<UserAppModel> ->
                getMainMutableStateFlow().value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
                _filteredApps.value = getMainMutableStateFlow().value
                applySearchFilter()
            }
        }
    }
    

    which gets called by the concrete derived view models.

    Here's how the base searchable view model looks:

    package com.mikewarren.speakify.viewsAndViewModels.pages
    
    import androidx.lifecycle.ViewModel
    import androidx.lifecycle.viewModelScope
    import com.mikewarren.speakify.data.AppsRepository
    import com.mikewarren.speakify.data.UserAppModel
    import com.mikewarren.speakify.viewsAndViewModels.pages.importantApps.AppListItemViewModel
    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.flow.StateFlow
    import kotlinx.coroutines.flow.asStateFlow
    import kotlinx.coroutines.launch
    
    abstract class BaseSearchableViewModel constructor(
        protected open var repository: AppsRepository
    ) : ViewModel() {
        protected abstract fun getMainMutableStateFlow(): MutableStateFlow<List<AppListItemViewModel>>
        protected abstract fun getRepositoryStateFlow(): StateFlow<List<UserAppModel>>
    
        private val _searchText = MutableStateFlow("")
        var searchText: StateFlow<String> = _searchText.asStateFlow()
    
        private val _filteredApps = MutableStateFlow<List<AppListItemViewModel>>(emptyList())
        val filteredApps: StateFlow<List<AppListItemViewModel>> = _filteredApps.asStateFlow()
    
        protected fun onInit() {
            viewModelScope.launch {
                getRepositoryStateFlow().collect { userAppModels: List<UserAppModel> ->
                    getMainMutableStateFlow().value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
                    _filteredApps.value = getMainMutableStateFlow().value
                    applySearchFilter()
                }
            }
        }
    
        fun onSearchTextChange(text: String) {
            _searchText.value = text
            applySearchFilter()
        }
    
        fun applySearchFilter() {
            val text = _searchText.value
            val appViewModels = getMainMutableStateFlow().value
            if (text.isBlank()) {
                _filteredApps.value = appViewModels
                return;
            }
            _filteredApps.value = appViewModels.filter { vm: AppListItemViewModel ->
                vm.model.appName.contains(
                    text,
                    ignoreCase = true
                )
            }
        }
    }
    

    The concrete derived view models then initialize the repository like this:

    @HiltViewModel
    class AddAppMenuViewModel @Inject constructor(
        override var repository: AppsRepository // Replace with your actual repository
    ) : BaseSearchableViewModel(repository) {
    

    and the initialize like this:

    init {
        onInit()
    }
    

    I HATE that I have to put the latter code on every last searchable view model, but I'll take the results for now...