Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AndroidX navigation + :api/impl split for feature modules #2

Draft
wants to merge 9 commits into
base: ab/bookmarks-app-impl-split
Choose a base branch
from

Conversation

adityabhaskar
Copy link
Owner

@adityabhaskar adityabhaskar commented Dec 3, 2024

What

This is an attempted solution to use the :api / :impl modularisation split for feature modules.

Why

Constraints:

  1. The solution needs to work with Jetpack navigation.
  2. :impl modules can only depend on other features' :api module

This constraint is key because, when using Jetpack navigation, the destinations are created using extension functions on NavGraphBuilder and directly reference screen composables. E.g.:

fun NavGraphBuilder.topicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
composable<TopicRoute> {
TopicScreen(
showBackButton = showBackButton,
onBackClick = onBackClick,
onTopicClick = onTopicClick,
)
}
}

Making this function public and using it where the screen may be needed will result in any using (dependent) modules becoming dependant on the concrete feature implementation. This defeats the purpose of the api-impl split - that dependant modules only depend on the public abstraction, and not concrete implementation.

How

The simple version

  1. Expose a FeatureNavigator interface from :api that provides a screen function.
  2. Implement the interface in :impl
  3. Inject the interface at the top level compose entry point (i.e. wherever setContent is called)
  4. Pass down and use the navigator to add screens

The additional stuff

Injecting and passing down a FeatureNavigator interface for each destination is impractical. So, I created a NavigatorProvider. It hosts instances of all navigators, and provides a getter for getting any navigator. We only inject this into compose content. We fetch the correct navigator where we want.

In practice

One time setup

  1. Create a generic Navigator interface:
    interface NiaNavigator<Route, Actions, Properties> {
    /**
    * Navigates to the [route] with the given [navOptions].
    */
    fun navigateToRoute(
    navController: NavController,
    route: Route,
    navOptions: NavOptions?,
    )
    /**
    * Creates the destination component/subgraph. Callbacks/slots are hoisted as part of [actions],
    * and properties are passed down as part of [properties].
    */
    fun screen(
    navGraphBuilder: NavGraphBuilder,
    navController: NavController,
    actions: Actions,
    properties: Properties
    )
    }
  2. Create a NavigatorProvider interface:
    interface NiaNavigatorProvider {
    fun <T: NiaNavigator<out Any, out Any, out Any>> get(clazz: Class<T>): T
    }
  3. Create a concrete NavigatorProviderImpl that takes a map of Navigator class to instance
    internal class NiaNavigatorProviderImpl @Inject constructor(
    // Key should be the Class for the navigator, value should be the navigator instance itself
    private val navigators: Map<@JvmSuppressWildcards Class<*>, @JvmSuppressWildcards NiaNavigator<*, *, *>>,
    ): NiaNavigatorProvider {
    override fun <T : NiaNavigator<out Any, out Any, out Any>> get(clazz: Class<T>): T {
    return navigators[clazz] as T
    }
    }
  4. Inject an instance of NavigatorProvider into your compose hierarchy

For the purpose of this sample, I added the NavigatorProvider instance to the existing NiaAppState which was already being passed around. A more practical solution may be to provide it as a static Composition Local and use it directly where needed.

Also, I added the Navigator and NavigatorProvider interfaces to the existing :core:common module. They'd be better located in a lean :core:navigation module.

Setup for each destination/route

  1. For each destination, create a specific navigator interface extending the generic Navigator in the :api module.
  2. Then create an internal implementation for this in the :impl module (:impl depends on :api)
    internal class BookmarksNavigatorImpl : BookmarksNavigator {
    override fun navigateToRoute(
    navController: NavController,
    route: BookmarksRoute,
    navOptions: NavOptions?,
    ) {
    navController.navigate(route = route, navOptions)
    }
    override fun screen(
    navGraphBuilder: NavGraphBuilder,
    navController: NavController,
    actions: Actions,
    properties: Unit,
    ) {
    with(navGraphBuilder) {
    composable<BookmarksRoute> {
    BookmarksRoute(actions.onTopicClick, actions.onShowSnackbar)
    }
    }
    }
    }
  3. Finally, provide 2 dagger bindings
    1. One for the destination specific implementation
      @Provides
      fun provideBookmarksNavigator(): BookmarksNavigator = BookmarksNavigatorImpl()
    2. Another multi-binding to provide this implementation to the NavigatorProviderImpl
      @Binds
      @IntoMap @ClassKey(BookmarksNavigator::class)
      fun bindsBookmarksNavigator(impl: BookmarksNavigator): NiaNavigator<*, * , *>

That's all the setup on the implementation side of the destination.

Adding and navigating to the destination

  1. For adding a destination or navigating to it from any feature, that feature's impl should depend on the destination's api module.
    For instance, to add FeatureA as destination inside FeatureB's subgraphy, featureB:impl would depend on featureA:api
  2. To add the destination, use the NavigatorProvider to get an instance of the destination specific navigator, and call its screen function
    appState.navigatorProvider.get(BookmarksNavigator::class.java).screen(
    navGraphBuilder = this,
    navController = navController,
    actions = BookmarksNavigator.Actions(
    onTopicClick = navController::navigateToTopic,
    onShowSnackbar = onShowSnackbar,
    ),
    properties = Unit,
    )
  3. To navigate to the destination, use the navigateToRoute function on the navigator
    BOOKMARKS -> navigatorProvider.get(BookmarksNavigator::class.java)
    .navigateToRoute(
    navController = navController,
    route = BookmarksRoute,
    navOptions = topLevelNavOptions,
    )

Future maybe: annotations

I'm completely unfamiliar with creating custom annotations and using KSP to generate code. If/when I know how to do that, we could get rid of a lot of code here.

We could mark the destination composable with say @Destination(<RouteClass>) - similar to how Circuit uses @CircuitInject and Enro uses @NavigationDestination.
This annotation could be then used as a hook to generate all the code for that route class - the route specific interface, its implementation with that annotated function, maybe even the navigator provider's implementation with all the annotated & generated navigators added in, and all the relevant dagger modules.

With such an annotation based setup in progress, the :api module would only need to expose a Route data class/object. But all that's still far in the future.

(Missed cost receivers while iterating on this. They'd have made those interface functions so much cleaner)

@adityabhaskar adityabhaskar marked this pull request as draft December 3, 2024 22:13
@adityabhaskar adityabhaskar changed the title Use Navigator interfaces and Dagger multi-binding to provide navigation destinations in api/impl module setup AndroidX navigation + :api/impl split for feature modules Dec 3, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant