Multibinding в Hilt — как собрать зависимости в Set и Map

· 9 минут чтения

androidkotlinархитектура

Обычный DI отвечает на вопрос «дай мне вот этот объект». Но довольно быстро появляется другая задача: «дай мне все объекты вот такого типа, кто бы их ни объявил». Инициализаторы, которые нужно прогнать на старте. Трекеры аналитики, каждый из которых живёт в своём модуле. Обработчики диплинков, разбросанные по фичам. Валидаторы формы, набор которых зависит от флейвора.

Ручной список тут разваливается на второй фиче. Multibinding — это механизм Dagger (а значит и Hilt), который собирает такой список за вас: каждый модуль объявляет свой вклад, а Dagger на этапе компиляции склеивает всё в один Set или Map.

Проблема: список, который правит каждый

Начнём с честного примера. У приложения есть несколько вещей, которые нужно поднять в Application.onCreate:

interface AppInitializer {
    val name: String
    fun init()
}

class CrashReportingInitializer @Inject constructor(...) : AppInitializer { ... }
class AnalyticsInitializer @Inject constructor(...) : AppInitializer { ... }
class ImageLoaderInitializer @Inject constructor(...) : AppInitializer { ... }

Первое, что приходит в голову, — отдать их одним списком:

@Module
@InstallIn(SingletonComponent::class)
object InitializersModule {

    @Provides
    fun provideInitializers(
        crash: CrashReportingInitializer,
        analytics: AnalyticsInitializer,
        images: ImageLoaderInitializer,
    ): List<AppInitializer> = listOf(crash, analytics, images)
}

Работает. Ровно до того момента, когда проект становится многомодульным.

Модуль :core:di, где лежит этот InitializersModule, теперь обязан видеть :feature:analytics и :feature:images — то есть ядро зависит от фич, а не наоборот. Стрелки зависимостей разворачиваются, граф модулей превращается в клубок, а любая новая фича начинается с правки общего файла, который постоянно конфликтует в merge request. Плюс в списке легко «потерять» инициализатор: забыл дописать аргумент — компилятор промолчит, а на проде не отправятся краши.

Хочется обратного: чтобы фича сама заявляла «я тоже участвую», а потребитель ничего про неё не знал.

Что такое multibinding

Multibinding переворачивает направление. Вместо одного метода, который возвращает готовую коллекцию, вы объявляете много методов, каждый из которых добавляет один элемент. Dagger на этапе компиляции находит все такие методы во всех подключённых модулях и генерирует код, собирающий из них коллекцию.

Ключевое: сборка происходит в compile time. Никакой рефлексии, никакого сканирования classpath в рантайме — просто сгенерированный класс, в котором элементы перечислены явно. Если фича не подключена к сборке, её вклада в коллекции просто не будет, и это ничего не сломает.

Два вида коллекций:

Все аннотации живут в пакете dagger.multibindings, а не в dagger.hilt. Hilt тут ничего не изобретает, он просто даёт готовые компоненты через @InstallIn.

@IntoSet

Возвращаемся к инициализаторам. Каждый модуль фичи объявляет только себя:

// :feature:analytics
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsInitializerModule {

    @Binds
    @IntoSet
    abstract fun bindAnalyticsInitializer(
        impl: AnalyticsInitializer,
    ): AppInitializer
}
// :feature:images
@Module
@InstallIn(SingletonComponent::class)
abstract class ImageInitializerModule {

    @Binds
    @IntoSet
    abstract fun bindImageInitializer(
        impl: ImageLoaderInitializer,
    ): AppInitializer
}

А потребитель просит коллекцию целиком:

@HiltAndroidApp
class App : Application() {

    @Inject lateinit var initializers: Set<@JvmSuppressWildcards AppInitializer>

    override fun onCreate() {
        super.onCreate()
        initializers.forEach { it.init() }
    }
}

:core больше не знает про :feature:analytics. Добавили фичу — она приехала в набор сама; выпилили — набор просто стал меньше, потребителя править не нужно.

@Binds или @Provides

@Binds — это абстрактный метод, который говорит «когда просят интерфейс, отдай вот эту реализацию». Он ничего не создаёт, поэтому не порождает лишнего кода и должен принимать ровно один параметр. Используйте его всегда, когда реализация умеет инжектиться сама (@Inject constructor).

@Provides нужен, когда объект надо собрать руками — например, чужой класс из библиотеки:

@Module
@InstallIn(SingletonComponent::class)
object LoggingModule {

    @Provides
    @IntoSet
    fun provideTimberInitializer(): AppInitializer = object : AppInitializer {
        override val name = "timber"
        override fun init() = Timber.plant(Timber.DebugTree())
    }
}

@ElementsIntoSet

Иногда один модуль хочет добавить сразу несколько элементов — например, набор зависит от флейвора или от конфига. Для этого есть @ElementsIntoSet: метод возвращает Set<T>, и Dagger вливает его содержимое в общий набор, а не кладёт как один элемент.

@Provides
@ElementsIntoSet
fun provideDebugInitializers(
    flipper: FlipperInitializer,
    leakCanary: LeakCanaryInitializer,
): Set<AppInitializer> =
    if (BuildConfig.DEBUG) setOf(flipper, leakCanary) else emptySet()

Обратите внимание: пустой Set — совершенно легальный вклад. Так удобно делать «ничего не добавляю в релизе», не заводя два разных модуля.

@JvmSuppressWildcards — та самая грабля

Kotlin при компиляции в JVM-байткод любит добавлять wildcards: Set<AppInitializer> в позиции параметра превращается в Set<? extends AppInitializer>. А Dagger — процессор аннотаций, он видит именно Java-сигнатуру, и Set<? extends AppInitializer> для него другой тип, чем Set<AppInitializer>. Итог — MissingBinding на ровном месте.

Лечится аннотацией на аргументе типа:

@Inject lateinit var initializers: Set<@JvmSuppressWildcards AppInitializer>

Или на всём объявлении:

class StartupRunner @Inject constructor(
    private val initializers: @JvmSuppressWildcards Set<AppInitializer>,
)

Правило простое: если тип элемента коллекции — не final класс (а интерфейс всегда таковым не является), пишите @JvmSuppressWildcards. Лишним оно не будет никогда, а сэкономит полчаса на разглядывании невнятной ошибки.

@IntoMap и ключи

Set хорош, когда вы обходите все элементы. Но если элемент нужно выбрать, нужен Map. Пример — обработчики диплинков: пришёл myapp://payments/…, надо найти того, кто умеет с этим работать.

interface DeepLinkHandler {
    fun handle(uri: Uri): Intent?
}

Каждая фича регистрирует себя под своим ключом:

@Module
@InstallIn(SingletonComponent::class)
abstract class PaymentsDeepLinkModule {

    @Binds
    @IntoMap
    @StringKey("payments")
    abstract fun bindPaymentsHandler(impl: PaymentsDeepLinkHandler): DeepLinkHandler
}

Потребитель получает готовую карту:

class DeepLinkRouter @Inject constructor(
    private val handlers: Map<String, @JvmSuppressWildcards DeepLinkHandler>,
) {
    fun route(uri: Uri): Intent? =
        handlers[uri.host]?.handle(uri)
}

Готовых ключей несколько: @StringKey, @IntKey, @LongKey, @ClassKey. Последний даёт Map<Class<*>, V> и удобен, когда ключом естественно выступает тип.

Свой ключ

Строки — это ключи без типов: опечатались в "payments" — узнаете в рантайме. Гораздо приятнее ключ на enum. Для этого есть @MapKey:

enum class Screen { Payments, Profile, Settings }

@MapKey
annotation class ScreenKey(val value: Screen)
@Binds
@IntoMap
@ScreenKey(Screen.Payments)
abstract fun bindPaymentsHandler(impl: PaymentsDeepLinkHandler): DeepLinkHandler
private val handlers: Map<Screen, @JvmSuppressWildcards DeepLinkHandler>

По умолчанию у @MapKey стоит unwrapValue = true: аннотация обязана иметь ровно одно поле, и ключом становится его значение. Если поставить unwrapValue = false, ключом станет сам объект аннотации целиком — так делают составные ключи из нескольких полей, но читаемость страдает, и без реальной нужды туда лезть не стоит.

Про ViewModel

Классический пример @IntoMap из статей эпохи чистого Dagger — Map<Class<out ViewModel>, Provider<ViewModel>> и самописная ViewModelProvider.Factory. В проекте на Hilt писать это руками не нужно: @HiltViewModel делает ровно то же самое за вас — генерирует @Binds @IntoMap @StringKey("<полное имя класса>") и подсовывает фабрику, читающую эту карту. Так что если видите такой код в проекте с Hilt — это, скорее всего, наследие, которое можно удалить.

Provider и Lazy: не создавайте всё разом

У Set<AppInitializer> есть неприятное свойство: чтобы отдать вам набор, Dagger должен создать каждый его элемент. Для инициализаторов это ровно то, что нужно. А вот для карты обработчиков — нет: вы возьмёте один по ключу, а построятся все, вместе со всем их деревом зависимостей. На старте приложения такое легко превращается в лишние десятки миллисекунд.

Решение — просить не сам объект, а Provider от него:

class DeepLinkRouter @Inject constructor(
    private val handlers: Map<String, @JvmSuppressWildcards Provider<DeepLinkHandler>>,
) {
    fun route(uri: Uri): Intent? =
        handlers[uri.host]?.get()?.handle(uri)
}

Отдельно объявлять @IntoMap-вклады как Provider не нужно — Dagger умеет сам оборачивать любую биндинг-точку в Provider<T>. Модули остаются прежними, меняется только тип у потребителя.

Работает это и с наборами: Set<Provider<Validator>>, если элементы дорогие, а нужны не все.

Разница между javax.inject.Provider и dagger.Lazy та же, что и всегда: Provider.get() каждый раз идёт в граф (для нескоупленной зависимости — новый объект на каждый вызов), а Lazy.get() создаёт объект один раз при первом обращении и дальше возвращает его же.

@Multibinds: коллекция, в которую никто не положил

Ситуация из жизни модульного проекта: DeepLinkRouter лежит в :core, а все вклады — в фичах. Собираем демо-сборку без фич — и получаем MissingBinding, потому что в графе нет ни одного @IntoMap-метода, а значит нет и самого типа Map<String, DeepLinkHandler>.

Dagger не создаёт коллекцию из воздуха: если вкладов ноль, биндинга не существует. Чтобы объявить «такая коллекция в графе есть, пусть даже пустая», используется @Multibinds:

@Module
@InstallIn(SingletonComponent::class)
abstract class DeepLinkBindingsModule {

    @Multibinds
    abstract fun deepLinkHandlers(): Map<String, DeepLinkHandler>

    @Multibinds
    abstract fun appInitializers(): Set<AppInitializer>
}

Метод абстрактный, тела нет, вызывать его никто не будет — это чистая декларация для процессора. Возьмите за правило: объявляете новую multibinding-точку в общем модуле — сразу пишите к ней @Multibinds. Это стоит три строки и снимает целый класс ошибок при сборке урезанных конфигураций.

Компоненты и скоупы

В Hilt каждый вклад живёт в том компоненте, который указан в @InstallIn. И тут работает наследование: коллекция, собираемая в дочернем компоненте, включает вклады из всех родительских.

// доступно везде
@Module
@InstallIn(SingletonComponent::class)
abstract class GlobalTrackersModule {
    @Binds @IntoSet
    abstract fun bindFirebase(impl: FirebaseTracker): Tracker
}

// только в пределах активити
@Module
@InstallIn(ActivityComponent::class)
abstract class ScreenTrackersModule {
    @Binds @IntoSet
    abstract fun bindScreenTracker(impl: ScreenViewTracker): Tracker
}

Класс, живущий в ActivityComponent (например, @AndroidEntryPoint-активити), получит Set<Tracker> из двух элементов. А синглтон, инжектящийся в SingletonComponent, увидит только FirebaseTracker — вниз вклады наследуются, вверх нет. Это, кстати, удобный способ давать разным слоям разный набор: общая часть в SingletonComponent, специфика — ниже.

Со скоупами правило обычное: @Singleton-элемент нельзя положить в набор, который собирается только в ActivityComponent — точнее, можно, но сам вклад должен быть установлен в компонент, где его скоуп разрешён. Несовместимость выловит компилятор, сообщением вида ... is scoped with @Singleton but ... does not have that scope.

Что генерирует Dagger

Полезно один раз посмотреть в сгенерированный DaggerApp_HiltComponents_SingletonC (в build/generated/…). Никакой магии там нет:

private Set<AppInitializer> setOfAppInitializer() {
    return SetBuilder.<AppInitializer>newSetBuilder(3)
        .add(crashReportingInitializer())
        .add(analyticsInitializer())
        .addAll(debugInitializers())
        .build();
}

Для отложенных случаев вместо этого появится SetFactory.builder(2, 1).addProvider(…), для карт — MapFactory / MapProviderFactory, где ключи перечислены литералами.

Из этой картины следуют три практических вывода:

  1. Порядок элементов не определён. Он зависит от того, в каком порядке процессор обошёл модули, и может измениться от одной сборки к другой. Если ваши инициализаторы должны идти в определённой последовательности — multibinding вам этого не даст, заводите явное поле приоритета и сортируйте на своей стороне.
  2. Дубликаты в Set схлопываются по equals. Два разных модуля добавили объекты, которые равны, — в наборе останется один, молча. С data class-элементами это стреляет чаще, чем кажется. У Map поведение обратное: одинаковый ключ — ошибка компиляции, и это хорошо.
  3. Коллекция иммутабельна. Попытка что-то в неё положить в рантайме кончится UnsupportedOperationException.

Разбор ошибок

[Dagger/MissingBinding] java.util.Set<? extends Foo> cannot be provided without an @Provides-annotated method — обратите внимание на ? extends. Это тот самый Kotlin-wildcard, лечится @JvmSuppressWildcards. Если wildcard’а в сообщении нет, значит вкладов действительно ноль: не подключён модуль, не тот компонент в @InstallIn, или нужен @Multibinds.

[Dagger/MapKeys] The same map key is bound more than once — два вклада с одинаковым ключом. Дальше в сообщении Dagger перечислит оба метода. Частый источник — копипаста модуля в новую фичу с забытой правкой строки в @StringKey.

@IntoMap methods must declare a map key — на методе есть @IntoMap, но нет key-аннотации. Либо забыли, либо ваша самописная аннотация не помечена @MapKey.

Отдельно про квалификаторы: они относятся к коллекции целиком, а не к элементу.

@Binds @IntoSet @Named("startup")
abstract fun bindAnalytics(impl: AnalyticsInitializer): AppInitializer

Такой вклад попадёт в набор @Named("startup") Set<AppInitializer>, а не в безымянный. Значит, все вклады одного набора должны нести один и тот же квалификатор, и потребитель обязан просить его же. Забыли квалификатор на одном из модулей — этот элемент тихо уедет в другой, никем не запрашиваемый набор. Ошибки не будет, просто перестанет отправляться аналитика. Если решили квалифицировать набор — квалифицируйте и @Multibinds-декларацию, она заодно послужит документацией.

Когда не надо

Multibinding — это неявность в обмен на развязку модулей. Иногда обмен невыгоден.

Если все реализации лежат в одном модуле и их три штуки, обычный when в фабрике честнее: видно весь список, легко перейти по коду, ничего не нужно объяснять новичку. Multibinding начинает окупаться там, где вклады физически живут в разных Gradle-модулях и потребитель не должен о них знать.

Не превращайте набор в свалку. Set<Any> или Set<Feature>, куда «всё складывается», через полгода становится местом, про которое никто не может сказать, что в нём лежит: перейти по ссылке невозможно, поиск по коду даёт двадцать модулей. Держите тип элемента узким и осмысленным, а количество разных multibinding-точек — небольшим.

И помните про цену конструирования: Set<T> строит всё, Map<K, V> тоже строит все значения. Как только элементов становится много или они тяжёлые, переходите на Provider.

Итог

Главная ценность здесь не в аннотациях, а в направлении зависимостей: ядро объявляет контракт и просит коллекцию, фичи подписываются на него сами. Это ровно то, ради чего затевается разбиение на модули.


Тема подсказана статьёй Shamim Shahrier Emon, Understanding Hilt Multibinding (2024). Детали проверены по документации Dagger: Multibindings и Hilt Components.