top of page

(Part - I) Android: Split ViewModel into Delegates


In Android app development, the ViewModel class is designed to store and manage UI-related data in a lifecycle conscious way. However, as your app grows, your ViewModel can become bloated with many responsibilities, making it hard to maintain and test. One way to address this issue is by splitting the ViewModel's responsibilities into separate classes, known as ViewModel Delegates.



The Problem with Large ViewModels


Consider the following ViewModel:


class UserProfileViewModel(
    private val getUserDetailUseCase: GetUserDetailUseCase,
    private val changeAvatarUseCase: ChangeAvatarUseCase
) : ViewModel() {

    var userDetailState = mutableStateOf<UserDetailState>(UserDetailState.Loading)
        private set

    fun loadUserDetail(userId: UserId) {
        userDetailState.value = UserDetailState.Loading
        viewModelScope.launch {
            getUserDetailUseCase(userId).let { user ->
                userDetailState.value = UserDetailState.Success(user)
            }
        }
    }

    fun changeAvatar(userId: UserId, avatarUrl: String) {
        viewModelScope.launch {
            changeAvatarUseCase(userId, avatarUrl)
        }
    }

    sealed class UserDetailState {

        data object Loading : UserDetailState()

        data class Success(val user: User) : UserDetailState()

        data class Error(val message: String) : UserDetailState()
    }
}

This ViewModel is responsible for user details and changing the avatar. As the application grows, this ViewModel might end up handling more responsibilities, making it harder to maintain and test.


The Solution: ViewModel Delegates


ViewModel Delegates allow us to split the responsibilities of a ViewModel into separate classes. Each delegate is responsible for a specific task. This makes our code more modular, easier to read, and easier to test.


Let's see how we can refactor our UserProfileViewModel using ViewModel Delegates.


Extracting UserDetailViewModelDelegate


First, we extract the user detail functionality into a `UserDetailViewModelDelegate`.


interface ViewModelDelegate {
    val delegateScope: CoroutineScope
}

interface UserDetailViewModelDelegate : ViewModelDelegate {
    
    val userDetailState: State<UserDetailState>
    
    fun loadUserDetail(userId: UserId)
    
    sealed class UserDetailState {
        
        data object Loading : UserDetailState()
        
        data class Success(val user: User) : UserDetailState()
        
        data class Error(val message: String) : UserDetailState()
    }
}

Using UserDetailViewModelDelegate in ViewModel


Next, we use the `UserDetailViewModelDelegate` in our `UserProfileViewModel`.


class UserProfileViewModel(
    private val delegateScope: CloseableCoroutineScope,
    private val userDetailDelegate: UserDetailViewModelDelegate
) : ViewModel(delegateScope),
    UserDetailViewModelDelegate by userDetailDelegate {

    fun changeAvatar(userId: UserId, avatarUrl: String) {
        viewModelScope.launch {
            changeAvatarUseCase(userId, avatarUrl)
        }
    }
}

class CloseableCoroutineScope(
    context: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate
) : Closeable, CoroutineScope {
    
    override val coroutineContext: CoroutineContext = context
    
    override fun close() {
        coroutineContext.cancel()
    }
}

Extracting ChangeAvatarDelegate


Similarly, we extract the avatar changing functionality into a `ChangeAvatarDelegate`.


interface ChangeAvatarDelegate : ViewModelDelegate {
    fun changeAvatar(userId: UserId, avatarUrl: String)
}

Using ChangeAvatarDelegate in ViewModel


Finally, we use the `ChangeAvatarDelegate` in our `UserProfileViewModel`.


class UserProfileViewModel(
    override val delegateScope: CloseableCoroutineScope,
    private val userDetailDelegate: UserDetailViewModelDelegate,
    private val changeAvatarDelegate: ChangeAvatarDelegate
) : ViewModel(delegateScope),
    UserDetailViewModelDelegate by userDetailDelegate,
    ChangeAvatarDelegate by changeAvatarDelegate {
        
    // Extract viewmodel functionality to delegates
}

We need to provide the same instance of CloseableCoroutineScope as delegateScope to both the ViewModel and all the delegates attached to it, ensuring that when the ViewModel is destroyed, all the delegate scopes are destroyed as well.


Advantages of ViewModel Delegates


By splitting the ViewModel's responsibilities into separate classes, we gain several advantages:


1. Single Responsibility Principle: Each delegate has a single responsibility, making the code easier to understand and maintain.


2. Easier Testing: Each delegate can be tested independently, making unit testing easier.


3. Reusability: Delegates can be reused across multiple ViewModels, reducing code duplication.


4. Modularity: Delegates make our code more modular, which is beneficial for large projects with many developers.


In conclusion, ViewModel Delegates are a powerful tool for managing the complexity of large ViewModels in Android development. They help us write cleaner, more maintainable, and more testable code.



Comments


bottom of page