14.
Multibinding With Maps
Written by Massimo Carli
In the previous chapter, you learned how to use multibinding with Set by implementing a simple framework to integrate information from remote endpoints into the Busso App. By refactoring the Information Plugin Framework, you saw how to dynamically add features to Busso in a simple and declarative way.
In this chapter, you’ll learn how to use multibinding with Map. In particular, you’ll learn how to:
- Configure multibinding with
Map. - Use fundamental type keys with
@StringKey,@ClassKey,@IntKeyand@LongKey. - Create a simple custom key.
- Use
@KeyMapto build complex custom keys.
This is an opportunity to see how Dagger multibinding simplifies the architecture of the Information Plugin Framework.
Using multibinding with Map
In the previous chapter, you learned how to use multibinding with Sets. Set is an unordered data structure that lets you avoid duplicates. This is great but sometimes you might need something different. For instance, in the case of the Information Plugin Framework, a Set doesn’t allow you to decide the order of the information to display on the screen.
In any case, Dagger offers you another option: multibinding with Map. Map is a data structure that allows you map a value to a key.
Note: If you want to know more about data structures in Kotlin and crack interviews for getting your dream job, have a look at Data Structures & Algorithms in Kotlin.
In the following paragraph, you’ll see how to use multibinding with Map<K, V> where the key, K, is one of the following:
-
String,IntandLong Class<T>- Custom type
Using a String is the simplest case, so you’ll start with that.
Using @StringKey
For your first example, suppose you want to simplify InformationPluginSpec by removing the property name and giving the plugin a name when you add it to the registry.
To do this, open InformationPluginSpec.kt in plugins.api and remove serviceName, so it looks like this:
interface InformationPluginSpec {
val informationEndpoint: InformationEndpoint
}
Now, open InformationPluginRegistryImpl.kt in plugins.di and change it like this:
@ApplicationScope
class InformationPluginRegistryImpl @Inject constructor(
private val informationPlugins: @JvmSuppressWildcards Map<String, InformationPluginSpec> /// 1
) : InformationPluginRegistry {
override fun plugins(): List<InformationPluginSpec> =
informationPlugins.values.toList() // 2
}
In this code, you:
- Replaced
Set<InformationPluginSpec>withMap<String, InformationPluginSpec>. - Returned
List<InformationPluginSpec>from the values inMap<String, InformationPluginSpec>.
Now, you’ve set everything up and just need to register InformationPluginSpec in InformationPluginRegistry. After the changes you made above, you need to remove the definition in InformationSpecsModule.kt in plugins.di. So open that file and change it like this:
@Module(
includes = [
WhereAmIModule::class,
WeatherModule::class
]
)
object InformationSpecsModule
Note: You could also delete it and restore the state of the app before More about Multibinding with Set: @ElementsIntoSet. However, simply deleting the previous definition is fine.
Next, open WeatherModule.kt in plugins.weather.di and apply the following change:
const val WEATHER_INFO_NAME = "Weather"
@Module(includes = [WeatherModule.Bindings::class])
object WeatherModule {
@Provides
@ApplicationScope
@IntoMap // 1
@StringKey(WEATHER_INFO_NAME) // 2
fun provideWeatherSpec(endpoint: WeatherInformationEndpoint): InformationPluginSpec = object : InformationPluginSpec {
override val informationEndpoint: InformationEndpoint
get() = endpoint
}
// ...
}
As you can see in this code, you use:
-
@IntoMapto tell Dagger that the object you@Providesis part of a multibinding definition with aMap. -
@StringKey(WEATHER_INFO_NAME)to tell Dagger that the key of the object you@Providesis aStringand, in this case, its value matchesWEATHER_INFO_NAME.
Now, open WhereAmIModule.kt in plugins.whereami.di and replace the code with:
const val WHEREAMI_INFO_NAME = "WhereAmI"
@Module(includes = [WhereAmIModule.Bindings::class])
object WhereAmIModule {
// ...
@Provides
@ApplicationScope
@IntoMap
@StringKey(WHEREAMI_INFO_NAME)
fun provideWhereAmISpec(endpoint: WhereAmIEndpointImpl): InformationPluginSpec = object : InformationPluginSpec {
override val informationEndpoint: InformationEndpoint
get() = endpoint
}
}
You can now successfully build and run as usual.
Note: Dagger also allows keys of type
IntandLong. You can easily try them out on your own by using@IntKeyand@LongKeyand repeating what you just did for@StringKey.
Using @StringKey, you just set the name of the information plugin as the key of the Map you’re using to multibind. In this case, you’re not actually using that information, but you’ll see how to use key information in a more complex example next.
Using @ClassKey
Another type of key Dagger gives you is Class<T>. You can use it the same way you used String, but for your next step, you’ll try something more ambitious, instead.
You’ll use code that’s similar to the information plugins you implemented earlier. The main difference is in InformationEndpoint — the rest is just scaffolding.
To do this you basically need to:
- Simplify the
InformationPluginSpecinterface - Update
InformationPluginRegistrywith its implementation - Use
InformationEndpointas abstraction for the information plugin endpoints - Configure multibindings for WhereAmI and Weather
- Migrate
InformationPluginPresenterImplto the new abstractions - Clean up the unused code
- Build and run the Busso app
Simplifying the InformationPluginSpec interface
Your first step will be to simplify the InformationPluginSpec interface. Open InformationPluginSpec.kt in plugins.api and change its content like this:
interface InformationPluginSpec {
val serviceName: String
}
Basically, InformationPluginSpec is just a name now.
Update InformationPluginRegistry with its implementation
Next, open InformationPluginRegistry in plugins.api and change it to:
interface InformationPluginRegistry {
fun plugins(): List<InformationEndpoint>
}
Here, you’re just trying to get a list of InformationEndpoints, and that’s what InformationPluginRegistry provides. The magic is in its implementation.
Now, open InformationPluginRegistryImpl.kt in plugins.impl and change it like this:
@ApplicationScope
class InformationPluginRegistryImpl @Inject constructor(
private val retrofit: Retrofit, // 1
informationPlugins: @JvmSuppressWildcards Map<Class<*>, InformationPluginSpec> // 2
) : InformationPluginRegistry {
val endpoints = informationPlugins.keys.map { clazz ->
retrofit.create(clazz) // 3
}.map { endpoint ->
endpoint as InformationEndpoint
}.toList()
override fun plugins(): List<InformationEndpoint> = endpoints // 4
}
In this code, you:
- Add
Retrofitas the primary constructor parameter. This is the object you need to create the actualInformationEndpoint. - Receive a
Map<Class<*>, InformationPluginSpec>from Dagger. You’ll see how this works very soon. - Initialize an
endpointsvariable of the typeInformationEndpointusingRetrofitand the information about the multibinding that Dagger provides. - Return
endpointsasList<InformationEndpoint>.
Because of 4 you need all the endpoint be an implementation of the InformationEndpoint interface.
Use InformationEndpoint as abstraction for the information plugin endpoints
Look at MyLocationEndpoint and WeatherEndpoint and notice there’s a problem — they don’t share any abstraction. The implementations Retrofit creates for you aren’t InformationEndpoint implementations, and they all define operations with different names and parameters.
To fix this, open InformationEndpoint.kt in plugins.api and change it like this:
interface InformationEndpoint {
fun fetchInformation(latitude: Double, longitude: Double): Single<InfoMessage>
}
This gives you two parameters that are consistent with the specific Retrofit interfaces.
Now, change MyLocationEndpoint.kt in plugins.whereami.endpoint like this:
interface MyLocationEndpoint : InformationEndpoint { // 1
@GET("${BUSSO_SERVER_BASE_URL}myLocation/{lat}/{lng}")
override fun fetchInformation( // 2
@Path("lat") latitude: Double,
@Path("lng") longitude: Double
): Single<InfoMessage>
}
Here you:
- Added the explicit implementation of the
InformationEndpointinterface. - Renamed
whereAmIInformation()infetchInformation()to be consistent withInformationEndpointand added theoverridekeyword.
Now, open WeatherEndpoint.kt in plugins.weather.endpoint and do the same, getting the following code:
interface WeatherEndpoint : InformationEndpoint {
@GET("${BUSSO_SERVER_BASE_URL}weather/{lat}/{lng}")
override fun fetchInformation(
@Path("lat") latitude: Double,
@Path("lng") longitude: Double
): Single<InfoMessage>
}
You have the InformationEndpoint implementations for MyLocationEndpoint and WeatherEndpoint now. You can finally configure multibinding.
Configure multibindings for WhereAmI and Weather
Now it’s time to configure multibinding with Map for the information plugins. Open WhereAmIModule.kt in plugins.whereami.di and change it to this:
const val WHEREAMI_INFO_NAME = "WhereAmI"
@Module
object WhereAmIModule {
@Provides
@ApplicationScope
@IntoMap // 1
@ClassKey(MyLocationEndpoint::class) // 2
fun provideWhereAmISpec():
InformationPluginSpec = object : InformationPluginSpec { // 3
override val serviceName: String
get() = WHEREAMI_INFO_NAME
}
}
Here, you:
- Use
@IntoMapto tell Dagger to put the object you@Providein aMapwith values of typeInformationPluginSpec. - Use
@ClassKey(MyLocationEndpoint::class)to tell Dagger that the key is aClass<T>, whereTisMyLocationEndpoint. - Remove
endpoint, which you don’t need anymore because the same information is in the@ClassKeyattribute.
More importantly, WhereAmIModule contains just one definition. The others are no longer necessary because the registry now creates the endpoints.
Next, switch to WeatherModule.kt in plugins.weather.di and change it to the following:
const val WEATHER_INFO_NAME = "Weather"
@Module
object WeatherModule {
@Provides
@ApplicationScope
@IntoMap // 1
@ClassKey(WeatherEndpoint::class) // 2
fun provideWeatherSpec():
InformationPluginSpec = object : InformationPluginSpec { // 3
override val serviceName: String
get() = WEATHER_INFO_NAME
}
}
As done in the case of WhereAmIModule you here:
- Use
@IntoMapto tell Dagger to put the object you@Providein aMapwith values of typeInformationPluginSpec. - Use
@ClassKey(WeatherEndpoint::class)to tell Dagger that the key is aClass<T>, whereTisWeatherEndpoint. - Remove
endpoint, which you don’t need anymore because the same information is in the@ClassKeyattribute.
Now you’re ready to use the new configurations.
Migrate InformationPluginPresenterImpl to the new abstractions
You changed something in the abstraction for the framework, so you also need to change InformationPluginPresenterImpl.kt in plugins.ui. Just replace the start() implementation with the following:
override fun start() {
disposables.add(
locationObservable.filter(::isLocationEvent)
.map { locationEvent ->
locationEvent as LocationData
}
.firstElement()
.map { locationData ->
val res = informationPluginRegistry.plugins().map { endpoint ->
val location = locationData.location
endpoint.fetchInformation(location.latitude, location.longitude) // HERE
.toFlowable()
}
Flowable
.merge(res)
.collectInto(mutableListOf<String>()) { acc, item ->
acc.add(item.message)
}
}
.subscribe(::manageResult, ::handleError)
)
}
What you get from the InformationPluginRegistry and the way you invoke the InformationEndpoint are now different.
Clean up the unused code
Before building and running, you have some cleanup to do. Delete the following files you don’t need anymore:
WeatherInformationEndpointWeatherInformationEndpointImplWhereAmIEndpointWhereAmIEndpointImpl
Now you’re ready to test the Busso app.
Build and run the Busso app
Now you can finally build and run the app and get the expected result in Figure 14.2:
The code is now much simpler, and you’ve removed most of the classes and interfaces from the first implementation of the framework.
The question now is: Can you do even better? You’ll make more improvements in the next section.
Using multibinding with a custom Key
You can usually cover all the use cases you encounter by using @StringKey and @ClassKey. But just in case you need something special, Dagger offers multibinding with Map and a custom type for the key.
This is useful when you want to give Dagger additional information when you implement the multibindings, as you’ll see also when you’ll learn how Dagger works with Google Architecture Components. In this scenario, Dagger provides the following @MapKey annotation:
@Documented
@Target(ANNOTATION_TYPE) // 1
@Retention(RUNTIME)
public @interface MapKey {
boolean unwrapValue() default true; // 2
}
There are some important things to understand here:
-
@MapKeyis an annotation for annotations. This is the annotation you’ll use to mark the type you’ll use as the key for the multibinding. - It has an attribute,
unwrapValue, that has a default value oftrue. You’ll learn more about it soon, but in short, it tells Dagger whether or not the key is complex.
A code example will help you to better understand how all this works.
Using a simple custom @MapKey
As you read above, @MapKey annotates the class you use as the key when multibinding with Map.
As an example, suppose you want to do what you did with @KeyClass — provide Class<InformationEndpoint> for the endpoint of an information plugin. But this time, you want to use a custom type as the key.
The first step is to create a new file named SimpleInfoKey.kt in plugins.api with the following code:
@MapKey // 1
annotation class SimpleInfoKey(
val endpointClass: KClass<*> // 2
)
In this very simple code, you:
- Use
@MapKeyto annotate the custom@SimpleInfoKeyannotation. - Define
endpointClassas the only property of typeKClass<*>.
Next, you need to replace the current @ClassKey with the custom annotation @SimpleInfoKey. Open WeatherModule.kt in plugins.weather.di and apply the following change:
@Module
object WeatherModule {
@Provides
@ApplicationScope
@IntoMap
@SimpleInfoKey(WeatherEndpoint::class) // HERE
fun provideWeatherSpec(): InformationPluginSpec = object : InformationPluginSpec {
override val serviceName: String
get() = WEATHER_INFO_NAME
}
}
You just replaced @ClassKey with @SimpleInfoKey, keeping the same WeatherEndpoint::class value for the attribute.
Now, open WhereAmIModule.kt in plugins.whereami.di and do the same thing:
@Module
object WhereAmIModule {
@Provides
@ApplicationScope
@IntoMap
@SimpleInfoKey(MyLocationEndpoint::class) // HERE
fun provideWhereAmISpec(): InformationPluginSpec = object : InformationPluginSpec {
override val serviceName: String
get() = WHEREAMI_INFO_NAME
}
}
And that’s it. You don’t have to do anything except build and run the app and it will work as expected.
When you use @MapKey to define a custom key that lets you use multibinding with Map, and unwrapValue() has a default value of true, it means that:
- Your key can only have a single property. In the previous example, it was
endpointClass. - The type for the key is same as the type for that single property — in this case,
KClass<*>. That’s why you didn’t have to changeInformationPluginRegistryImpl.
For your final task, you’ll create a more complex key.
Using a complex custom @MapKey
Your final step is to make the InformationPluginSpec definition redundant and instead, put all the information you need in a custom key to use with multibinding and Map.
Start by creating a new file named ComplexInfoKey.kt in plugins.api and adding:
@MapKey(unwrapValue = false) // 1
annotation class ComplexInfoKey(
val endpointClass: @JvmSuppressWildcards KClass<out InformationEndpoint>, // 2
val name: String // 2
)
This code has some interesting parts. Here, you:
- Use
@MapKeyto passfalseas the value forunwrapValue. - Define two different properties with the types
KClass<out InformationEndpoint>andString.
In this case, the annotation has false as unwrapValue’s value, which means that the type for the key is now the annotation itself.
It’s important to say that, if you want to use a complex @MapKey with false for the unwrapValue attribute, Dagger requires an additional dependency.
Add it by opening build.gradle for the main app module and adding the following definitions in the dependencies block:
implementation "com.google.auto.value:auto-value-annotations:$autovalue_annotation_version"
kapt "com.google.auto.value:auto-value:$autovalue_version"
In this code you:
- Add the dependency to the
auto-value-annotationslibrary - Configure the annotation processor the auto value library needs for the code generation.
Note: The values for the version parameters are already available in versions.gradle in the root of the project.
Now, open WeatherModule.kt in plugins.weather.di and apply the following change:
@Module
object WeatherModule {
@Provides
@ApplicationScope
@IntoMap
@ComplexInfoKey( // 1
WeatherEndpoint::class,
WEATHER_INFO_NAME
)
fun provideWeatherSpec(): InformationPluginSpec = InformationPluginSpec
}
In this code, you:
- Use
@ComplexInfoKeyas the key, passing itInformationEndpoint’s information and the name of the plugin. - Return
InformationPluginSpec— which you’re only using now to put something into theMapfor this exercise.
For this reason, you now need to open InformationPluginSpec.kt in plugins.api and make the following changes:
object InformationPluginSpec
Now, open WhereAmIModule.kt in plugins.whereami.di and apply the same change:
@Module
object WhereAmIModule {
@Provides
@ApplicationScope
@IntoMap
@ComplexInfoKey(
MyLocationEndpoint::class,
WHEREAMI_INFO_NAME
)
fun provideWhereAmISpec(): InformationPluginSpec = InformationPluginSpec
}
When you use a complex key like this, you also need to change Map’s type, which now becomes the annotation type.
Do this by opening InformationPluginRegistryImpl.kt in plugins.impl and applying the following changes:
@ApplicationScope
class InformationPluginRegistryImpl @Inject constructor(
private val retrofit: Retrofit,
informationPlugins: @JvmSuppressWildcards Map<ComplexInfoKey, InformationPluginSpec> // 1
) : InformationPluginRegistry {
val endpoints = informationPlugins.keys.map { complexKey ->
retrofit.create(complexKey.endpointClass.java as Class<*>) // 2
}.map { endpoint ->
endpoint as InformationEndpoint
}.toList()
override fun plugins(): List<InformationEndpoint> = endpoints
}
In this code, you:
- Make
ComplexInfoKeythe type for the key in theMapyou inject as the primary constructor parameter. - Use
ComplexInfoKey’sendpointClass, which you have as a key.
Now you can finally build and successfully run the Busso App.
Key points
- Dagger allows you to use multibinding with a
Map. - When you use a
Mapfor multibinding, you can use keys of the following types:String,Int,LongandKClass. - If you need more informative keys,
@KeyMapallows you to create custom types, which you can use in a simple or complex way. - If you use
@KeyMapand anunwrapValueattribute with a default value oftrue, the type of the key is the type of the unique property of your custom key. - A
@KeyMapis complex if the value for theunwrapValueattribute isfalse. In this case, you need to add an auto-value as a dependency in your project. - You must use a complex
@KeyMapfor the key of theMapyou use in multibinding.
Wow! It’s been a very interesting chapter. Using multibinding with Map, you managed to improve the Information Plugin Framework architecture even further.
But, what if you want a more structured and organized set of modules? In the next chapter, you’ll learn how Dagger can help you.