Skip to content

Get started

Khonshu’s Codegen is a plugin for Anvil that helps with generating dependency injection related code and common boilerplate for screens.

Advantages

  • eliminate boilerplate that usually needs to be repeated on each screen
  • optional integration with Navigator to simplify its setup
  • easily let objects survive configuration changes

One other general advantage is around Fragments. If an app uses them or has to use them for legacy reasons, Codegen will mostly hide them from the developers because it generates the Fragment for each screen it’s used in. This way logic is kept out of them and the actual components like UI and state machine (presenter/view model) can be tested more easily in isolation. It also makes a migration away from Fragments easier since generated code can be easily replaced by other generated code. In fact migrating an app where each screen uses Codegen from Fragments to just using Compose is as easy as replacing a single annotation on each screen.

Dependency

implementation("com.freeletics.khonshu:codegen-runtime:0.24.0")
// instead of `anvil` it's also possible to use `ksp`
anvil("com.freeletics.khonshu:codegen-compiler:0.24.0")

Basic usage

The library provides 2 different runtime implementations. One for Fragment based apps and one for pure Compose apps. If an app uses Compose but the composables are hosted inside fragments it falls into the Fragment category.

The @NavDestination annotation is added to the top level composable of a screen. This function can have 2 parameters: the state that should be rendered and a lambda that allows the composable to send actions for user interactions. Adding the annotation will then generate another Composable function called KhonshuExampleUi, a Dagger component and a NavDestination that uses the given route and the generated composable.

@NavDestination(
    route = ExampleRoute::class,
    parentScope = AppScope::class, // AppScope is the default value and can be omitted
    stateMachine = ExampleStateMachine::class,
    destinationScope = AppScope::class, // AppScope is the default value and can be omitted
)
@Composable
internal fun ExampleUi(
  state: ExampleState,
  sendAction: (ExampleAction) -> Unit
) {
  // composable logic ...
}
scope, parentScope and destionationScope` are described in the next sections

The generated KhonshuExampleUi function will use the generated component, the annotated composable as well as the stateMachine parameter from the annotation. It will automatically hook up the state machine with the composable so that the state from the state machine is passed to the composable and actions from the latter are sent back to the state machine. The generated composable will use the generated component to obtain the state machine.

The @ComposeFragmentDestination annotation is added to the top level composable of a screen. This function can have 2 parameters: the state that should be rendered and a lambda that allows the composable to send actions for user interactions. Adding the annotation will then generate a Fragment called KhonshuExampleUiFragment, a Dagger component and a NavDestination that uses the given route and the generated Fragment.

@ComposeFragmentDestination(
    route = ExampleRoute::class,
    parentScope = AppScope::class, // AppScope is the default value and can be omitted
    stateMachine = ExampleStateMachine::class,
    destinationScope = AppScope::class, // AppScope is the default value and can be omitted
)
@Composable
internal fun ExampleUi(
  state: ExampleState,
  sendAction: (ExampleAction) -> Unit
) {
  // composable logic ...
}
scope, parentScope and destionationScope` are described in the next sections

The generated KhonshuExampleUiFragment will use the generated component, the annotated composable as well as the stateMachine parameter from the annotation. It will use the composable as its view and automatically hook up the state machine with the composable so that the state from the state machine is passed to the composable and actions from the latter are sent back to the state machine. The generated fragment will use the generated component to obtain the state machine.

The annotation has an optional fragmentBaseClass parameter that allows to specify a class other than Fragment to be used as super class for the generated Fragment. This allows using DialogFragment or BottomSheetDialogFragment for example.

Generated component

All annotations have a scope and a parentScope parameter. These will be used in Anvil’s @ContributesSubcomponent annotation on the generated subcomponent, i.e. @ContributesSubcomponent(route = ExampleRoute::class, parentScope = AppScope::class).

Since the generated subcomponent is using @ContributesSubcomponent, it is possible to use @ContributesTo, @ContributesBinding and so on with that same scope to contribute objects into it.

scope is also used for Dagger scopes. The generated component is annotated with the @SingleIn annotation that ships with Anvil and uses the scope value as a parameter. To scope a class just add @SingleIn(ExampleScope::class) to it. Any object using this scope will automatically survive configuration changes and will not be recreated together with the UI. In fact any scoped object that is created in generated component will do so together with component itself.

A factory for the generated subcomponent is automatically generated and contributed to the component that uses parentScope as its own scope. This component will be looked up internally with Context.getSystemService(name) using the fully qualified name of the given parentScope as key for the lookup. It is expected that the app will provide it through its Application class or an Activity.

For convenience purposes the generated component will make the instance of route that was used to navigate to the screen and a SavedStateHandle available which can be injected to classes like the state machine to save state. When injecting the SavedStateHandle a @ForScope(ExampleScope::class) qualifier needs to be added.

A NavDestination is automatically generated for each annotated screen. The type of the generated destination is based on the used NavRoute. To get an OverlayDestination (DialogDestination for Fragments) the route class needs to implement the Overlay marker interface.

To avoid having to access any generated code the destination is directly contributed to a Set<NavDestination> which can then be injected where the NavHost or NavHostFragment is created. By default this set lives in the AppScope component, but this can be changed by setting a different scope marker class to destinationScope. It’s also possible to use destinationScope to create distinct sets of destinations. For example if an app has a logged in and a logged out component, their scopes could be used as destinationScope depending on whether a screen is shown in the logged in or logged out state.

The integration of Khonshu’s Codegen and Navigation libraries also expects a NavEventNavigator to be injectable. This can be easily achieved by adding @SingleIn(ExampleScope::class) @ForScope(ExampleScope::class) @ContributesBinding(ExampleScope::class, NavEventNavigator::class) to a subclass of it. The generated code will automatically take care of setting up the navigator by calling NavigationSetup for compose and handleNavigation for Fragments inside the generated code.

Sharing objects between screens

Sometimes it is needed to share an object between 2 or more screens, for example in a flow of screens that are connected to each other and work on the same data. This is possible by using the route of the first screen in the flow as parentScope for the other scopes. Internally this will cause the generated component for the first screen to become the parent for the components of the other screens.

This then allows injecting anything that is available in the scope of the first screen, including the SavedStateHandle and route of the initial/parent screen.

Example

This is a minimal example of how using Khonshu’s Codegen for a screen would look like.

// marker class for the scope
sealed interface ExampleScope

// state machine survives orientation changes
@SingleIn(ExampleRoute::class)
internal class ExampleStateMachine @Inject constructor(
    val route: ExampleRoute, // inject the navigator route that was used to get to this screen
    @ForScope(ExampleRoute::class)
    val savedStateHandle: SavedStateHandle, // a saved state handle tied to this screen
    val repository: ExampleRepository, // a repository that pas provided somewhere in the app
) : StateMachine<ExampleState, ExampleAction> {
    // ...
}

// scope the navigator so that everything interacts with the same instance
@SingleIn(ExampleRoute::class)
@ForScope(ExampleRoute::class)
// make ExampleNavigator available as NavEventNavigator so that the generated code can automatically
// set up the navigation handling
@ContributesBinding(ExampleRoute::class, NavEventNavigator::class)
class ExampleNavigator @Inject constructor() : NavEventNavigator() {
    // ...
}
@NavDestination(
    route = ExampleRoute::class, // the route used to navigate to ExampleUi
    parentScope = AppScope::class, // the scope of the app level component, AppScope is the default value and can be omitted
    stateMachine = ExampleStateMachine::class, // the state machine used for this ui
    destinationScope = AppScope::class, // contribute the generated destination to AppScope, AppScope is the default value and can be omitted
)
@Composable
internal fun ExampleUi(
    state: ExampleState,
    sendAction: (ExampleAction) -> Unit,
) {
    // render the ui for ExampleState
}
@ComposeFragmentDestination(
    route = ExampleRoute::class, // the route used to navigate to ExampleUi
    parentScope = AppScope::class, // the scope of the app level component, AppScope is the default value and can be omitted
    stateMachine = ExampleStateMachine::class, // the state machine used for this ui
    destinationScope = AppScope::class, // contribute the generated destination to AppScope, AppScope is the default value and can be omitted
)
@Composable
internal fun ExampleUi(
    state: ExampleState,
    sendAction: (ExampleAction) -> Unit,
) {
    // render the ui for ExampleState
}

Using this would require a one time setup in the app so that the screens can look up the AppScope component through getSystemService to retrieve the parent component:

@AppScope
@MergeComponent(scope = AppScope::class)
interface AppComponent {
    // allows an Activity to get all generated NavDestinations to set up the NavHost
    val destinations: Set<NavDestination>

    @Component.Factory
    interface Factory {
        fun create(): AppComponent
    }
}

class App : Application() {

    private val component: AppComponent = DaggerAppComponent.factory().create(this)

    override fun getSystemService(name: String): Any {
        if (name == AppScope::class.qualifiedName) {
            return component
        }
        return super.getSystemService(name)
    }
}