Unidirectional Data Flow for Android UIs
Photo by Марьян Блан | @marjanblan on Unsplash
Where is the code?
Inspiration
The ideas explored in this article are based on multiple sources that I recommend checking before diving any further:
- Etienne Caron - Simple MVI Architecture for Android
- Managing State with RxJava by Jake Wharton
- Android Unidirectional Flow with LiveData
- Modeling UI State on Android
Introduction
One of the main tasks of the HSE Android app team is to rebuild the company’s Android app: taking old code as a reference and replace it with new code — a recurring Android development nightmare. Reading the old code made us ask the some questions I have asked myself before:
- How can different possible reactions to user inputs be written in a comprehensible manner, in order to make the code readable, robust and scalable?
- How can we avoid having multiple flags in our Activities and Fragments that represent how the UI should look like at a given point in time?
These questions are intended to be solved by the following snippets of code, heavily inspired by the sources listed at the top of the article.
There are multiple ways to achieve the proper display of information to the users and react to the input, the Android framework has evolved at an exponential rate in all directions, an example of this is are the Lifecycle library and specifically ViewModel
s and LiveData
.
ViewModel
s are often used as direct intermediaries between user events over UI widgets and data sources, because of their tight connection to the lifecycle of their host Fragment
s and Activities
.
Although there is a huge advantage of having ViewModel
s that receive user inputs and expose new data from these interactions, the generated code is often messy and difficult to translate into the clear differentiated states that the UI has to represent. An over-generalized user interaction in an Android app often looks something like this:
- The user navigates to a
Fragment
orActivity
- Data starts to load from some data source
- An indication of progress is shown to the user
- Data is loaded
- The UI is rendered
- The user interacts with a widget in the view component
- The input of the interaction (explicit or implicit) is sent to the data source to be operated on and obtain new information
- An indication of progress is shown to the user…
So on and so forth. It is easy to extract a common pattern from this, in fact it is so common that that's exactly the reason LCE models exist.
Components
We use 3 basic elements that can model the unidirectional cycle of user interactions, data obtention and information rendering.
- State: Represents a single state of the UI. It can be short or long lived.
- Action: Implements the method that takes: the input from an event, the contents of the previous state, and combines them through some business logic into a different state.
- UI Event: Represents an input given implicitly or explicitly from the user.
Please note the order of these components. Normally the implementation tends to make the most sense when it is developed in the way these elements are listed.
Also of importance is the suspend
modifier of the perform
function, denoting the asynchronous execution of a transaction.
interface UiEvent<A : Action<S>, S : State> {
suspend fun toAction(): Flow<A>
}
interface Action<S : State> {
suspend fun perform(getPreviousState: () -> S): Flow<S>
}
interface State {
interface Loading : State
interface Complete<T : Any?> : State {
val result: T
}
interface Error : State {
val throwable: Throwable?
}
}
The event processor/state publisher
In this case we are using a Channel
that sends UI Events, then is consumed as a Flow
and finally can be transformed: first into an Action Flow
and then to a State Flow
by performing each emitted action. This resulting Flow
then can be collected to modify the UI according to the new resulting state.
The engine of the state machine is composed by an extension function on the Channel
field of UI Events. The implementation can be summarized as follows:
- The Events
Channel
is consumed as aFlow
- Transform the Events
Flow
into an ActionsFlow
by callingtoAction()
on each emitted Event - Transform the Actions
Flow
into a StatesFlow
by callingperform()
on each emitted action - Collect the obtained states by passing each one in the
collectionHandler
lambda parameter
There are some Flow
transformation operations to ensure the correct sequential and distinct emission of the resulting states to be collected in the collector lambda parameter.
interface UiEventsProcessor<E : UiEvent<A, S>, A : Action<S>, S : State> {
val getPreviousState: () -> S
val events: Channel<E>
suspend fun Channel<E>.consumeAsStatesFlow(collectHandler: (state: S) -> (Unit)) {
this.consumeAsFlow()
.toAction()
.toState()
.distinctUntilChanged()
.collect { collectHandler(it) }
}
suspend fun dispatchEvent(event: E) {
events.send(event)
}
private fun Flow<E>.toAction(): Flow<A> = this.flatMapMerge { event ->
event.toAction()
}
private fun Flow<A>.toState(): Flow<S> = this.flatMapMerge { action ->
action.perform(getPreviousState)
}
}
The previous state needs to be provided as this state machine doesn’t keep tabs on the previous states and delegates this functionality to its implementation.
Because the state machine is an interface, it doesn’t make sense for the Channel
to be initialized since it has to be overridden by the implementation.
The choice of using an interface was made because this implementation is not limited to Android applications, and in the example project from where this code is extracted, the interface is declared in a Kotlin only module.
The next steps describe the implementation of this state machine and its components in an Android module. In the example project the presentation module that hosts the state machine interface and the base and concrete components is included into an Android app module as a dependency.
The Abstract ViewModel
The logical place for the abstract parts of the state machine to be implemented is a ViewModel
, because we have multiple suspending functions being called and the ViewModel
provides the viewModelScope
where it is safe to execute this suspending methods, since it is tied directly to the ViewModel
lifecycle.
However, if we are to re-use the ViewModel
implementation we might as well create an abstract ViewModel
and handle the specifics of each case scenario in children ViewModel
s. Here you can see how this abstract ViewModel
is declared:
abstract class UniDirectionalFlowViewModel<E : UiEvent<A, S>, A : Action<S>, S : State> :
ViewModel(), UiEventsProcessor<E, A, S> {
protected abstract val initialState: S
private val stateLiveData: MutableLiveData<S> = initializeStateLiveData()
final override val getPreviousState: () -> S
get() = { stateLiveData.requireValue() }
final override val events: Channel<E> = Channel()
init {
startEventsProcessing()
}
private fun startEventsProcessing() {
viewModelScope.launch {
events.consumeAsStatesFlow {
stateLiveData.value = it
}
}
}
protected fun E.dispatch() {
viewModelScope.launch { dispatchEvent(this@dispatch) }
}
private fun initializeStateLiveData(): MutableLiveData<S> =
MutableLiveData(initialState)
private fun MutableLiveData<S>.requireValue() = requireNotNull(value) { "State is null!" }
fun observe(owner: LifecycleOwner, observingBlock: (S?) -> Unit): Observer<S> =
Observer<S>(observingBlock).also { stateLiveData.observe(owner, it) }
}
Live Data is being used here to host the states that are received after dispatching the events. Making the new states be available in the observingBlock
of the observe
function declared at the bottom of the class.
It is mandatory to provide an initial state for the state machine to work, since it is necessary for actions to perform. This was chosen over having a nullable value for the perform()
method in the Action
interface, because of the cyclic nature of the paradigm to be applied here: from a state some events can be triggered, this events converted into states by transforming actions, taking the optional output of the previous state. A null
state in this cycle just doesn't make sense.
Implementations
All the elements necessary for the state machine to function are in place, now we just have to add the specific implementations.
Implemented Components
We are making use of sealed class
es in order to be able to represent the different states, actions and events of our simple example project. Since sealed classes support nesting we can be as specific as we want.
States
sealed class MainState : State {
object Initial: MainState()
object Loading : MainState(), State.Loading
data class Complete(override val result: String) : MainState(), State.Complete<String>
data class Error(override val throwable: Throwable? = null) : MainState(), State.Error
}
Events
They are simple containers of inputs from the UI.
sealed class MainEvent : UiEvent<MainAction, MainState> {
data class SuccessRequest(private val message: String) : MainEvent() {
override suspend fun toAction(): Flow<MainAction> =
MainAction.SendSuccess(message = message).toFlow()
}
object ErrorRequest : MainEvent() {
override suspend fun toAction(): Flow<MainAction> = MainAction.SendError.toFlow()
}
}
Actions
As you can see there is 1 to 1 mapping from event to action, which makes sense since we want a user event to trigger one transformation and get one event containing all the newUI information to be displayed.
Actions tend to be bigger since they are links to the execution of the business logic, taking the previous and the inputs from the events as parameters for the transactions under the hood. This won't be a problem anymore when Kotlin 1.5 arrives, though since sealed classes are going to be allowed to be implemented in different files than their declaration.
Here we are emulating the business logic of the app by calling a random delay on both the SendSuccess
and SendError
actions.
sealed class MainAction : Action<MainState> {
protected val delayMillis: Long
get() = loadingSecondsRange.random().let { TimeUnit.SECONDS.toMillis(it) }
data class SendSuccess(private val message: String) : MainAction(), SuccessOperationSimulator {
override suspend fun perform(getPreviousState: () -> MainState): Flow<MainState> = flow {
emit(MainState.Loading)
val newMessage = getPreviousState().getNewMessage()
emit(MainState.Complete(result = newMessage))
}
/**
* Simulates a time consuming asynchronous successful operation
*/
private suspend fun MainState.getNewMessage(): String {
delay(delayMillis)
return this.let { it as? MainState.Complete }?.result.appendMessageAbove(message)
}
}
object SendError : MainAction(), ErroneousOperationSimulator {
override suspend fun perform(getPreviousState: () -> MainState): Flow<MainState> = flow {
emit(MainState.Loading)
// Simulates a time consuming asynchronous unsuccessful operation
delay(delayMillis)
val exception = getException()
emit(MainState.Error(throwable = exception))
}
}
private companion object {
val loadingSecondsRange: LongRange = 1L..5L
}
}
The ViewModel
Now that our components are done, the ViewModel
can be implemented allowing us to declare the initial state and dispatch both events.
class MainActivityViewModel : UniDirectionalFlowViewModel<MainEvent, MainAction, MainState>() {
override val initialState: MainState
get() = MainState.Initial
fun sendError() {
MainEvent.ErrorRequest.dispatch()
}
fun sendSuccess(message: String) {
MainEvent.SuccessRequest(message = message).dispatch()
}
}
MainActivity
class MainActivity : BaseActivity<ActivityMainBinding>() {
private val viewModel: MainActivityViewModel by viewModels()
override fun inflateBinding(): ActivityMainBinding = ActivityMainBinding.inflate(layoutInflater)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.observe(this) { it?.render() }
binding.errorButton.setOnClickListener { viewModel.sendError() }
binding.eventsButton.setOnClickListener {
viewModel.sendSuccess(
Random.nextLong().toString()
)
}
}
private fun MainState.render() {
when (this) {
is MainState.Loading -> renderLoading()
is MainState.Error -> render()
is MainState.Complete -> render()
else -> return
}
}
private fun renderLoading() {
binding.progressBar.isVisible = true
switchButtonState(false)
}
private fun MainState.Error.render() {
binding.progressBar.isGone = true
Snackbar.make(
binding.root,
getMessage(),
Snackbar.LENGTH_LONG
).onDismissed {
switchButtonState(true)
}.show()
}
private fun MainState.Error.getMessage(): String =
this.throwable?.message ?: getString(R.string.unknown_error)
private fun MainState.Complete.render() {
showAlertDialog(result)
binding.progressBar.isGone = true
switchButtonState(true)
}
private fun switchButtonState(isEnabled: Boolean) {
binding.apply {
eventsButton.isEnabled = isEnabled
errorButton.isEnabled = isEnabled
}
}
}
The activity code is now reduced to the initialization of the layout elements and the observing of the state change, that is hooked with a when statement.
In this way the logic of rendering the fragments and activities can be isolated to the current state that is being handled. No weird flags necessary.
Testing
How can we ensure that the states are being correctly generated after each event? The process of testing can be described as follows:
- Dispatch an event
- Transform it into an action and perform it
- Assert the generated states
Here is the implementation:
internal inline fun <reified S : State, A : Action<S>, E : UiEvent<A, S>> E.dispatchAndAssertStateTransitions(
initialState: S,
vararg expectedStates: S
) {
runBlocking {
toAction()
.flatMapConcat { it.perform { initialState } }
.collectIndexed { index, actualState ->
assertEquals(expectedStates.getOrNull(index), actualState)
}
}
}
The implementation of this function looks very familiar, this is because it is a minimized version of the UiEventsProcessor
.
This function will validate that the expected states are exactly the same ones that are generated after calling the perform()
function on the Action generated by the dispatched UI Event. The complexity of the tests that use this function lie in the definition of the initial and expected states.
Below are some examples of the over-simplistic approach of the implementation of the example project, but a variation of the method declared above is used in the tests for the production application.
@Test
fun whenSendSuccessIsSentWithInitialStateResultingStateIsSuccess() {
val eventMessage = "Hello world"
val expectedCompleteMessage =
successOperationSimulator.getMessage(eventMessage)
val expectedFirstState = MainState.Loading
val expectedFinalState = MainState.Complete(result = expectedCompleteMessage)
MainEvent.SuccessRequest(message = eventMessage).dispatchAndAssertStateTransitions(
initialState = MainState.Initial, expectedFirstState, expectedFinalState
)
}
@Test
fun whenSendErrorIsSentWithInitialStateResultingStateIsSuccess() {
val expectedThrowable = erroneousOperationSimulator.getException()
val expectedFirstState = MainState.Loading
val expectedFinalState = MainState.Error(throwable = expectedThrowable)
MainEvent.ErrorRequest.dispatchAndAssertStateTransitions(
initialState = MainState.Initial, expectedFirstState, expectedFinalState
)
}
@Test
fun whenSendSuccessIsSentAfterPreviousCompleteBothMessagesAreAppended() {
val previousMessage = "Message: first message 😀"
val eventMessage = "second Message 💪"
val expectedCompleteMessage =
successOperationSimulator.getMessage(
previousMessage = previousMessage,
newMessage = eventMessage
)
val expectedFirstState = MainState.Loading
val expectedFinalState = MainState.Complete(result = expectedCompleteMessage)
MainEvent.SuccessRequest(message = eventMessage).dispatchAndAssertStateTransitions(
initialState = MainState.Complete(result = previousMessage), expectedFirstState, expectedFinalState
)
}
In this example the final and more complex expected states are generated by the successOperationSimulator
and erroneousOperationSimulator
, encapsulations of the simulated business logic the application uses. It is evident that in order to have proper tests these kinds of encapsulations should be provided to both the test and the real application modules, but this is another topic.
Conclusion
There is no silver bullet for Android apps implementation, there are multiple valid approaches to the complexities of the parts that make an Android application, but having specific division of the states of the UI has worked very well in the reconstruction of HSE, and has allowed to provide control over a part of the Android implementation that has always posed a problem to multiple developers.
Thanks for reading and Kotlin your problems away!
Top comments (0)