Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android MVI with Coroutines Flow

Hello, I am Liang the Android developer, who is mainly in charge of Gunosy Android App development. This article is about how we build the MVI design pattern based on Coroutines Flow and migrate from RxJava to Coroutines for development.

Issues

Currently, We have some issues in our Gunosy Android Project:

  • The instability of RxJava 1

    • Highest occurring crash: MissingBackpressureException.

    • The main feature: Article Tabs and Contents were developed by RxJava.

  • Poor maintainability

    • In the regular MVVM view model: BasePresenter, since the business logic gets more complicated, it could be tough to trace the logic of a specific flow.

    • The coupling and nesting pattern with multiple interfaces implementation.

MVI with Coroutines Flow

MVI Diagram

What is MVI?

Model View Intent(MVI) is a reactive architecture pattern where the model is updated based on some interactions from user or the system and the view is updated based on states emitted from the model. — Hitesh Das tech.olx.com

Advantage

  • Unidirectional data flow

    • The flow runs in one direction, in order to send the immutable result.
  • Events observer pattern

    • Define all the events with data class instead of interface, to achieve loose coupling.
  • Lifecycle awareness

    • The whole flow is launched within the LifecycleCoroutineScope, which means it will be shut down automatically when destroyed.
  • Thread management

    • Thread switching could be applied everywhere in the suspend function by flowOn or withContext.
  • Back-Pressure handling

    • The suspend function suspends the emitter and resumes it later when it is ready to accept more elements.
  • Maintainability

    • Collecting and processing business logic directly in the linear flow, makes it easy to edit or stack up the extension functions.
  • Testability

    • With modularization, simply add or remove the modules we want to test and verify the results.
  • Supportability

    • There are many JetPack libraries that provide support for Coroutines, such as Compose, WorkManager, and Room.

Disadvantage

  • Lots of boilerplate

    • Each event needs its own module for passing through the flow.
  • Non-performance

    • It needs to create many objects to identify in the flow, which becomes less efficient than using the functions directly.
  • Readability

    • Because Coroutines is written in the imperative style, If there is a flow containing super complicated logic, it could be confusing to understand.
    • We are trying to apply Domain-drive design such as Clean Architecture, making logic operations work separately and reusable.

Used Libraries

  • Main

    • org.jetbrains.kotlin:kotlin-reflect

    • org.jetbrains.kotlinx:kotlinx-coroutines-core

    • org.jetbrains.kotlinx:kotlinx-coroutines-android

    • androidx.appcompat:appcompat

    • androidx.lifecycle:lifecycle-runtime-ktx

  • Testing

    • junit:junit

    • org.mockito.kotlin:mockito-kotlin

    • org.jetbrains.kotlinx:kotlinx-coroutines-test

    • androidx.lifecycle:lifecycle-runtime-testing

Components

  • FlowContract

    • Defines all the event models with data class.
  • FlowPresenter

    • Receives the request events, processes the business logic, and stores states.
  • FlowView

    • Activity or Fragment that already implements LifecycleCoroutineScope.

    • With a custom view, we can implement the interface: CoroutineScope to define a scope for launching in FlowPresenter.

Workflow

Step Component Action Thread Suspendable
1 View Click Main non-suspend
2 View Send Intent Main non-suspend
3 Presenter Do tasks to execute Action Background suspend
4 Presenter Update State Background suspend
5 View Observe State Main suspend
6 View Update View Main non-suspend

MVI Components

FlowContract

  • The Intent is the events from the view side.

  • The State is the definition of states.

  • The Action is the parent class of actions, which pass through the flow.

    • The Event is the task to execute in FlowPresenter.

    • The View is the task to execute on the view side.

    • The State is the task to update the states.

class FlowContract {
    sealed class Intent {
        object Click : Intent()
    }

    data class State(
        val value: Int
    )

    sealed class Action {
        sealed class Event : Action() {
            object ExecuteTask : Event()
        }

        sealed class View : Action() {
            object UpdateView : View()
        }

        sealed class State : Action() {
            data class SetValue(val value: Int) : State()
        }
    }
}

FlowPresenter

  • The CoroutineScope is the main scope launching on Coroutines flow, we simply useLifecycleCoroutineScope here.

  • The CoroutineContext is the base context on Coroutines flow for processing the business logic, also emitting and collecting the elements. We can switch to a different context at any moment.

abstract class FlowPresenter
constructor(
    coroutineScope: CoroutineScope,
    coroutineContext: CoroutineContext = Dispatchers.Default
)
  • The based flow is built on MutableSharedFlow.
val stateFlow = MutableSharedFlow<Intent>(extraBufferCapacity = 64)
    .increaseAction {
         states.value
     }
    .scan(initializeState) { state, action ->
        action.reduceState(state)
    }
    .flowOn(coroutineContext)
    .stateIn(coroutineScope, SharingStarted.Eagerly, initializeState)
Design pattern
  • The states is StateFlow which can extract the value we want, and derive the flow to observe the value from it.

  • increaseAction is used to receive the specific Intent with flatMap buffer, process the business logic, and transform it into a new flow for passing the Action.

  • reduceEvent is used to receive the Action.Event to execute the final task in FlowPresenter, such as logging or navigations.

  • reduceState is used to receive the Action.State to update the target state.

val value = states
    .distinctUntilChangedBy { it.value }
    .map { it.value }

override fun Flow<Intent>.increaseAction(state: () -> State) =
    merge(
        filterIsInstance<Intent.Click>()
            .flatMapConcat {
                flow {
                    emit(Action.Event.ExecuteTask)
                    emit(Action.View.UpdateView)
                    emit(Action.State.SetValue)
                }
            }
        )

override suspend fun Action.Event.reduceEvent() {
    when (this) {
        is Event.ExecuteTask -> {}
    }
}

override fun Action.State.reduceState(state: State) =
    when (this) {
        is State.SetValue -> state.copy(value = value)
    }
FlowView

Set up FlowPresenter after initializing the view.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setupView()
    setupPresenter()
}
Set up FlowPresenter
  • The intents() is the flow that collects view events and emits them to FlowPreseter, it also launches with lifecycle-aware.

  • To observe the Action.View and State, we launch a new coroutine from LifecycleCoroutineScope with repeatOnLifecycle, so that we can decide which lifecycle state to start collecting the elements.

  • The value is the state for observing by the flow.

  • The views is the action for receiving to execute the task on the view side.

private fun setupPresenter() {
    presenter.apply {
        intents()
            .onEach {
                sendIntent(it)
            }
            .launchIn(lifecycleScope)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.CREATED) {
                value.collect {             
                }
                views.collect {
                    when (it) {
                        is View.UpdateView -> {}
                    }
                }
            }
        }
    }
}
Bind with UI
  • The callbackFlow is the flow that wraps the call-back events and produces the request intents.

  • With trySend to send the view events immediately to the flow.

  • With awaitClose, when LifecycleCoroutineScope is in the destroyed state, we release the call-back to stop detecting the events.

private fun intents(): Flow<Contract.Intent> =
    callbackFlow {
        button.setOnClickListener {
            trySend(Contract.Intent.Click)
        }
        awaitClose {
            button.setOnClickListener(null)
        }
    }

Unit Test

  • Using TestScope instead of runBlocking for CoroutineScope.

  • Using Mockito for module mocking.

  • testIntentInvokeEvent is the Action.Event invoking verification.

  • testIntentInvokeView is the Action.View invoking verification.

  • testIntentUpdateState is the Action.State for State updating verification.

@Test
fun testIntentInvokeEvent() = runTest(coroutineRule.testDispatcher) {
    testPresenter.sendIntent(Intent.InvokeEvent(1))
    Mockito.verify(testEventMock).invoke(1)
}

@Test
fun testIntentInvokeView() = runTest(coroutineRule.testDispatcher) {
    val results = mutableListOf<Action.View>()
    val job = launch { testPresenter.views.toList(results) }
    testPresenter.sendIntent(Intent.InvokeView(1))
    Assert.assertEquals(listOf(Action.View.TestView(1)), results)
    job.cancel()
}

@Test
fun testIntentUpdateState() = runTest(coroutineRule.testDispatcher) {
    val results = mutableListOf<Int>()
    val job = launch { testPresenter.value.toList(results) }
    testPresenter.sendIntent(Intent.UpdateState(1))
    Assert.assertEquals(listOf(0, 1), results)
    job.cancel()
}

RxJava to Coroutines

In our Gunosy Android Project, there is a numerous amount of code currently written in RxJava 1, to work smoothly with Coroutines, we use suspendCancellableCoroutine to wrap RxJava into suspend function.

suspend fun <T> Observable<T>.toSuspend(): T =
    suspendCancellableCoroutine { continuation ->
        subscribe(object : Subscriber<T>() {
            override fun onNext(t: T) {
                continuation.resume(t)
            }
            override fun onError(e: Throwable?) {
                continuation.resumeWithException(e)
            }
        }
    }

Conclusion

Although it's not easy to migrate from RxJava to Coroutines, our code quality and development efficiency has improved. For advanced implementation in the future, we will consider how to modularize and combine multiple scenes with design patterns and architectures.

The goal of software architecture is to minimize the human resources required to build and maintain the required system. — Robert C. Martin.

Reference

developer.android.com

elizarov.medium.com

github.com

proandroiddev.com