Dagger Hilt provide alternative Modules for different flavors/build types

Viewed 3399

I try to migrate an App to Dagger Hilt. In my old setup I switched a Module for a Debug Version in Debug builds or for different product flavors. E.g.:

@Module
open class NetworkModule {

    @Provides
    @Singleton
    open fun provideHttpClient(): OkHttpClient {
        ...
    }
}

class DebugNetworkModule : NetworkModule() {

    override fun provideHttpClient(): OkHttpClient {
        ...
    }
}

Then I swapped in the correct Module in Debug builds:

val appComponent = DaggerAppComponent.builder().networkModule(DebugNetworkModule())

Since Hilt manages the ApplicationComponent I see no possibility to swap in Modules.

However when I have a look into the generated source code (for me: DaggerApp_HiltComponents_ApplicationC) I see that Hilt does generate a Builder for the different Modules (which are unused beside the ApplicationContextModule).

I know this is not the best practice. It would be cleaner to just provide different NetworkModules for each build type/product flavor. But that would result in lots of duplicated code.

In Tests I can uninstall Modules and install Test Modules. But that seem to be impossible in production code.

Is there any other way to achieve my goal?

1 Answers

The key thing with Hilt is that by default the modules in your source code = the modules installed in your app.

Option 1: Separate Code paths

Ideally, you would have alternative modules for the different builds, and separate which ones are used via sourceSets

In release source set:

@InstallIn(ApplicationComponent::class) 
@Module
object ReleaseModule {
  @Provides
  fun provideHttpClient(): OkHttpClient { /* Provide some OkHttpClient */ }
}

In debug source set:

@InstallIn(ApplicationComponent::class) 
@Module
object DebugModule {
  @Provides
  fun provideHttpClient(): OkHttpClient { /* Provide a different OkHttpClient */ }
}

Option 2: Override using @BindsOptionalOf

If option 1 isn't feasible because you want to override a module that's still present in the source, you could use dagger optional binding

@InstallIn(ApplicationComponent::class)
@Module
object Module {
  @Provides
  fun provideHttpClient(
    @DebugHttpClient debugOverride: Optional<OkHttpClient>
  ): OkHttpClient {
    return if (debugOverride.isPresent()) {
      debugOverride.get()
    } else {
      ...
    }
  }
}

@Qualifier annotation class DebugHttpClient

@InstallIn(ApplicationComponent::class) 
@Module
abstract class DebugHttpClientModule {
  @BindsOptionalOf 
  @DebugHttpClient
  abstract fun bindOptionalDebugClient(): OkHttpClient
}

and then in a file only in the debug configuration:

@InstallIn(ApplicationComponent::class) 
@Module
object DebugHttpClientModule {
  @Provides 
  @DebugHttpClient
  fun provideHttpClient(): OkHttpClient { ... }
}

Option 3: Multibinding @IntoMap

If you need more granularity that just implmenentation + test/debug override, you could use multibinding and maps, using the key as the priority for which implementation to choose.

@InstallIn(ApplicationComponent::class)
@Module
object Module {
  @Provides
  fun provideHttpClient(
    availableClients: Map<Int, @JvmSuppressWildcards OkHttpClient>
  ): OkHttpClient {
    // Choose the available client from the options provided.
    val bestEntry = availableClients.maxBy { it.key }
    return checkNotNull(bestEntry?.value) { "No OkHttpClients were provided" }
  }
}

Main application module:

@InstallIn(ApplicationComponent::class) 
@Module
object MainModule {
  @Provides 
  @IntoMap 
  @IntKey(0)
  fun provideDefaultHttpClient(): OkHttpClient {
    ...
  }
}

Debug override:

@InstallIn(ApplicationComponent::class) 
@Module
object DebugModule {
  @Provides 
  @IntoMap 
  @IntKey(1)
  fun provideDebugHttpClient(): OkHttpClient {
    ...
  }
}

If you use option 3, I would either make the provided type nullable/optional, or refrain from using @Multibinds so that things fail at compile time rather than runtime if nothing is bound into the map

Related