Simple architecture using MVVM and delegates in Android. The optimal solution for small projects

In this article, we’ll look at how to create a simple Android architecture using the MVVM (Model-View-ViewModel) pattern and delegates for efficient state management.

How to work with UiStateDelegate you can read in the previous one article.

Spoiler:

It is important to note that the approach under consideration and its implementation are recommended for use in small projects where the emphasis is on operational development. In the context of larger, more complex projects, a deeper dive into architectural details and optimization may be required, but on a smaller scale this solution is a viable option.

When you are developing an Android application, state management plays an important role. It allows you to efficiently handle data and events in your application. However, instead of filling your ViewModel with a lot of code, you can use a simple architecture based on MVVM and delegates.

Using delegates with a simple implementation can greatly improve the process of integrating new employees into a project. Here’s how it can be useful:

  1. Quick onboarding for new developers: Simple implementation of delegates makes code clearer and more accessible for new developers. They can get up to speed with the project and start making contributions.

  2. Less chance of errors: Simple implementation of delegate methods reduces the chance of errors in the code as it makes the code more reliable and understandable.

  3. Increased Team Productivity: With simple method implementations, developers can work more efficiently with delegates, resulting in increased team productivity and faster development of new features.

  4. Reduced documentation time: Simple and intuitive delegate code can reduce the need for extensive documentation since much of the logic is already self-documenting.

  5. Ease of Testing: Simple delegate implementations simplify the testing process, allowing you to quickly identify and fix bugs.

  6. Clearer Separation of Responsibilities: Using simple-to-implement delegates promotes a clear separation of responsibilities in your code, making it more structured and understandable.

  7. Maintainability: Simple delegate implementations make code more maintainable. New developers can quickly understand existing code and make changes as needed.

  8. Reuse code across screens: Reusing code with delegates helps reduce workload

Implementation example

MVVM: The basis of our architecture

MVVM is an architecture pattern that divides an application into three key components: Model, View and ViewModel.

  • Model: This is your business logic and data. This is where you process data, perform operations, and interact with data sources.

  • View: This is the user interface (UI) of your application. The View is responsible for displaying data and responding to user interaction.

  • ViewModel: ViewModel is an intermediary between Model and View. It contains the UI related logic and manages the state of the application. The ViewModel provides the data that the View displays and handles user actions.

Delegates: A tool for managing state

We will use delegates to manage state within our architecture. Let’s define a few key delegates that will help us do this more effectively.

UiStateDelegate

UiStateDelegate
/**
 * UiState - must be Data class, immutable
 */
interface UiStateDelegate<UiState, Event> {

    /**
     * Declarative description of the UI based on the current state.
     */
    val uiStateFlow: StateFlow<UiState>

    val singleEvents: Flow<Event>

    /**
     * State is read-only
     * The only way to change the state is to emit[reduce] an action,
     * an object describing what happened.
     */
    val UiStateDelegate<UiState, Event>.uiState: UiState

    /**
     * Transforms UI state using the specified transformation.
     *
     * @param transform  - function to transform UI state.
     */
    suspend fun UiStateDelegate<UiState, Event>.updateUiState(
        transform: (uiState: UiState) -> UiState,
    )

    /**
     * Changing the state without blocking the coroutine.
     */
    fun UiStateDelegate<UiState, Event>.asyncUpdateUiState(
        coroutineScope: CoroutineScope,
        transform: (state: UiState) -> UiState,
    ): Job

    suspend fun UiStateDelegate<UiState, Event>.sendEvent(event: Event)
}

UiStateDelegate is an interface that allows you to manage the UI state in your ViewModel. With it, you can easily update UI state and send UI related events. Your code allows you to declaratively describe the state of the UI and update it asynchronously.

Methods updateUiState And asyncUpdateUiState in the interface UiStateDelegate used to update the UI state (UiState) in the application, but they are executed asynchronously so as not to block the main thread of execution.

updateUiState(transform: (uiState: UiState) -> UiState):

This method accepts a function transformwhich takes the current UI state (UiState) and returns the new state after applying some logic or transformation. Method updateUiState blocks access to UI state changes to avoid resource races. Once the state is updated, it notifies all observers of the new state, which may result in an update to the user interface.

Usage example:

in ViewModel:
updateUiState { currentState ->
    // update state
    currentState.copy()
}

asyncUpdateUiState(coroutineScope: CoroutineScope, transform: (state: UiState) -> UiState): Job:

This method also updates the UI state, but it runs asynchronously within the specified coroutine scope (coroutineScope). It accepts a function transformwhich performs a similar state transformation as in the method updateUiState. Return object Job represents the execution of an asynchronous state update operation.

Usage example:

in ViewModel:
asyncUpdateUiState(viewModelScope) { currentState ->
    // async update state
    currentState.copy()
}

Using these methods allows you to efficiently update the UI state of your application to respond to user actions or internal data changes without blocking the main thread of execution and ensuring your application is responsive.

val singleEvents: Flow<Event> in the interface UiStateDelegate used for transmission events, which may arise during user interaction with the interface. This may include events such as navigation, successful completion of an operation, display of an error, etc.

InternalStateDelegate

InternalStateDelegate
interface InternalStateDelegate<State> {

    /**
     * Get the internal state as data flow.
     */
    val InternalStateDelegate<State>.internalStateFlow: Flow<State>

    /**
     * Get the current internal state.
     */
    val InternalStateDelegate<State>.internalState: State

    /**
     * Transforms internal state using the specified transformation.
     *
     * @param transform  - function to transform internal state.
     */
    suspend fun InternalStateDelegate<State>.updateInternalState(
        transform: (state: State) -> State,
    )

    /**
     * Changing the state without blocking the coroutine.
     */
    fun InternalStateDelegate<State>.asyncUpdateInternalState(
        coroutineScope: CoroutineScope, transform: (state: State) -> State
    ): Job
}

InternalStateDelegate is another interface that provides the ability to manage the internal state of your ViewModel. This is useful when you want to store and process data that should not be directly displayed on the UI, but affects it.

Method updateInternalState in the interface InternalStateDelegate used to update internal state (internalState) in the application. This method allows you to change state asynchronously, which can be useful when, for example, performing asynchronous operations or updating data within your ViewModel.

Method updateInternalState performs the following actions:

  1. It blocks access to internal state changes using a mutex (or other synchronization mechanism) to avoid resource races and ensure thread safety.

  2. Then it calls the function transformpassing the current internal state as an argument.

  3. Function transform performs the necessary logic to update the internal state. This may include adding, deleting, changing data, etc.

  4. After a successful state update, the method notifies all observers of the new state, which can lead to further action or a UI update if the internal state affects the display of data in the UI.

CombinedStateDelegate

CombinedStateDelegate
interface CombinedStateDelegate<UiState, State, Event> :
    UiStateDelegate<UiState, Event>,
    InternalStateDelegate<State> {

    /**
     * Transforms UI state using the specified transformation.
     *
     * @param transform  - function to transform UI state.
     */
    suspend fun CombinedStateDelegate<UiState, State, Event>.updateUiState(
        transform: (uiState: UiState, state: State) -> UiState
    )

    fun CombinedStateDelegate<UiState, State, Event>.collectUpdateUiState(
        coroutineScope: CoroutineScope,
        transform: (state: State, uiState: UiState) -> UiState,
    ): Job

    fun <T> CombinedStateDelegate<UiState, State, Event>.combineCollectUpdateUiState(
        coroutineScope: CoroutineScope,
        flow: Flow<T>,
        transform: suspend (state: State, uiState: UiState, value: T) -> UiState,
    ): Job

    fun <T1, T2> CombinedStateDelegate<UiState, State, Event>.combineCollectUpdateUiState(
        coroutineScope: CoroutineScope,
        flow1: Flow<T1>,
        flow2: Flow<T2>,
        transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2) -> UiState,
    ): Job

    fun <T1, T2, T3> CombinedStateDelegate<UiState, State, Event>.combineCollectUpdateUiState(
        coroutineScope: CoroutineScope,
        flow1: Flow<T1>,
        flow2: Flow<T2>,
        flow3: Flow<T3>,
        transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2, value3: T3) -> UiState,
    ): Job

    fun <T1, T2, T3, T4> CombinedStateDelegate<UiState, State, Event>.combineCollectUpdateUiState(
        coroutineScope: CoroutineScope,
        flow1: Flow<T1>,
        flow2: Flow<T2>,
        flow3: Flow<T3>,
        flow4: Flow<T4>,
        transform: suspend (state: State, uiState: UiState, value1: T1, value2: T2, value3: T3, value4: T4) -> UiState,
    ): Job
}

CombinedStateDelegate unites UiStateDelegate And InternalStateDelegate, allowing you to link the logic of these two states. This is especially useful when you need to update UI state that depends on internal state.

Implementing Delegates

Methods updateUiState And updateInternalState in the corresponding delegates accept the function transform:

Implementation
transform: (state: State) -> State


override suspend fun InternalStateDelegate<State>.updateInternalState(
        transform: (state: State) -> State,
    ) {
        mutexState.withLock { internalMutableState.update(transform) }
    }


override suspend fun UiStateDelegate<UiState, Event>.updateUiState(
        transform: (uiState: UiState) -> UiState,
    ) {
        mutexState.withLock { uiMutableStateFlow.emit(transform(uiState))}
    }

The function takes the current state as a parameter and returns the new state.

Used inside the function Mutex to avoid a race for resources.

Such simple implementations allow states to be updated asynchronously, and they can be extended with more complex logic depending on the requirements of your application.

Example of using CombinedStateDelegate

CombinedStateDelegate with Viewmodel
class HomeViewModel(
    private val dashboardRepository: DashboardRepository,
) : ViewModel(),
    CombinedStateDelegate<UiState, State, Event> by CombinedStateDelegateImpl(
    initialState = State(),
    initialUiState = UiState(),
) {

In this part of the code we see that HomeViewModel expands ViewModel and delegates its state and event management responsibilities to a delegate CombinedStateDelegatewhich is a combination UiStateDelegate And InternalStateDelegate. This makes the code more modular and readable, since the logic for managing state and events is placed in a separate component.

data class UiState(
    val isLoading: Boolean = false,
    val items: List<DashboardUi> = emptyList(),
    val filter: String = "",
)

The structure is defined here UiState, which represents the screen’s UI state. This object contains information about whether data is loaded, what elements are displayed on the screen, and the current filter.

data class State(
    val fullItems: List<DashboardUi> = emptyList(),
)

State represents the internal state, in this case the list of items retrieved from the repository.

sealed interface Event {
    object StartForgotPasswordFeature : Event
}

The interface is defined here Eventwhich can be used to handle events such as moving to another screen.

init {
    collectUpdateUiState(viewModelScope) { state, uiState ->
        val newItems = if (uiState.filter.isBlank()) {
            state.fullItems
        } else {
            state.fullItems.filter { item -> item.title.contains(uiState.filter) }
        }
        uiState.copy(items = newItems)
    }

This part of the code is where the initialization takes place HomeViewModel. We use collectUpdateUiStateto track changes in the user’s state (UiState). In this case, we filter the elements according to the value filter and update items V UiState.

viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
    throwable.printStackTrace()
}) {
    updateUiState { uiState, _ -> uiState.copy(isLoading = true) }
    val items = runCatching { dashboardRepository.getHomeItems() }
        .getOrDefault(emptyList())

    val uiItems = items.map { item -> item.toDashboardUi() }

    updateInternalState { state -> state.copy(fullItems = uiItems) }
}.invokeOnCompletion {
    asyncUpdateUiState(viewModelScope) { uiState -> uiState.copy(isLoading = false) }
}

In this part of the code we see asynchronous work with state. First we update UiStatesetting isLoading V trueto indicate that the download has started. Then we get the data from the repository, transform it into uiItems and update the internal state. Finally, in invokeOnCompletion,after the operation completes, we update asynchronously UiStatesetting isLoading V false.

Code for the entire ViewModel:
class HomeViewModel(
    private val dashboardRepository: DashboardRepository,
) : ViewModel(),
    CombinedStateDelegate<UiState, State, Event> by CombinedStateDelegateImpl(
    initialState = State(),
    initialUiState = UiState(),
) {

    data class UiState(
        val isLoading: Boolean = false,
        val items: List<DashboardUi> = emptyList(),

        val filter: String = "",
    )

    data class State(
        val fullItems: List<DashboardUi> = emptyList(),
    )

    sealed interface Event {
        object StartForgotPasswordFeature : Event
    }

    init {
        collectUpdateUiState(viewModelScope) { state, uiState ->
            val newItems = if (uiState.filter.isBlank()) {
                state.fullItems
            } else {
                state.fullItems.filter { item -> item.title.contains(uiState.filter) }
            }
            uiState.copy(items = newItems)
        }

        viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
            throwable.printStackTrace()
        }) {
            updateUiState { uiState, _ -> uiState.copy(isLoading = true) }
            val items = runCatching { dashboardRepository.getHomeItems() }
                .getOrDefault(emptyList())

            val uiItems = items.map { item -> item.toDashboardUi() }

            updateInternalState { state -> state.copy(fullItems = uiItems) }
        }.invokeOnCompletion {
            asyncUpdateUiState(viewModelScope) { uiState -> uiState.copy(isLoading = false) }
        }
    }
}

This code demonstrates how to use a delegate CombinedStateDelegate to control state and events on the screen. It makes the code more modular and understandable, making it easier to manage screen state in the MVVM architecture.

Reusing Delegates across different screens

One of the key aspects of using delegates is their ability to be easily reused across different screens and in different parts of your application. In this section, we’ll look at an example where delegates play an important role in making components modular and reusable.

Example: ToolbarDelegate

Imagine a situation where your application has screens, each of which has its own toolbar. These top bars can have different contents such as title, progress level and level label. To manage these top bars, you could create a generic component that would be responsible for displaying and updating the data in toolbar.

Example
interface ToolbarDelegate {
    val toolbarUiState: StateFlow<ToolbarUiState>
}

data class ToolbarUiState(
    val title: String = "",
    @FloatRange(from = 0.0, to = 100.0)
    val levelProgress: Float = 0f,
    val levelLabel: String = "",
)

class ToolbarDelegateImpl @Inject constructor(
    coroutineScope: CoroutineScope,
    userRepository: UserRepository,
) : ToolbarDelegate,
    UiStateDelegate<ToolbarUiState, Unit> by UiStateDelegateImpl(ToolbarUiState()) {

    override val toolbarUiState: StateFlow<ToolbarUiState>
        get() = uiStateFlow

    init {
        coroutineScope.launch {
            delay(500L)
            updateUiState { state ->
                state.copy(
                    title = "Title",
                    levelLabel = "Level:",
                )
            }
        }

        userRepository.getUserLevelFlow()
            .flowOn(Dispatchers.IO)
            .onEach { levelProgress ->
                updateUiState { state -> state.copy(levelProgress = levelProgress) }
            }
            .catch { throwable -> throwable.printStackTrace() }
            .launchIn(coroutineScope)
    }
}

This code defines the interface ToolbarDelegatewhich describes what the top bar control component should provide. ToolbarDelegate includes StateFlowproviding information about the current state TopBar.

ToolbarDelegateImpl implements ToolbarDelegate and uses a delegate UiStateDelegateto manage state TopBar. It updates the title, progress level and level label and also listens for user level changes and updates the corresponding data in TopBar.

Now that we have a reusable component ToolbarDelegate, we can use it on different screens. Here’s what it might look like:

class UserViewModel(
    private val toolbarDelegate: ToolbarDelegate,
) : ToolbarDelegate by toolbarDelegate, ViewModel()

UserViewModel uses ToolbarDelegateto control the top bar on the user’s screen. This means that the same component ToolbarDelegatewhich has been used on other screens, can be easily reused here.

UI
@Composable
fun UserScreen(
    viewModel: UserViewModel,
) {
    val toolbarUiState by viewModel.toolbarUiState.collectAsState()

    AppTopBar(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        state = toolbarUiState,
    )
}

IN Composable-functions UserScreen we use toolbarUiStateprovided UserViewModelto display TopBar. This creates a consistent design on the user’s screen and ensures that TopBarwill look and behave the same on this screen as on other screens.

Thus, we see how the use of delegates allows us to create reusable components, which simplifies the development and maintenance of applications, especially in the case where multiple screens may share common interface elements.

Reusing PaymentDelegate for payment on different screens

Let’s look at another example that demonstrates how delegates can be reused across different screens in an application. In this example we will create PaymentDelegatewhich will be used to make payments on different screens.

Example: PaymentDelegate

PaymentDelegate
interface PaymentDelegate {

    context(ViewModel)
    fun pay(productId: String)

    suspend fun purchaseProduct(productId: String)
}

class PaymentDelegateImpl @Inject constructor(
    private val paymentRepository: PaymentRepository,
    private val paymentHistoryRepository: PaymentHistoryRepository,
    private val paymentAnalytics: PaymentAnalytics,
) : PaymentDelegate {

    context(ViewModel)
    override fun pay(productId: String) {
        viewModelScope.launch {
            purchaseProduct(productId)
        }
    }

    override suspend fun purchaseProduct(productId: String) {
        paymentAnalytics.trackStartPaymentEvent()

        paymentRepository.pay(productId)

        paymentAnalytics.trackFinishPaymentEvent()

        paymentHistoryRepository.refresh()
    }
}

PaymentDelegate defines one method pay(productId: String)which performs the payment process.

PaymentDelegateImpl implements this interface and provides an implementation of the method pay. Inside this method the following steps are performed:

  1. Tracking the start of payment event using analytics (paymentAnalytics.trackStartPaymentEvent()).

  2. Making a payment for a product using paymentRepository.

  3. Payment completion event tracking (paymentAnalytics.trackFinishPaymentEvent()).

  4. Update payment history via paymentHistoryRepository.

Now we can use this PaymentDelegate on different screens in our application where the payment process is required.

For example, let’s say we have a screen Shop and screen Subscription. Both screens can use PaymentDelegate to process payment without having to duplicate payment logic on each screen.

In both ViewModel (ShopViewModel And SubscriptionViewModel) we use PaymentDelegate to complete payment for a product or subscription. This greatly simplifies the code and allows you to reuse the payment logic on different screens.

Thus, using delegates in this context allows you to create reusable components to handle common tasks such as payment, and improves the readability and maintainability of the code in your application.

Let’s describe implementation examples ShopViewModel And SubscriptionViewModelwhere both ViewModel will be used PaymentDelegate to complete the payment.

ShopViewModel
class ShopViewModel(
    paymentDelegate: PaymentDelegate
) : ViewModel(),
    UiStateDelegate<UiState, Any> by UiStateDelegateImpl(UiState()),
    PaymentDelegate by paymentDelegate {

    data class UiState(
        val isLoading: Boolean = false,
    )

    fun purchase(productId: String) {
        viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
            throwable.printStackTrace()
        }) {
            updateUiState { state -> state.copy(isLoading = true) }
            purchaseProduct(productId)
        }.invokeOnCompletion { asyncUpdateUiState(viewModelScope) { state -> state.copy(isLoading = false) } }
    }
}

In some cases PaymentDelegate may also have its own state, which describes the current state of the payment process. Let’s update PaymentDelegate to enable payment status.

PaymentDelegate
interface PaymentDelegate {

    val paymentState: StateFlow<PaymentState>

    context(ViewModel)
    fun pay(productId: String)
}

data class PaymentState(
    val isPaymentInProgress: Boolean = false,
    val paymentResult: PaymentResult? = null
)

sealed class PaymentResult {
    data class Success(val transactionId: String) : PaymentResult()
    data class Failure(val error: String) : PaymentResult()
}

class PaymentDelegateImpl @Inject constructor(
    private val paymentRepository: PaymentRepository,
    private val paymentHistoryRepository: PaymentHistoryRepository,
    private val paymentAnalytics: PaymentAnalytics,
) : PaymentDelegate {

    override val paymentState = MutableStateFlow(PaymentState())
    
    context(ViewModel) 
    override fun pay(productId: String) {
        viewModelScope.launch {
            paymentAnalytics.trackStartPaymentEvent()
            paymentState.value = PaymentState(isPaymentInProgress = true, paymentResult = null)

            val result = runCatching { paymentRepository.pay(productId) }

            if (result.isSuccess) {
                paymentAnalytics.trackFinishPaymentEvent()
                paymentHistoryRepository.refresh()
                paymentState.value = PaymentState(isPaymentInProgress = false, paymentResult = PaymentResult.Success("transactionId"))
            } else {
                val errorMessage = result.exceptionOrNull()?.message ?: "Payment failed"
                paymentState.value = PaymentState(isPaymentInProgress = false, paymentResult = PaymentResult.Failure(errorMessage))
            }
        }
    }
}

In this updated version PaymentDelegatewe added the property paymentStaterepresenting the current payment status. PaymentState contains information about whether the payment process is currently in progress (isPaymentInProgress) and payment result (paymentResult). paymentResult could be either Success (successful payment with transaction ID), or Failure (payment failed with error message).

In method pay(productId: String)we are now updating paymentState according to the current payment status. We install isPaymentInProgress V true at the beginning of the payment, and then update it depending on the payment result. In case of successful payment, we also transmit transaction information.

Thus, PaymentDelegate now not only performs payment, but also provides information about the current payment status, which can be useful for displaying payment information on the user interface or for handling payment errors on different screens in the application.

Implementation example

conclusions

Using delegates in mobile applications provides many benefits and will make your ViewModel clearer, more concise, and easier to reuse. Here are some key takeaways:

1. Reusing logic: Delegates allow you to take frequently used logic out of your system. ViewModel and use it on different screens and in different parts of the application. This helps reduce code duplication and supports the DRY (Don’t Repeat Yourself) principle.

2. Code clarity and simplification: ViewModel become cleaner and more understandable. The logic associated with certain functional blocks is separated into individual delegates, making the code more structured and manageable.

3. Maintainability improvements: When using delegates, it is easier to maintain the code, since each delegate is responsible only for its specific functional block. This makes it easier to find and correct errors.

4. Possibility of replacement and expansion: Delegates can be easily replaced or extended without changing the core logic ViewModel. This makes it easy to make changes and add new functionality.

5. Improved code readability: The code becomes more readable and understandable thanks to a clear division of functionality into separate delegates. This makes teamwork and training for new developers easier.

6. State and logic encapsulation: Delegates allow you to encapsulate state and logic within yourself, allowing for a more flexible application architecture.

The bottom line is that using delegates in mobile apps is a powerful tool for simplifying development, improving code quality, and making developers more productive. This approach helps create more modular and reusable components, making applications much easier to develop and maintain.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *