Search code examples
androidkotlinandroid-jetpack-composeandroid-viewmodelandroid-mvvm

How to avoid duplicate functions in MVVM architecture Android?


I'm building a very simple game with Jetpack Compose where I have 3 screens:

  1. HeroesScreen - where I display all heroes in the game. You can select one, or multiple of the same character.
  2. HeroDetailsScreen - where I display more info about a hero. You can select a hero several times, if you want to have that character multiple times.
  3. ShoppingCartScreen - where you increase/decrease the quantity for each character.

Each screen has a ViewModel, and a Repository class:

HeroesScreen -> HeroesViewModel -> HeroesRepository
HeroDetailsScreen -> HeroDetailsViewModel -> HeroDetailsRepository
ShoppingCartScreen -> ShoppingCartViewModel -> ShoppingCartRepository

Each repository has between 8-12 different API calls. However, two of them are present in each repo, which is increase/decrease quantity. So I have the same 2 functions in 3 repository and 3 view model classes. Is there any way I can avoid those duplicates?

I know I can add these 2 functions only in one repo, and then inject an instance of that repo in the other view models, but is this a good approach? Since ShoppingCartRepository is not somehow related to HeroDetailsViewModel.


Edit

All 3 view model and repo classes contain 8-12 functions, but I will share only what's common in all classes:

class ShoppingCartViewModel @Inject constructor(
    private val repo: ShoppingCartRepository
): ViewModel() {
    var incrementQuantityResult by mutableStateOf<Result<Boolean>>(false)
        private set
    var decrementQuantityResult by mutableStateOf<Result<Boolean>>(false)
        private set

    fun incrementQuantity(heroId: String) = viewModelScope.launch {
        repo.incrementQuantity(heroId).collect { result ->
            incrementQuantityResult = result
        }
    }

    fun decrementQuantity(heroId: String) = viewModelScope.launch {
        repo.decrementQuantity(heroId).collect { result ->
            decrementQuantityResult = result
        }
    }
}

And here is the repo class:

class ShoppingCartRepositoryImpl(
    private val db: FirebaseFirestore,
): ShoppingCartRepository {
    val heroIdRef = db.collection("shoppingCart").document(heroId)

    override fun incrementQuantity(heroId: String) = flow {
        try {
            emit(Result.Loading)
            heroIdRef.update("quantity", FieldValue.increment(1)).await()
            emit(Result.Success(true))
        } catch (e: Exception) {
            emit(Result.Failure(e))
        }
    }

    override fun decrementQuantity(heroId: String) = flow {
        try {
            emit(Result.Loading)
            heroIdRef.update("quantity", FieldValue.increment(-1)).await()
            emit(Result.Success(true))
        } catch (e: Exception) {
            emit(Result.Failure(e))
        }
    }
}

All the other view model classes and repo classes contain their own logic, including these common functions.


Solution

  • I don't use Firebase, but going off of your code, I think you could do something like this.

    You don't seem to be using the heroId parameter of your functions so I'm omitting that.

    Here's a couple of different strategies for modularizing this:

    1. For a general solution that can work with any Firebase field, you can make a class that wraps a DocumentReference and a particular field in it, and exposes functions to work with it. This is a form of composition.
    class IncrementableField(
        private val documentReference: DocumentReference,
        val fieldName: String
    ) {
        private fun increment(amount: Float) = flow {
            try {
                emit(Result.Loading)
                heroIdRef.update(fieldName, FieldValue.increment(amount)).await()
                emit(Result.Success(true))
            } catch (e: Exception) {
                emit(Result.Failure(e))
            }
        }
    
        fun increment() = increment(1)
        fun decrement() = increment(-1)
    }
    

    Then your repo becomes:

    class ShoppingCartRepositoryImpl(
        private val db: FirebaseFirestore,
    ): ShoppingCartRepository {
        val heroIdRef = db.collection("shoppingCart").document(heroId)
    
        val quantity = IncrementableField(heroIdRef, "quantity")
    }
    

    and in your ViewModel, can call quantity.increment() or quantity.decrement().

    1. If you want to be more specific to this quantity type, you could create an interface for it and use extension functions for the implementation. (I don't really like this kind of solution because it makes too much stuff public and possibly hard to test/mock.)
    interface Quantifiable {
        val documentReference: DocumentReference
    }
    
    fun Quantifiable.incrementQuantity()(amount: Float) = flow {
            try {
                emit(Result.Loading)
                heroIdRef.update("quantity", FieldValue.increment(amount)).await()
                emit(Result.Success(true))
            } catch (e: Exception) {
                emit(Result.Failure(e))
            }
        }
    
    fun Quantifiable.incrementQuantity() = incrementQuantity(1)
    fun Quantifiable.decrementQuantity() = incrementQuantity(-1)
    

    Then your Repository can extend this interface:

    interface ShoppingCartRepository: Quantitfiable {
        //... your existing definition of the interface
    }
    
    class ShoppingCartRepositoryImpl(
        private val db: FirebaseFirestore,
    ): ShoppingCartRepository {
        private val heroIdRef = db.collection("shoppingCart").document(heroId)
        override val documentReference: DocumentReference get() = heroIdRef
    }