Coordinators on Android: how to build flows quickly with reusable screens

New technical post! Android engineer @Tas shares a new architectural pattern we use in our codebase called ‘the Coordinator Pattern’ :point_down:

5 Likes

@Tas, thanks for the article, the Coordinator pattern looks very useful.

It seems to me, that FlowNavigator could be replaced by NavController in the apps using Navigation Component library. Have you tried that approach or did you at least consider it? I’m interested with your thoughts about it.

Hi, welcome to the community!

The thing I do not pick up from the otherwise very detailed article was to what degree the existing code for the Android application has been refactored to pick up the new patterns

You are absolutely right, the NavController and the FlowNavigator are quite similar - and could easily be swapped. That’s one of the main benefits this patterns provided us with - a clear separation of concerns, where we can easily make changes like this.
Now as for why we chose to create our own interface, this mostly has to do with simplicity. Our navigation needs are pretty straightforward, and the FlowNavigator interface reflects that (it only has 5 methods). At this point in time, we don’t need anything more complicated, and more ways where navigation can go wrong. Having said that, maybe in the future we might change our minds…

2 Likes

I conveniently omitted that :wink: The truth is, just like any other fundamental architectural change, it’s pretty hard to move your codebase to the new paradigm. We’ve started this work about a year ago, and I think we’ve converted roughly 60% of our codebase. The initial setup was hard, but the good thing is that it’s very easy to break down, and it gets a lot easier over time. The process we followed goes something like this:

  • Identify a flow that we want to convert to use Coordinators. Ideally, it needs to be a flow that has a lot of screens that can be reused later.
  • Write the Coordinator skeleton (without any screens backing it up, just the navigation logic) in the first PR.
  • Raise one PR with the Fragment and ViewModel for each of the screens in that flow.
  • On the final PR, write the CoordinatorHost (Activity) code that hooks everything together, and update all the entry points to this flow, to point to the new Activity.

When we select another flow to convert to use Coordinators, we might already have a Fragment and a ViewModel that we’ve already converted, so we can just reuse it. Of course, when we reuse one of these into a new flow, we might need to make minor adjustments (like make the title configurable, or a button optional etc).

2 Likes

Thanks for that detailed response, and for satisfying my curiosity

It looks promising. Do you have rest of the source code published somewhere? It would be great to see how BaseViewModel, FeatureNavigator and other classes are implemented and how everything is glued together

Although we considered open sourcing parts of this, when we actually sat down and tried to extract the truly ‘reusable’ elements, we realised that there’s was very little. I’ve already pasted the CoordinatorHost and the Coordinator interfaces in the blog post. The FlowNavigator is extremely similar to the Cicerone implementation I mention.

The FeatureNavigator is really app specific, there’s nothing to open source. The interface looks something like this:

interface Navigator {
    fun goToAddMoney(accountId: String)
    fun goToTransactionDetails(transactionId: String)
    // more goTo functions
}

The implementation simply takes the parameter from the method, and creates an intent with he parameter and the correct Activity class.

As for the BaseViewModel, it doesn’t have that much code in it. Here’s most of it:

abstract class BaseViewModel<S : Any, E : Any>(initialState: S) : ViewModel() {

    private val _coordinatorEvents = MutableLiveData<SingleEvent<Any>>()
    private val _viewEvents = MutableLiveData<SingleEvent<E>>()
    private val _state: MutableLiveData<S> = MutableLiveData(initialState)

    /**
     * State changes. To be consumed by the View.
     */
    val state: LiveData<S> = _state

    /**
     * View events. To be consumed by the View (only consumed once).
     */
    val viewEvents: LiveData<SingleEvent<E>> = _viewEvents

    /**
     * Coordinator events. To be consumed by the Coordinator (only consumed once).
     */
    val coordinatorEvents: LiveData<SingleEvent<Any>> = _coordinatorEvents

    /**
     * Modify the state of this [ViewModel].
     */
    @MainThread protected fun setState(reducer: S.() -> S) {
        val currentState = _state.requireValue()
        val newState = currentState.reducer()
        if (newState != currentState) {
            _state.value = newState
        }
    }

    /**
     * Sends an event to [viewEvents] to only be consumed once.
     */
    @MainThread protected fun sendViewEvent(event: E) {
        _viewEvents.value = SingleEvent(event)
    }

    /**
     * Sends an event to [coordinatorEvents] to only be consumed once.
     */
    @MainThread protected fun sendCoordinatorEvent(event: Any) {
        _coordinatorEvents.value = SingleEvent(event)
    }

}
3 Likes