Unidirectional Data Flow for Android UIs
This article is co-authored with Hans-Martin Schuller, my Android development brolleague at HSE.
Where is the code?
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
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.
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.
The event processor/state publisher
In this case we are using a Channel that sends UI Events that is consumed as a Flow and then 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 UI Events Channel field. 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.
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 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:
LiveData 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 necessary to provide an initial state for the state machine to work, since it is necessary for actions to be performed. 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.
All the elements necessary for the state machine to function are in place, now we just have to add the specific implementations.
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.
They are simple containers of inputs from the UI as parameters.
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 new UI information to be displayed.
Actions tend to be larger 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, because 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.
Now that our components are done, the ViewModel can be implemented allowing us to declare the initial state and dispatch both events.
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.
How can we ensure that the states are being correctly generated after the dispatch of an event? We created a testing process that can be described as follows:
- Define an initial or previous state
- Define the expected resulting states
- Dispatch the event to be tested
- Transform it into an action and perform it
- Assert the equality of generated states with the expected states
Here is the implementation:
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.
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.
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!