Coordinator Pattern in Android with Kotlin Coroutines
At Capital One, my team builds Android SDKs for the enterprise. Recently we encountered an interesting situation where the UI Flow had to be dynamic, meaning every interaction with the server would return a response containing the next screen the SDK needed to display. We knew this problem could easily result in bad code if not designed well. So, we turned to an approach that responds dynamically to API responses, inspired by Hannes Dorfmann’s Coordinator pattern.
To give some context, our SDKs are built using an in-house Model-View-Intent (MVI) framework. The framework is designed using redux principles and supports structured concurrency.
This article focuses on our approach to solve dynamic navigation using Kotlin coroutines.
If you’re unfamiliar with coroutines, Roman Elizarov has numerous articles and videos that explains the concept extremely well.
A Coroutines-Based Solution
Coordinators contain business logic to navigate between views.
A coordinator uses a coroutine actor that processes asynchronous intentions sequentially and these are scoped to a flow’s lifecycle.
The following diagram is a reference to various components we will be discussing.
- Root Coordinator is scoped to the application’s / SDK’s coroutine context and is responsible for invoking a specific flow coordinator based on the business rule via Root Navigator.
- Flow Coordinator is scoped to the activity view model and is shared with all child view models. Basically, they own the responsibility to navigate between views within the flow.
- Flow Navigator is a stateless component that only owns logic for adding views/fragments to the flow’s activity. They are invoked by the flow coordinator.
The below code example represents a coordinator that is used in Sign In activity flow which is started by Root Coordinator. To keep this article short and focused we will be discussing only one of the coordinators since they all follow similar design principle.
scope is tied to main activity’s view model and shared across child fragment view models. The onFlowComplete lambda is provided by RootCoordinator which should be invoked when a flow is finished. This way, exit and entry of flow is controlled by RootCoordinator.
These are what our coordinator consumes to compute an outcome. They could be created by a user action or some async operation like an API response.
An actor that can win an Oscar in the async programming world ;)
Our coordinator also has an actor that would be initialized lazily and with an unlimited channel capacity. The capacity we selected is based on our use case since we did not want to drop any intentions. An actor processes intentions sequentially and computes new states via the
copy(…) extension function, thus making sure concurrent state mutation does not happen. Ideally, each intention would map to a navigation action that is provided by SignInFlowNavigator.
Coordinator also exposes a function to add intentions to actor’s queue.
More about coroutine actors can be read here.
Now let’s look at the navigator that is used in Sign In activity flow which is invoked by Sign In Coordinator. To keep this article short and focused we will be discussing only one of the navigators since they all follow similar design principle.
Navigators are stateless and have similar scope to that of SignInFlowCoordinator , although they own no business logic. Like coordinator, it processes its intentions via an actor. As you may have noticed, we also pass in an activityFetcher which exposes
CompletableDeferred<AppCompatActivity> that navigator suspends on while the activity is not available or is not in a valid lifecycle state.
The approach discussed above is just one possible implementation of the Coordinator concept. This approach can help make view models and activity/fragments lighter, remove duplication, and abstract navigation logic.