Managing states with Kaskade

Miguel Panelo
ProAndroidDev
Published in
7 min readMar 11, 2019

--

States are important in every application — it tells us what the app’s current landscape is and the user’s next actions. However, sometimes, state can not be immediately evident when it is managed in different places, especially with Android’s lifecycle wherein the screen can stop and resume in a lot of ways.

That’s why unidirectional data flows work. It makes states predictable by enforcing rules on when to do an action and when to update the application state; this results in more effective control of the application. This also makes testing easier by treating the flow as a black box that execute actions and observe states.

Yet Another State Container?

With lots of open source solutions and libraries implementing this kind of data flow — Why create another one?

  1. Lightweight — Unlike most implementations of MVI that uses RxJava extensively, I wanted a solution that enforces unidirectional data flow without the use of external dependencies.
  2. Modular — can be easily substituted to different implementation with or without the use of another library.
  3. Extendable — in relation to modular and lightweight, it’s important to extend the API and create user defined implementation to fit specific requirements.
  4. DSL (Domain Specific Language)— able to hide complexity in a fluent way.

These are the ideas that I keep in mind when developing Kaskade.

Kaskade Data flow

Giphy App

To demonstrate how Kaskade works, let’s create an app that gets the trending GIFs from Giphy.

Our first screen would be a list of GIFs we get from Giphy.

List screen

And a second screen where we view the GIF in full detail and get a random GIF.

Detail Screen

For the sake of this article, we will only cover the List Screen.

Actions

An action contains the necessary inputs to represent anything that requires interaction from the user.

With the list screen we can come up with these actions:

  1. Refresh —from the SwipeRefreshLayout.
  2. Load More — from reaching the end of the list.
  3. Item Clicks — when the user clicks on an item.
  4. Error Action — when the application encounters an error.

Even though the error action is not visible here, it’s a good idea to always keep in mind that there will be an error state in your application.

We represent these actions in Kaskade as:

Action Sealed Classes

States

A state is a representation of your user interface. It contains information of how the screen should look like.

For the states of the list screen:

  1. Screen — a representation of what the screen looks like.
  2. Transitioning to the detail screen — a state where we are signaling the application to move onto the the detail screen.
  3. Error — indicates that an error has occurred

These states are represented as:

State Sealed Classes

The Screen state has four loading Modes and a list of GiphyItem which represents the list shown by the RecyclerView.

  1. REFRESHSwipeRefreshLayout is refreshing
  2. LOAD_MORE — reached the end of the list and start loading the next page of giphies.
  3. IDLE_REFRESH — when the list has finished refreshing, repopulate the list with new list of giphies.
  4. IDLE_LOAD_MORE —when loading GIFs from server is finished then add giphies at the end of the list.

Another thing to note is both Error and GoToDetail states are typed as SingleEvent or SingleLiveEventthese types are states that should only happen ONCE and cannot be considered as previous state in the Reducer.

Creating the Kaskade

With Kaskade DSL, there’s an easy to use syntax to create the flow of the application.

Kaskade DSL

The DSL exposes a create() function that accepts an initial state and is emitted as soon as the states are observed (or onStateChanged function is instantiated with a non-null value). In Android, initial state is important in handling Process Death in order to pass state that is saved in onSavedInstanceState().

The on method is the Reducer which takes a reified type of ListAction and a lambda with ActionState as a receiver.

The concept of reducer in Kaskade is different from the traditional reducer wherein the reducer’s responsibility is a general function to reduce actions and states into a new state while Kaskade pairs up an action and a reducer to create the new state. This way we have the freedom to add side effects in the reducer to come up with a new state for this action and the current state.

Kaskade also has an rx builder DSL to create reducers that output an Observable of ListState.

Rx Kaskade DSL

Putting it together and using RxJava to create asynchronous reducers, we will have something like this:

Kaskade for ListAction and ListState

In the example, the Kaskade is created using the rx builder to have a shared observer for actions inside the block, that is, both Refresh and LoadMore actions uses this observer.

OnItemClick and OnError is not part of the rx block and are not using the observer. They use the default reducer and run synchronously.

The DSL makes it easier to compose actions behaviors. You are able to create actions with asynchronous reducers using RxJava or Coroutines while also having reducers that runs synchronously.

Implementing ListAction.Refresh and ListAction.LoadMore, we have this following code:

Detailed implementation of Refresh and LoadMore reducers

The rx builder’s on method has a lambda parameter that has a receiver of Observable<ActionState>; thus, we can immediately call flatMap and pass in an observable.

Implementation of loadTrending() function

In this case, loadTrending() creates an Observable<ListState> that starts with a loading state and finishes with an idle mode screen with the list that it gets from the Repository.

Executing Actions

Now that we have created a Kaskade, we can start sending actions for it to process and emit new states.

Kaskade has a method to process actions:

Processing Actions in Kaskade

But we would want to observe actions instead of calling the method every time. We can then wrap this method into an Observable.

Enables the use of Observable<ListAction>

Notice that it returns a Disposable. The actions that it listens to usually will have a reference to the UI or View. Thus, to avoid memory leaks this needs to be disposed in onDestroyView.

We create the actions observable using RxBinding:

Function that creates Observable<ListAction>

Observing states

We have already created the Kaskade and passed actions to it, but nothing happens unless we observe the states emitted.

To observe the states, Kaskade has an extension:

Extension function to Observe states with LiveData

This creates a LiveData that emits the states from Kaskade. The DamLiveData saves every latest emission by type except for SingleEvent. Moreover, since it’s a LiveData, we can easily use it in Activities or Fragments without thinking about disposing the observer when the view is destroyed.

We can call a render function inside the LiveData observer to update the UI every time a state is emitted. The render function can be something like this:

Render function that is called in the LiveData Observer

Testing

With unidirectional flows testing is easier.

  1. Given a Kaskade
  2. Observe the State
  3. Process an Action
  4. Verify changes on the observer

With having state changes and actions more predictable, the tests can be simplified in four steps. This also means by adding features, we only need to add an action to handle and a state to show.

The tests for the sample app can be found here.

Putting it all together

Our final data flow will look something like this:

ViewModel abstracts the use of Kaskade as outside of it, we only have Action and State. Observing states uses LiveData and actions are observed using Rx Observables. This makes it modular and Kaskade can be removed without affecting too much of the code.

Now we have a beautiful app that shows us trending GIFs in Giphy. Yay!

Giphy Trending List

With Kaskade, we can have the benefits of a unidirectional data flow architecture that does not force itself into the architecture!

The code for the sample app is available here:

--

--