Some time ago, I wrote an article about modern Android architecture using the MVI pattern. You can read it on our website or on Medium. In that article, I presented the benefits of using the MVI pattern, described the structure and implementation details and introduced an example of how to implement the MVI pattern using a library I’ve created.

The solution proposed by me uses popular libraries such as RxJava and DataBinding. As the Android world is rapidly evolving and the previous article met with great interest, I decided to write a second article in which I will show you how to use the MVI pattern - this time basing it on Kotlin Coroutines and Jetpack Compose. So, compared to the previous RxJava solution, we will replace RxJava with Kotlin Couritnes and DataBinding with Jetpack Compose. The author of the library is my good friend and great Android Developer Maciej Tomczyński and the original library code can be found here.

Kotlin Coroutines 

From developer.android.com:

“A coroutine is a concurrency design pattern that you can use on Android to simplify code that executes asynchronously. Coroutines were added to Kotlin in version 1.3 and are based on established concepts from other languages.

On Android, coroutines help to manage long-running tasks that might otherwise block the main thread and cause your app to become unresponsive. Over 50% of professional developers who use coroutines have reported seeing increased productivity.”

Jetpack Compose

From developer.android.com:

“Jetpack Compose is Android’s modern toolkit for building native UI. It simplifies and accelerates UI development on Android. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs.”

MVI Implementation based on Kotlin Coroutines and Jetpack Compose

Similar to the previous implementation, the new one is based on several basic components with specific responsibilities: 

ViewState - state of a given screen

ViewEffect - an effect that can occur on a given screen (f.e. showing a snackbar or navigation action)

Event (old Intent) - intention or desire to perform an action (e.g. clicking a button or typing text)

PartialState - an object that updates the ViewState using the "reduce" method

All “data flow” logic was moved to extension functions (in the old implementation it was in base classes like Presenter and MviFragment) and based on Kotlin Coroutines (in the old aproach it was based on RxJava). The whole thing is much simpler to implement, there is definitely less boilerplate, and at the same time the principle remains the same, so we can take advantage of all the benefits that MVI offers us. 

Sample

Similar to the previous article, we will use the counter application as an example. The application displays the counter value. Clicking the "increase" button increases the counter value, clicking the "decrease" button decreases it.

the counter application

Let's start with the class that represents the state of the view:

data class CounterState(
   val value: Int = 0
)

and the effects that may occur on this screen:

sealed class CounterEffect {
   object NavigateToSecondScreen : CounterEffect()
}

Next, let's move on to Events, which are actions that the user can perform:

sealed class CounterEvent {

   object Increase : CounterEvent()

   object Decrease : CounterEvent()

   object NavigateToSecondScreen : CounterEvent()

}

Now the PartialState classes that modify the view state:

sealed class CounterPartialState : Intent<CounterState> {
   object CounterIncreased : CounterPartialState() {
       override fun reduce(oldState: CounterState): CounterState {
           return oldState.copy(value = oldState.value + 1)
       }
   }

   object CounterDecreased : CounterPartialState() {
       override fun reduce(oldState: CounterState): CounterState {
           return oldState.copy(value = oldState.value - 1)
       }
   }
}

In the ViewModel, we create an instance of the StateEffectProcessor, which is responsible for mapping Events to PartialStates and Effects:

@HiltViewModel
class CounterViewModel @Inject constructor() : ViewModel() {

   val processor: StateEffectProcessor<CounterEvent, CounterState, CounterEffect> =
       stateEffectProcessor(
           defViewState = CounterState(),
           prepare = { emptyFlow() },
           effects = { effects, event ->
               when (event) {
                   is CounterEvent.NavigateToSecondScreen -> effects.send(CounterEffect.NavigateToSecondScreen)
                   else -> { /* do nothing */ }
               }
           },
           statesEffects = { _, event ->
               when (event) {
                   is CounterEvent.Increase -> flowOf(CounterPartialState.CounterIncreased)
                   is CounterEvent.Decrease -> flowOf(CounterPartialState.CounterDecreased)
                   else -> emptyFlow()
               }
           })
}

Then all that remains is to create a view:

@AndroidEntryPoint
class CounterActivity : ComponentActivity() {

   private val viewModel: CounterViewModel by viewModels()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           MainAppTheme {
               Surface(color = MaterialTheme.colors.background) {
                   CounterScreen()
               }
           }
       }
       onCreated(viewModel::processor, onEffect = ::trigger)
   }

   private fun trigger(effect: CounterEffect) {
       when (effect) {
           is CounterEffect.NavigateToSecondScreen -> {
               // process navigation action here
           }
       }
   }
}

@Composable
@Preview
fun CounterScreen() {
   val processor = viewModel<CounterViewModel>().processor
   val value by processor.collectAsState { it.value }
   Column(
       modifier = Modifier
           .fillMaxSize(),
       verticalArrangement = Arrangement.Center,
       horizontalAlignment = Alignment.CenterHorizontally
   ) {
       Text(
           text = "$value", modifier = Modifier
               .wrapContentSize()
               .padding(8.dp)
       )
       Surface(
           modifier = Modifier
               .wrapContentSize()
               .padding(8.dp)
       ) {
           Button(onClick = {
               processor.sendEvent(CounterEvent.Increase)
           }) {
               Text(text = "Increase")
           }
       }
       Surface(
           modifier = Modifier
               .wrapContentSize()
               .padding(8.dp)
       ) {
           Button(onClick = {
               processor.sendEvent(CounterEvent.Decrease)
           }) {
               Text(text = "Decrease")
           }
       }
       Surface(
           modifier = Modifier
               .wrapContentSize()
               .padding(8.dp)
       ) {
           Button(onClick = {
               processor.sendEvent(CounterEvent.NavigateToSecondScreen)
           }) {
               Text(text = "Show toast")
           }
       }
   }
}

Unit tests:

internal class CounterViewModelTest : BaseCoroutineTest() {



   private val viewModel: CounterViewModel = CounterViewModel()



   @Test

   fun `when increase event emitted then state changed`() = processorTest(

       given = viewModel::processor,

       whenEvent = CounterEvent.Increase,

       thenStates = {

           assertValues(

               CounterState(value = 0),

               CounterState(value = 1)

           )

       }

   )



   @Test

   fun `when decrease event emitted then state changed`() = processorTest(

       given = viewModel::processor,

       whenEvent = CounterEvent.Decrease,

       thenStates = {

           assertValues(

               CounterState(value = 0),

               CounterState(value = -1)

           )

       }

   )



   @Test

   fun `when navigate to second screen event emitted then effect emitted`() = processorTest(

       given = viewModel::processor,

       whenEvent = CounterEvent.NavigateToSecondScreen,

       thenStates = {

           assertValues(

               CounterState(value = 0)

           )

       },

       thenEffects = {

           assertValues(

               CounterEffect.NavigateToSecondScreen

           )

       }

   )

}

As you can see, the principle remains the same but the implementation is very simplified.

The described example you can find here.  And the original library code is here.

Enjoy!

Share

facebook linkedin
Einde