Designing Jetpack Compose architecture for a gradual transition from fragments on Android

Adopting Jetpack Compose at Turo

Pavlo Stavytskyi
Published in
15 min readJun 5, 2023

--

Jetpack Compose is a modern toolkit recommended for building native UI for Android apps. It provides a more intuitive developer experience helping engineers to write less UI code while making it more reliable.

The engineering team at Turo was excited about adopting Jetpack Compose in our Android app. However, as it usually happens, we have an existing codebase that heavily relies on fragment API. While Jetpack Compose has pretty good compatibility with a legacy UI stack out of the box, just including a new dependency in the project and starting to write composable functions was not enough.

An important task when integrating such a thing as a core UI toolkit is to make sure it fits well with the existing app architecture and APIs it relies on. More specifically, we wanted to find solutions to the following questions:

  • How do we enable our team to write purely-compose features without bothering about compatibility with the rest of the app?
  • How do we navigate from fragments to compose screens?
  • How do we navigate from compose to fragments?
  • How do we make the experience above simple and unified?

In this story, we will share our experience of adopting Jetpack Compose at Turo by designing the architecture that provides seamless and gradual integration of the modern UI toolkit into the existing codebase. We are going to talk about how we leveraged the power of Kotlin Symbol Processing API to provide a pure Jetpack Compose experience for our Android engineers while keeping the new product features fully compatible with the rest of the app automatically.

The content in this story is now available as an open-source navigation library — Nibel. You can find it on GitHub. In this post, you’ll see how it’s implemented under the hood, while the next one, describes how can you use it to migrate your project from fragments to Jetpack Compose.

Overview

Without further ado, let’s see what experience our engineers have when creating new UI features for the Turo Android app.

As you can see below, when we’re creating a UI screen, all we need to do is start writing @Composable functions. The only additional step is to annotate it with the @UiEntry annotation and declare a simple object or data class that will register this screen as a destination in a navigation graph.

@UiEntry(
destination = VehicleSearchDestination::class,
type = ImplementationType.Fragment
)
@Composable
fun VehicleSearchScreen(viewModel: VehicleSearchViewModel = ...) {
SideEffectHandler(viewModel.sideEffects) {
when (it) {
// navigating to another screen
is VehicleSearchSideEffect.ShowDetails -> navigateTo(
VehicleDetailsDestination(VehicleDetailsArgs(it.vehicleId))
)
}
}

// the rest of the compose UI
...
}

That’s it. Now this screen is compatible with the rest of the app. You might have noticed we’re using ImplementationType param in the annotation that could be one of two values: Fragment or Composable.

But what does it mean? In order to keep the screen compatible with the legacy features the Fragment param will generate a corresponding fragment (so-called fragment entry) that will use the annotated compose function as its content. Of course, we can’t just migrate the entire app to compose at once. Therefore, this is helpful when navigating from the legacy screens to the new compose screens.

Let’s say you’re developing a new product feature with 10 screens using Jetpack Compose. One of those screens will be the external entry point of the feature, meaning that users will navigate to it from other parts of the app. In order for this screen to be compatible with the app codebase, it must also be wrapped in a fragment. This wrapper is generated automatically so that the rest of the app will see this new screen as a fragment and will navigate to it using fragment transactions as usual.

For the remaining 9 screens that are internal in the feature, there is no need to generate a separate fragment for each. Instead, ImplementationType.Composable is used to generate a thin compose wrapper around the screen (so-called composable entry). This way, navigation between screens inside the feature is done purely with compose.

Navigation to compose features

Over time, when more compose screens are added to the app there will be more features that are navigated only from compose screens. These features could completely rely on ImplementationType.Composable even for their external entry points.

At any point in time, for any screen inside the feature, you could change the ImplementationType value and no other code changes would be required. The code will be still compilable including the navigation between screens.

Abstraction over navigation

This is possible due to the abstraction layer over the navigation we’ve built. Obviously, in order to navigate between fragments, transactions should be used. For composable screens, we’re using compose navigation library under the hood. Overall, there are 4 scenarios for navigation between screens that should be covered:

  • fragment to fragment — using fragment transactions
  • fragment to compose — using compose navigation
  • compose to compose — using compose navigation
  • compose to fragment — using fragment transactions

However, all of the above is an implementation detail and is hidden from the developer. Instead, we use navigateTo function (as shown in the code snippet above) that provides an abstraction over the navigation and figures out automatically under the hood, which tool for navigation to choose (we will cover it in more detail later in this post).

This means that compose navigation library for example could be replaced with any other solution at any point in time, and not a single code change in feature modules would be required.

This is also handy as we don’t have to reinvent the wheel by implementing a custom navigation graph, backstack, etc as everything is handled for us by the underlying tools.

The thought process

Now that we have seen a brief overview of the architecture, let’s take a closer look at its core components and the thought process behind their design.

The first question we needed to answer with a fragment-based codebase is what is the most straightforward way to start writing composable screens? One of the first solutions that comes to mind is to start wrapping every composable screen with a fragment as shown below.

class VehicleSearchFragment: Fragment() {

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
VehicleSearchScreen(...)
}
}
}
}

@Composable
fun VehicleSearchScreen(...) {
...
}

One great benefit such an approach provides is compatibility with the rest of the app. If there is a need to navigate to or from this screen you could use fragment transactions as you normally do.

Reducing the boilerplate

The example above contains a bunch of boilerplate code that could be moved to a base ComposableFragment class. Therefore, instead of overriding onCreateView every time, developers could just use ComposableContent function instead as shown below.

class VehicleSearchFragment: ComposableFragment() {

@Composable
override fun ComposableContent() {
VehicleSearchScreen(...)
}
}

Temptation to do it the old way

While this is already a solution that might serve the purpose, it has its flaws. Even though our fragment allows writing compose UI, it is still a fragment.

This leads to a temptation to do certain things in an old imperative way using APIs provided directly by fragments. Therefore, this is not quite a proper compose experience as it makes certain parts of the code less flexible and convenient to maintain or refactor.

Ensuring proper compose experience

However, fragments like VehicleSearchFragment could be easily autogenerated ensuring compatibility with other screens that rely on fragments as well as pure compose experience.

Instead of creating a custom fragment that extends ComposableFragment, all we need to do is just annotate a composable function with @UiEntry like shown in the example below.

@UiEntry
@Composable
fun VehicleSearchScreen(...) {
...
}

The custom Kotlin Symbol Processor generates the corresponding fragment class automatically providing a proper Jetpack Compose experience for the team.

Navigation

One of the most important aspects of any architecture on Android is navigation between product features and screens. This is especially challenging and important in multi-module projects as it has a direct impact on many aspects of the development process including build times.

Let’s take a look at how navigation between features in the Turo app looked before adopting Jetpack Compose architecture in the figure below.

Relationship between feature modules

There are a few key points here worth mentioning.

  • The app consists of feature and library modules for the most part.
  • No feature modules are not allowed to depend on each other.
  • In order to navigate between features a navigation module is used. All features depend on navigation while the latter is not allowed to depend on any feature module.

One great benefit provided by this approach is better build times. This is because changes in a specific feature do not trigger incremental rebuilds of all modules that depend on it (since nothing depends on feature modules except the root app module).

On the other hand, there is still a flaw with this approach. As mentioned earlier, the navigation module cannot depend on any feature, which means that the navigation process lacks type safety.

Let’s have a look at how the feature navigation in the app was organized in the past.

// :navigation

object VehicleDetailsNavigation {
const val FRAGMENT_NAME = "com.turo.vehicledetails.VehicleDetailsFragment"

@Parcelize
data class Args(val vehicleId: String): Parcelable

fun newInstance(vehicleId: String): Fragment {
// instantiate fragment using reflection
val fragment = loadFragment(FRAGMENT_NAME)
fragment.arguments = bundleOf(
"args" to Args(vehicleId = vehicleId)
)
return fragment
}
}

Since the navigation module does not depend on feature modules, it is not able to refer to fragments directly. Instead, the app was using a fully qualified name of fragment classes to instantiate them using reflection using a custom loadFragment utility function. Consequently, calling the newIntance factory function returned an instance of a fragment that feature modules could use for navigation.

Overall, as you can see in the snippet above, it was required to write a bunch of boilerplate code in order to navigate from one feature to another.

Destinations concept

When designing the new architecture we wanted to preserve the benefits of the existing navigation approach while mitigating its flaws.

Instead of a bunch of boilerplate code above that forces developers to manually instantiate screen entries, we now operate with the concept of destinations.

The destination is a type that represents a UI screen in a global navigation graph. They should be declared in a navigation module so that every feature could reference them.

// :navigation

@Parcelize
data class VehicleDetailsArgs(val vehicleId: String): Parcelable

class VehicleDetailsDestination(override val args: VehicleDetailsArgs): DestinationWithArgs<VehicleDetailsArgs>

Once declared, it needs to be explicitly connected to a certain UI screen using the @UiExternalEntry annotation as shown below.

// :features:vehicledetails

@UiExternalEntry(VehicleDetailsDestination::class)
@Composable
fun VehicleDetailsScreen(viewModel: VehicleDetailsViewModel = ...) {
...
}

It is worth admitting that one piece of information shared earlier in this blog post was not quite correct. We don’t actually use @UiEntry annotation but rather we have 2 its variations which are UiEntry and @UiExternalEntry. We will cover the difference between them shortly in this post.

As a result, a corresponding fragment class is generated.

// generated code

class VehicleDetailsScreenEntry: ComposableFragment() {

@Composable
override fun ComposableContent() = VehicleDetailsScreen()

companion object: FragmentEntryFactory<VehicleDetailsDestination> {

fun newInstance(destination: VehicleDetailsDestination): FragmentEntry {
val fragment = VehicleDetailsScreenEntry
fragment.arguments = bundleOf("args" to destination.args)
return FragmentEntry(fragment)
}
}
}

From the code snippet above we can see that one of the important parts of each generated fragment is its companion object that serves as a factory for this fragment. On the other hand, a similar factory is being generated for screens that use ImplementationType.Composable along with a corresponding composable entry class, instead of a fragment.

// generated code

@Parcelize
class VehicleDetailsScreenEntry(
override val args: Parcelable?,
override val name: String,
) : ComposableEntry(args, name) {

@Composable
override fun ComposableContent() = VehicleDetailsScreen()

companion object: ComposableEntryFactory<VehicleDetailsDestination> {

override fun newInstance(destination: VehicleDetailsDestination): ComposableEntry {
val entry = VehicleDetailsScreenEntry(
args = destination.args,
name = buildRouteName(VehicleDetailsScreenEntry::class.qualifiedName!!, destination.args),
)
return entry
}
}
}

Regardless of an implementation type if it is an external entry, its factory is injected to a map with Dagger, and Anvil in particular, using @IntoMap. Consequently, a corresponding Dagger module needs to be generated as shown below.

// generated code

@Module
@ContributesTo(AppScope::class)
object VehicleDetailsNavigationModule {

@Provides
@SingleIn(AppScope::class)
@IntoMap
@DestinationKey(VehicleDetailsDestination::class) // dagger map key
fun provideVehicleDetailsEntry(): EntryFactory<*, *> {
// returning a reference to a companion object.
return VehicleDestinationEntry
}
}

For navigation to another screen, a class of a destination is used as a key in order to retrieve a corresponding factory and instantiate the entry.

typealias DestinationsMap = Map<Class<out Destination>, @JvmSuppressWildcards EntryFactory<*, *>>

fun <D : ExternalDestination> DestinationsMap.findEntryFactory(destinationClass: Class<out D>): EntryFactory<D, *> {
return this[destinationClass] as EntryFactory<D, *>
}

As a result, when navigating to a destination, the following code should be used.

val destination = VehicleDetailsDestination(VehicleDetailsArgs(it.vehicleId))
navigateTo(destination)

It’s worth mentioning that for screens that don’t accept arguments only a single line is required for declaring a corresponding destination type for them.

// :navigation

object VehicleSearchDestination: DestinationWithNoArgs

Internal navigation

Creating destination types for feature entry points is required to mitigate the fact that the navigation module does not have direct references to specific classes from the feature modules.

In case we need to navigate between screens inside a specific feature module, we could just reference them directly as internal destinations. This also helps to avoid unnecessary code generation required to

In order to understand how internal and external navigation is organized, let’s take a look at the complete hierarchy of Destination types as core architecture components.

Destination type hierarchy

We’re already familiar with DestinationWithNoArgs and DestinationWithArgs types that are used to declare destinations for external navigation.

When navigating internally inside a single feature module, there is no need for declaring redundant destinations as all the respective classes could be referenced directly. Therefore, fragment or composable entries are serving as destinations themselves as they are both the descendants of an Entry interface.

For fragments, it's just a simple wrapper class as we want to avoid exposing the fragment instances directly in the feature code.

class FragmentEntry(val fragment: Fragment): Entry

Similarly, the base class for composable entries also inherits the Entry interface.

abstract class ComposableEntry(
open val args: Parcelable?,
open val name: String,
) : Entry, Parcelable {

@Composable
abstract fun ComposableContent()

@Composable
fun ComposableEntry(internalNavController: NavController) {
...
CompositionLocalProvider(...) {
ComposableContent()
}
}
}

As a result, when navigating to another screen inside the same feature module, the following code should be used.

val entry: FragmentEntry = VehicleDetailsEntry.newInstance(it.vehicleId)
navigateTo(entry)

Implementation

As we’ve discovered above, each screen is represented by an automatically generated underlying class that could be either a fragment entry or a composable entry. In general, we want to minimize the number of fragment entries in a codebase and use them only where it’s truly needed (E.g. when navigating from legacy fragment screens).

This means we need to make sure we are able to navigate between different types of entries in a seamless and unified way. Overall, the navigation on the UI level is performed using a concept of side effects which is a stream of the events that is consumed using SideEffectHandler composable function as shown below.

@UiEntry
@Composable
fun VehicleSearchScreen(viewModel: VehicleSearchViewModel = ...) {
SideEffectHandler(viewModel.sideEffects) { [this: NavigationController]
when (it) {
// navigating to another screen
is VehicleSearchSideEffect.ShowDetails -> navigateTo(
VehicleDetailsDestination(VehicleDetailsArgs(it.vehicleId))
)
}
}

// the rest of the compose UI
...
}

The SideEffectHandler is a simple library function that consumes the side effects from the flow and emits them one by one.

@Composable
fun <SE> SideEffectHandler(
sideEffects: Flow<SE>,
key: Any = Unit,
handle: NavigationController.(SE) -> Unit,
) {
val navigationController = LocalNavigationController.current
LaunchedEffect(key1 = key) {
sideEffects.collectLatest { navigationController.handle(it) }
}
}

The handle lambda above has a receiver of NavigationController type that provides an abstraction over the navigation layer in the app. This way, it is available as this context when subscribing to side effects on the UI layer.

The code snippet below provides a simplified version of a navigation controller which is able to handle 2 primary cases for navigation: external and internal. Each time it checks the type of next destination and performs the navigation accordingly.

class NavigationControllerImpl(...) : NavigationController {
...

override fun navigateTo(externalDestination: ExternalDestination) {
val destinationEntry = destinationsMap
.findEntryFactory(externalDestination.javaClass)
.newInstance(externalDestination)
navigateTo(internalDestination = destinationEntry)
}

override fun navigateTo(internalDestination: Entry) {
when (internalDestination) {
is ComposableEntry -> navigateToComposable(internalDestination)
is FragmentEntry -> navigateToFragment(internalDestination)
}
}

// perform a fragment transaction
private fun navigateToFragment(destinationFragment: Fragment) {
...
}

// add a new route with the compose navigation library,
// and navigate to it at once.
private fun navigateToComposable(destinationComposable: ComposableEntry) {
...
}
}

With that being said, let’s take a look in more detail at each of the use cases for the navigation between the app screens.

Fragment to compose. Navigating between different types of screens is the most challenging part as it requires bridging two different tools. In our particular case, we’re using compose navigation library for this purpose. However, its API is not exposed directly to engineers, and thus, any navigation solution could be used in its place under the hood.

Fragment to compose navigation

When we’re navigating from fragments to compose, we’re using compose navigation. This is possible by using a simple trick that sets up a NavHost composable for every fragment entry and has all its contents as a root destination by default.

abstract class ComposableFragment : Fragment() {

@Composable
abstract fun ComposableContent()

override fun onCreateView(...) = ComposeView(...).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
...
NavHost(..., startDestination = "@root") {
composable("@root") {
ComposableContent()
}
}
}
}
}
}

When navigation to a composable screen is needed, a new destination is dynamically added to the NavHost inside of the NavigationController as shown in the example below.

import androidx.navigation.NavController

class NavigationControllerImpl(
private val internalNavController: NavController,
private val exploredEntries: MutableMap<String, ComposableEntry>
) : NavigationController {
...

private fun navigateToComposable(destinationComposable: ComposableEntry) {
val route = destinationComposable.name

if (internalNavController.graph.findNode(route) == null) {
// keeping track of dynamically added entries
// will be handy later, when we will be talking
// about handling configuration changes
exploredEntries[route] = destinationComposable

val navigator = internalNavController.navigatorProvider
.getNavigator(ComposeNavigator::class.java)

val destination = ComposeNavigator.Destination(navigator) {
destinationComposable.ComposableEntry(...)
}
destination.route = route
internalNavController.graph.addDestination(destination)
}

internalNavController.navigate(route)
}
}

In this context, a destination is not a concept we’ve discussed earlier but rather a class from the compose navigation library.

Compose to compose. This is a pretty straightforward case, as the navigation between composable screens would be done similarly as above using the compose navigation library under the hood.

Compose to compose navigation

When navigating to a compose screen a respective destination is being added to a navigation graph dynamically similarly to what was described above.

Compose to fragment. When navigation to a screen that uses legacy fragments is required, a fragment transaction is performed between the fragment that hosts the composable navigation graph (NavHost in our case) and the next destination fragment.

Compose to fragment navigation

Handling configuration changes

Compose navigation library does not retain dynamically added destinations on configuration changes. However, this is how all the destinations are being added in the NavigationControllerImpl as we saw above. Therefore, every time the UI state is restored, all of them will be lost.

When restoring the state, NavHost will recreate only those destinations that are explicitly defined using a composable function in its builder. By default, our NavHost has only the original root destination declared, as we have already seen.

// inside onCreateView of ComposableFragment

NavHost(..., startDestination = "@root") {
composable("@root") {
ComposableContent()
}
}

This means that all the dynamically added destinations will be lost after NavHost is recreated. This may cause the app to crash, as the navigation library will try to restore the back stack that remembers routes that no longer exist.

To solve this issue, every time NavHost is recreated, we need to ensure that we consider all the routes that were added dynamically by storing the respective ComposableEntry instances with rememberSaveable.

// survies configuration changes
val exploredEntries = rememberSaveable(...) {
mutableMapOf<String, ComposableEntry>()
}

NavHost(..., startDestination = "@root") {
composable("@root") {
ComposableContent()
}

// restoring dynamically added routes
for ((route: String, entry: ComposableEntry) in exploredEntries) {
composable(route) {
entry.ComposableEntry(...)
}
}
}

If you remember, we register each ComposableEntry in the exploredEntries every time we perform the navigateToComposable navigation in the NavigationControllerImpl.

All the instances of ComposableEntry type implement Parcelable which makes it easy to serialize/deserialize them during configuration changes.

To conclude

When discussing the architecture of Android apps, topics like dependency injection, view models, and domain/data layers often come up. However, this story intentionally avoids focusing on them, as they can all seamlessly integrate into the setup described here.

There are various libraries and patterns for implementing view models available out there. All of them could be integrated with the architecture described here. Similarly, if you need to configure the dependency injection tool of your choice, a corresponding setup could be made in the base classes like ComposableFragment, ComposableEntry and their specific descendants that are generated.

When migrating an Android codebase to Jetpack Compose it is crucial to provide a proper developer experience from day one. Continuing to rely on legacy APIs significantly prolongs their presence in the codebase, making development and maintenance more complex and less flexible. This can restrict the engineering team from fully experiencing the benefits of using Jetpack Compose.

A highly effective approach for achieving this goal is leveraging code generation. It serves the purpose of concealing a boilerplate code, primarily aimed at ensuring compatibility with the legacy stack. Another notable advantage of utilizing code generation is the flexibility it offers in modifying the implementation of a symbol processor or base architecture classes. Because the codebase relies on annotations, updates, and adjustments can be made to the underlying core components at any time, with minimal or no code changes required.

Adopting Jetpack Compose helped the engineering team at Turo to be much more efficient when working on product features while keeping the development process more enjoyable and less error-prone.

--

--

Pavlo Stavytskyi

Google Developer Expert for Android, Kotlin | Sr. Staff Software Engineer at Turo | Public Speaker | Tech Writer