Rewriting a Complex Screen with Epoxy and Unidirectional Data Flow

Nimrod Dayan
ProAndroidDev

--

One of the projects I was working on recently, which was among the first activity recording apps, had a very complex screen with a lot of user interaction and data (referred to as Workout Summary Screen from now on). Naturally, as you’d expect, it had legacy code and tech, which was a blocker for extending it with new features. A decision was made to rewrite this screen so that it can be easily extended with new features and also get rid of weird bugs.

In this article, I’ll go through how I rewrote it, the technology and architecture I picked for the task and why.

Analyzing and Planning

Before we jump to code, it’s important to understand what is the challenge, what we want to achieve and how we’d solve it.

Screen overview

As you can see in the cover image screenshot, the screen is fairly complex. There’s a RecyclerView with many different types of items. There are actually a lot more items when you scroll down, which aren’t shown in the screenshot.

We also have many interactable UI widgets, such as the like button, add a comment, pictures pager and others which affect the screen state. For instance, when the user clicks the like button, the like counter needs to be incremented or when the user adds a comment, that comment needs to be added to the screen.

Deciding the technology and architecture

The technology and architecture I picked for the task is a Unidirectional Data Flow powered by Jetpack ViewModel combined with Kotlin Flow and AirBnB’s Epoxy library and its Kotlin extension functions, which provide the ability to write declarative UI.

Combining the above for this screen makes sense since this screen doesn’t only show data, but also responds to user input, which means this screen has multiple states. By modelling the UI states into state objects that encapsulate the data and combining it with Epoxy, which handles diffing of data out-of-the-box and dispatching UI changes respectively, we get Reactive UI.

The goal is that only the UI widgets that need to be updated will be updated without affecting other UI widgets. Data flows in one direction only and is immutable. It can only be changed in one place by copying the old state and changing it.

Implementation

Modelling the UI state in Kotlin data classes

A typical UI goes through 3 common states: Loading, Loaded and Error.

  • Loading is when data is loaded from disk or server. It can also be when we’re computing something.
  • Loaded is when the previous state (Loading) has finished successfully and now we have the result.
  • Error is when Loading state has failed and we want to show an error to the user.

Utilizing Kotlin’s power, we can make use of Sealed Classes like so:

You might be wondering why do we need to have data property for Loading and Error states. This will become clearer soon.

The next step is to model the UI to a Kotlin data class by walking through each RecyclerView item one by one from top to bottom and model a data class for it. Note that for simplicity, we will ignore the AppBar in this article and concentrate only on the RecyclerView.

We introduced a container class called WorkoutSummaryViewState, which holds references to the actual data that we’re going to present in the UI. Each *Item class is wrapped with ViewState sealed class we created earlier. You’ll shortly see the benefits of doing that.

Implementing the ViewModel

In this section, we’re going to use Google Jetpack ViewModel. The advantage is that ViewModel instance is persisted throughout configuration changes.

How does it work?
We expose one LiveData property in the ViewModel, which the Activity/Fragment will observe. This LiveData will emit a new instance of WorkoutSummaryViewState everytime there’s a state change in the UI. To start the loading of data, we will also expose a function that will be called by the Activity/Fragment.

Now here is the key in this architecture: Utilizing Kotlin Coroutines, we can load each item class in parallel and since each item has 3 states, we can use it to update the UI accordingly as data comes in. What we will get is lightning fast UI loads. Here is how it goes:

  1. Each item is loaded by a dedicated function call that returns a Kotlin Flow.
  2. In our ViewModel entry point function, the one that is called from Activity/Fragment, we use Kotlin’s combine function to combine all the Flows together.
  3. Due to the nature of combine function, we can make each Flow emit immediately with an initial state so that the combine function will emit a new value everytime one of the Flows emits a new Item state.
  4. We then pass the combined result to the LiveData that is observed by the Activity/Fragment. (Example below).
  5. Thanks to Epoxy’s diffing powers, this will result in flawless UI transitions and animations — no flickerings!

For brevity, I included only the code for loading the likes in the snippet above. Let’s analyze it:

  1. Each Item has it’s own load function that launches a new coroutine and immediately returns with a Flow.
  2. The Flow is actually a ConflatedBroadcastChannel, which keeps the last emitted item.
  3. As the new coroutine starts, it emits the initial state right away with a Loading state.
  4. Once all the functions in the combine function emit their initial state, the lambda in the combine function is invoked and right afterwards onEach function is invoked, which will emit a new instance of WorkoutSummaryViewState via the LiveData.
  5. Each coroutine is still running, loading its data and once it finishes, it emits a new state via the channel of that Item — LikesItem in the code snippet above in a Loaded state.
  6. In case of an error, we emit an Error state via the item specific channel.

What happens when a user hits the like button?

  1. We launch a new coroutine that gets the old state from the item specific channel.
  2. We update the database and reload the likes from the database while passing the previous state as the initial state for loadLikesItem() function. Why is it useful to pass the old state? If you recall, loadLikesItem() will immediately emit an initial state. By default, it is an empty state. To avoid clearing the item from the screen, we pass the old state. This can, of course, be improved if desired.
  3. In case of an error, we follow the pattern, we emit an Error state and the previous state.

Notice that we always emit a new instance. This way our state is immutable and cannot be changed anywhere else.

Implementing the Activity and Epoxy Controller

The next and final step is to implement the Activity and Epoxy Controller. The Activity is very basic. It gets an ID for which it needs to load data via Intent extras and calls the loadData(id) function. It subscribes to the LiveData that the ViewModel exposes and awaits for state emissions.

Now for the fun part, Epoxy controller and declarative UI 😃

In the Epoxy controller, we get the view state instance and we add the item (or Model in Epoxy terms) accordingly. Since we have the knowledge of what state each item is in, we can show individual loading indicators and error messages. Epoxy internally takes care to diff the new state with the old state and it will dispatch a notification to RecyclerView only if it found a difference.

Note that unless specified otherwise, when using TypedEpoxyController, Epoxy will build the models and do the diffing on the main thread by default. That’s why we explicitly specify the Handlers in TypedEpoxyController's constructor (lines 2–3). EpoxyAsyncUtil class comes out-of-the-box with Epoxy. You can read more about this here.

And here’s what we achieved:

The final result for likes

Not implemented this article, but implemented in the real app, adding a comment:

Adding comments with the architecture presented in this article

If you liked this article, please remember to clap and follow me here on Medium and on Twitter for more articles in the future.

--

--

Senior Software Engineer with more than a decade of professional experience specialized in Android (https://bit.ly/nimroddayan)