DEV Community

loading...

Unidirectional Data Flow for Android UIs

j1c1m1b1 profile image Julio Mendoza ・9 min read

Unidirectional Data Flow for Android UIs

heading image

Photo by Марьян Блан | @marjanblan on Unsplash

Where is the code?

Here

Inspiration

The ideas explored in this article are based on multiple sources that I recommend checking before diving any further:

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 ViewModels and LiveData.

ViewModels 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 Fragments and Activities.

Although there is a huge advantage of having ViewModels 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 or Activity
  • 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?
    }
}
Enter fullscreen mode Exit fullscreen mode

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 a Flow
  • Transform the Events Flow into an Actions Flow by calling toAction() on each emitted Event
  • Transform the Actions Flow into a States Flow by calling perform() 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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 ViewModels. 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) }
}
Enter fullscreen mode Exit fullscreen mode

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 classes 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
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        )
    }
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)

pic
Editor guide