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
andContents
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
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 ofinterface
, to achieve loose coupling.
- Define all the events with
Lifecycle awareness
- The whole flow is launched within the
LifecycleCoroutineScope
, which means it will be shut down automatically when destroyed.
- The whole flow is launched within the
Thread management
- Thread switching could be applied everywhere in the suspend function by
flowOn
orwithContext
.
- Thread switching could be applied everywhere in the suspend function by
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
.
- Defines all the event models with
FlowPresenter
- Receives the request events, processes the business logic, and stores states.
FlowView
Activity
orFragment
that already implementsLifecycleCoroutineScope
.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
isStateFlow
which can extract the value we want, and derive the flow to observe the value from it.increaseAction
is used to receive the specificIntent
withflatMap
buffer, process the business logic, and transform it into a new flow for passing the Action.reduceEvent
is used to receive theAction.Event
to execute the final task in FlowPresenter, such as logging or navigations.reduceState
is used to receive theAction.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
andState
, we launch a new coroutine fromLifecycleCoroutineScope
withrepeatOnLifecycle
, 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
, whenLifecycleCoroutineScope
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 ofrunBlocking
forCoroutineScope
.Using
Mockito
for module mocking.testIntentInvokeEvent
is theAction.Event
invoking verification.testIntentInvokeView
is theAction.View
invoking verification.testIntentUpdateState
is theAction.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.