Android MVI Architecture With Data Binding

Say hello to Android's latest architecture, the Model-View-Intent design pattern. Learn how this cycle.js-inspired framework can become a great addition to your coding toolbox.

Frameworks and Languages

Introduction

In the Android world, architecture seems to be something a lot of us care deeply about. Different engineers have different preferences for the many architectural styles out there such as MVC (Model-View-Controller), MVVM(Model-View-ViewModel) and MVI (Model-View-Intent) . We all have our preferences amongst these three and I'm not going to debate the merits of one over the other. However, I'd like to explain how we can use the MVI architecture efficiently with data binding.

Concepts Of MVI Architecture

ViewState: This is part of the model layer of MVI, and our view observes this model for state changes. It should represent the current state of the view at any given time. It should be immutable.  Every time the view needs to be updated as a result of a user action, we will expose a modified copy of the previous ViewState. The ViewState will have a result based on MutableLiveData.

ViewEffect: This is also part of the model layer of MVI. There are certain actions in Android development that we do not want the results to be re-emitted whenever the view is recreated probably due to a configuration change. Actions such as navigating to another screen, showing a toast or a dialog. Our view will thereby observe ViewEffects which is based on SingleLiveEvent for actions that are more or less fire and forget.

Intent: This represents all user actions/events performed on the view such as a click event, swipe event etc. These events are passed to the ViewModel as the user intent.

So in summary, the ViewModel takes input via intents, and gives output via ViewState and ViewEffect.

Base Components

So let's set up our base components so that we can reuse them across board in our codebase

Firstly, we will create a data state as a sealed class which will act as a wrapper for all our view states
//T represents our ViewState
sealed class DataState<out T> {
    data class Success<T>(val data: T?) : DataState<T>()
    data class Error<T>(val data: T?, val exception: Exception) : DataState<T>()
    data class Loading<T>(val data: T?) : DataState<T>()

    //Helper methods that make it easy to access the sealed class contents in xml data binding    
    fun toData(): T? = when (this) {
        is Success -> this.data
        is Error -> this.data
        is Loading -> this.data
    }
    fun isLoading(): Boolean? = if (this is Loading) true else null
    fun toErrorMessage(): String? = if (this is Error) this.exception.message else null
}

Secondly, we will create a BaseViewModel that will be the backbone holding all our MVI components together
//T represents our ViewState
//U represents our ViewEffect
abstract class BaseViewModel<T, U> : ViewModel() {
    //It will be accessed by child classes to update the view states 
    protected val _dataStates: MutableLiveData<DataState<T>> = MutableLiveData()
    //Will be accessed by the view to observe view state updates
    val dataStates: LiveData<DataState<T>> = _dataStates

    //It will be accessed by child classes to update the view effects 
    protected val _viewEffects: SingleLiveEvent<U> = SingleLiveEvent()
    //It will be accessed by the view to observe view effects updates
    val viewEffects: SingleLiveEvent<U> = _viewEffects

    //This is a wrapper for making network/room calls (use the latest version of room and retrofit that supports suspend functions)
    //This way we don't have to manually set our error and loading states in our child view models.
    protected fun launchRequest(block: suspend () -> Unit): Job {
        val currentViewState = getViewState()        
        return viewModelScope.launch {
            try {
                _dataStates.postValue(DataState.Loading(currentViewState))
                block()
            } catch (exception: Exception) {
                _dataStates.postValue(DataState.Error(currentViewState, exception))
            }
        }
    }

    protected fun getViewState() : T? = _dataStates.value?.toData()
}

Here's a snippet of the SingleLiveEvent that allows us to emit an update for a liveData once, even when there is a configuration change.
class SingleLiveEvent<T> : MutableLiveData<T>() {

    private val mPending = AtomicBoolean(false)

    @MainThread
    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        if (hasActiveObservers()) {
            Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
        }

        // Observe the internal MutableLiveData
        super.observe(owner, Observer { t ->
            if (mPending.compareAndSet(true, false)) {
                observer.onChanged(t)
            }
        })
    }


    @MainThread
    override fun setValue(t: T?) {
        mPending.set(true)
        super.setValue(t)
    }

    /**
     * Used for cases where T is Void, to make calls cleaner.
     */
    @MainThread
    fun call() {
        value = null
    }

    operator fun invoke() {
        call()
    }

    operator fun invoke(data: T) {
        value = data
    }

    companion object {

        private val TAG = "SingleLiveEvent"
    }
}

What is Next?

Now that we have our base components ready, let's pretend we are building a simple view with 3 buttons, a textview and a recyclerView. The user has to click on a button to view a list of wallet transactions. The user also has to click on a button to view the wallet amount and we would like  the user to navigate to the next screen after clicking the next button.

Let's pretend our WalletListItem looks like this:
data class WalletListItem (
    val amount: String,
    val type: String
)

Let's pretend our retrofit service looks something like this:
interface WalletService {
    @GET
    suspend fun getWalletAmount(): String

    @GET
    suspend fun getWalletList(): List<WalletListItem>
} 

I'll be skipping all the other hoops you probably have to add to your flow such as use cases and repositories. Then we can have a user intent that looks like this:
sealed class WalletIntent {
    object GetWalletListEvent(): WalletIntent()
    object GetWalletAmountEvent(): WalletIntent()
    object OnNextEvent(): WalletIntent()
}

Our ViewState will look like this:
data class WalletViewState(
    val walletItems: List<WalletListItem>? = null,
    val walletAmount: String? = null
)

Our ViewEffect will look like this:
sealed class WalletViewEffect {
    object NavigateToNextScreen : WalletViewEffect()
}

Our ViewModel will then look like this:
class WalletViewModel constructor(private val walletService: WalletService) : BaseViewModel<WalletViewState, WalletViewEffect>() {
    
    init { //set default for the view state
        _dataStates.postValue(DataState.Success(WalletViewState()))
    }

    //User related actions will trigger this method
    fun setIntent(intent: WalletIntent) {
        when (intent) {
            is WalletIntent.GetWalletListEvent -> getWalletList()
            is WalletIntent.GetWalletAmountEvent -> getWalletAmount()
            is WalletIntent.OnNextEvent -> navigateToNextScreen()
        }
    }

    private fun getWalletList() {
        val currentViewState = getViewState()
        launchRequest {
            val walletItems = walletService.getWalletList()
            val newViewState = currentViewState?.copy(walletItems = walletItems)
            _dataStates.postValue(DataState.Success(newViewState))
        }
    }

    private fun getWalletAmount() {
        val currentViewState = getViewState()
        launchRequest {
            val walletAmount = walletService.getWalletAmount()
            val newViewState = currentViewState?.copy(walletAmount = walletAmount)
            _dataStates.postValue(DataState.Success(newViewState))
        }
    }

    private fun navigateToNextScreen() {
        _viewEffects.postValue(WalletViewEffect.NavigateToNextScreen)
    }
}

Then our XML will look like this: 
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <import type="android.view.View" />

        <variable
            name="viewModel"
            type="com.example.WalletViewModel" />

        <variable
            name="onGetWalletAmountEvent"
            type="com.example.WalletIntent.GetWalletAmountEvent" />

        <variable
            name="onGetWalletListEvent"
            type="com.example.WalletIntent.GetWalletListEvent" />

        <variable
            name="onNextEvent"
            type="com.example.WalletIntent.OnNextEvent" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.WalletFragment">


        <Button
            android:id="@+id/button_get_amount"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="28dp"
            android:layout_marginTop="40dp"
            android:onClick="@{() -> viewModel.setIntent(onGetWalletAmountEvent)}"
            android:text="Get Amount"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/amountTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="56dp"
            android:text="@{viewModel.dataStates.toData().walletAmount}"
            app:layout_constraintStart_toEndOf="@+id/button_get_amount"
            app:layout_constraintTop_toTopOf="@+id/button_get_amount" />

        <Button
            android:id="@+id/button_get_wallet_list"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="64dp"
            android:onClick="@{() -> viewModel.setIntent(onGetWalletListEvent)}"
            android:text="Get Wallet List"
            app:layout_constraintStart_toStartOf="@+id/button_get_amount"
            app:layout_constraintTop_toBottomOf="@+id/button_get_amount" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:data="@{viewModel.dataStates.toData().walletItems}"
            app:layout_constraintStart_toStartOf="@+id/button_get_wallet_list"
            app:layout_constraintTop_toBottomOf="@+id/button_get_wallet_list" />

        <Button
            android:id="@+id/button_next"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="128dp"
            android:onClick="@{() -> viewModel.setIntent(onNextEvent)}"
            android:text="Next"
            app:layout_constraintStart_toStartOf="@+id/recyclerView"
            app:layout_constraintTop_toBottomOf="@+id/recyclerView" />

        <TextView
            android:id="@+id/label_error"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="48dp"
            android:text="@{viewModel.dataStates.toErrorMessage()}"
            app:layout_constraintStart_toStartOf="@+id/button_next"
            app:layout_constraintTop_toBottomOf="@+id/button_next" />

        <ProgressBar
            android:id="@+id/progressBar"
            style="?android:attr/progressBarStyle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="@{viewModel.dataStates.isLoading() ? View.VISIBLE : View.GONE}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Finally, our fragment will look like this:
class WalletFragment : Fragment() {

    private val viewModel: WalletViewModel by viewModels()
    private lateinit var binding: WalletFragmentBinding

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = WalletFragmentBinding.inflate(inflater, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        binding.viewModel = viewModel
        binding.onGetWalletAmountEvent = WalletIntent.GetWalletAmountEvent
        binding.onGetWalletListEvent = WalletIntent.GetWalletListEvent
        binding.onNextEvent = WalletIntent.OnNextEvent
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel.viewEffects.observe(viewLifecycleOwner, Observer { onViewEffectReceived(it) })
        //If you want you can decide to handle your view state changes by simply observing it
        viewModel.dataStates.observe(viewLifecycleOwner, Observer { onDataStateReceived(it) })
    }

    private fun onViewEffectReceived(it: WalletViewEffect?) {
        when (it) {
            is WalletViewEffect.NavigateToNextScreen -> navigateToNextScreen()
        }
    }
    
    private fun onDataStateReceived(it: DataState<WalletViewState>) {
        when (it) {
            is DataState.Error -> { }
            is DataState.Loading -> { }
            is DataState.Success -> { }
        }
    }
    
    private fun navigateToNextScreen() {}
}

Conclusion

Please play around with the above and actually try to do something small with it so you can see the concept come to life. If you like being in an environment that cares about software architecture and best practices, you should consider joining our team at Oozou.

Looking for a new challenge? Join Our Team

Like 3 likes
Anu Karounwi
Android engineer at oozou
Share:

Join the conversation

This will be shown public
All comments are moderated

Get our stories delivered

From us to your inbox weekly.