Внедрение зависимостей (DI) означает, что класс получает свои зависимости снаружи, обычно через конструктор, а не создаёт их сам и не достаёт из глобального объекта.
Тестируемость: тест передаёт фейковый UserApi прямо в конструктор, ничего глобального мокать не нужно.
Честные сигнатуры: конструктор перечисляет всё, что нужно классу, поэтому скрытая связанность видна на код-ревью.
Dependency Inversion (буква D в SOLID) является принципом: зависеть от абстракций, например от интерфейса UserRepository, а не от конкретных классов.
Inversion of Control: что-то снаружи класса решает, что он получит; DI представляет собой одну из форм IoC.
Service locator: класс сам просит нужное у реестра (ServiceLocator.userRepository); зависимость не видна в сигнатуре, и каждый тест должен сначала настроить глобальный объект.
AndroidMiddle
Dependency Injection в Android
Hilt, Dagger, Koin, scope-ы, модули, тестируемость
Закрыто вопросов
0 / 24
- 0Повторить
- 6Часто спрашивают
Вопросы
Внедрение через конструктор служит вариантом по умолчанию: объект просто не может существовать без своих зависимостей. Activity и Fragment создаёт система через конструктор без аргументов, поэтому Hilt приходится присваивать @Inject-поля уже после создания.
Пометьте класс @AndroidEntryPoint и объявите @Inject lateinit var analytics: AnalyticsTracker. Поле не может быть private: его присваивает сгенерированный код.
Когда поля заполняются: в Activity во время super.onCreate(), во Fragment во время super.onAttach(). Если прочитать поле раньше, например в инициализаторе свойства, будет UninitializedPropertyAccessException.
Fragment с @AndroidEntryPoint должен жить в Activity, которая тоже @AndroidEntryPoint: биндинги ему поставляет компонент Activity.
Есть ещё внедрение через метод (@Inject на функции); оно выполняется сразу после внедрения полей и на практике встречается редко.
Держите внедрение через поля на краю: Activity остаётся тонкой, а настоящая работа живёт в @HiltViewModel, который получает зависимости через конструктор и собирается с фейками в обычном unit-тесте.
@Inject помечает конструкторы, которые Dagger может вызывать, @Module с @Provides собирает типы, которые сам он создать не может, а @Component помечает интерфейс, который Dagger реализует, чтобы связать одно с другим; граф проверяется при компиляции, а экземпляры создаются лениво во время выполнения.
**@Inject constructor**: Dagger генерирует UserRepository_Factory, которая его вызывает. На поле @Inject означает внедрение в объект, созданный кем-то другим (members injection).
**@Module + @Provides**: тело функции для сторонних или настраиваемых типов: Retrofit, OkHttpClient, база Room. @Binds связывает интерфейс с реализацией.
**@Component**: перечисляет свои модули и открывает provision-методы (fun userRepository(): UserRepository) или методы внедрения (fun inject(activity: MainActivity)). Dagger генерирует DaggerAppComponent.
**@Component.Factory с @BindsInstance**: передаёт в граф объекты времени выполнения, например Context.
Скоупы: @Singleton на компоненте и на биндинге означает один экземпляр на экземпляр компонента, а не на процесс.
Annotation processor (KSP или kapt) читает всё это во время компиляции, проверяет граф и пишет обычный Java-код. Отсутствующий биндинг даёт ошибку сборки, а в рантайме нет никакой рефлексии, только сгенерированные фабрики, вызывающие конструкторы. Hilt использует те же аннотации и пишет компоненты за вас.
Hilt представляет собой DI-библиотеку Google для Android: Dagger 2 с заранее заданным деревом компонентов, привязанным к жизненным циклам Android, поэтому вы ставите аннотации на классы, а не пишете и связываете компоненты сами.
Точки входа задаются аннотациями: @HiltAndroidApp на Application генерирует корневой компонент; @AndroidEntryPoint внедряет зависимости в Activity, Fragment, View, Service или BroadcastReceiver; @HiltViewModel регистрирует ViewModel для by viewModels() и hiltViewModel().
Фиксированная иерархия: корень SingletonComponent; под ним ActivityRetainedComponent (родитель ViewModelComponent и ActivityComponent) и ServiceComponent; FragmentComponent и ViewComponent находятся под ActivityComponent. У каждого своя аннотация скоупа.
**@InstallIn: модуль сам объявляет, к какому компоненту относится; никто не перечисляет модули в интерфейсе компонента.
Интеграции с Jetpack**: SavedStateHandle во ViewModel, @HiltWorker для WorkManager, hiltViewModel() для Compose, API для тестов.
Всё остальное работает как обычный Dagger: @Module, @Provides, @Binds, квалификаторы, мультибиндинги. Для времени жизни, которого нет в Android, например сессии залогиненного пользователя, добавляют @DefineComponent.
Для подключения нужны Gradle-плагин com.google.dagger.hilt.android и ksp("com.google.dagger:hilt-compiler"); kapt по-прежнему работает, но это медленный устаревший путь. Hilt и чистый Dagger уживаются в одной сборке, поэтому существующее Dagger-приложение можно переводить экран за экраном.
@Binds говорит Dagger, что один тип удовлетворяется уже существующим биндингом другого типа, поэтому генерируется только псевдоним; @Provides выполняет тело функции, которое написали вы. Оба умещаются в одном модуле, если @Provides лежит в companion object.
@Binds, когда
Реализация ваша и у неё уже есть @Inject constructor.
Нужно только сказать: «кто просит AnalyticsTracker, получает FirebaseTracker».
Вы добавляете свой внедряемый класс в мультибиндинг через @IntoSet или @IntoMap.
@Provides, когда
Тип не ваш: Retrofit, OkHttpClient, FirebaseAnalytics, база Room. Конструктора, на который можно повесить аннотацию, нет.
Создание требует настройки: билдеры, base URL, таймауты, Context.
Почему это важно: @Binds не порождает класс-фабрику, только приведение типа, поэтому граф, построенный в основном на @Binds, генерирует меньше кода и собирается быстрее.
Форма модуля следует из этого. Методы @Binds абстрактные, поэтому живут в abstract class или модуле-interface; @Provides в Kotlin кладут в object, чтобы функция была статической. Чтобы совместить оба вида, используют абстрактный класс с companion object, в котором лежат функции @Provides.
Скоуп делает биндинг одним экземпляром на экземпляр своего компонента, так что выбор скоупа равносилен утверждению о том, сколько живёт объект; биндинги без скоупа создаются заново в каждой точке внедрения, и это правильное поведение по умолчанию.
@Singleton → SingletonComponent: один на процесс.
@ActivityRetainedScoped → ActivityRetainedComponent: переживает поворот экрана, живёт, пока Activity не завершится по-настоящему.
@ViewModelScoped → ViewModelComponent: один на @HiltViewModel, общий для её зависимостей, освобождается, когда ViewModel очищается.
@ActivityScoped, @FragmentScoped, @ViewScoped, @ServiceScoped: один на Android-объект, поэтому после поворота экрана @ActivityScoped-экземпляр будет новым.
Почему не скоупить всё
Объект со скоупом удерживается, пока жив его компонент. @Singleton, кэширующий данные пользователя, хранит их и после логаута и никогда не собирается сборщиком мусора.
Каждый биндинг со скоупом идёт через DoubleCheck, провайдер с double-checked locking: дёшево, но не бесплатно на горячих путях.
Биндинг может зависеть только от своего компонента или более широких. @Singleton не может принять тип с @ActivityScoped: сборка упадёт.
@Reusable служит промежуточным вариантом: Dagger может закэшировать экземпляр, но ничего не обещает и не берёт блокировку. Подходит для объектов без состояния, которые довольно дорого создавать и которые можно, но не обязательно, переиспользовать.
У метода @Provides нет скоупа, значит, это рецепт, а не экземпляр: Dagger выполняет его заново для каждой точки внедрения, и три потребителя получают три клиента.
Исправление: добавить скоуп компонента, в который установлен модуль: здесь @Provides @Singleton. Скоуп, не принадлежащий этому компоненту, даёт ошибку компиляции.
Чем это вредно: у каждого OkHttpClient свой пул соединений, свои потоки диспетчера и свой кэш, поэтому соединения не переиспользуются, а TLS-рукопожатия повторяются. Две базы Room над одним файлом держат отдельные трекеры инвалидации и не видят записей друг друга; два DataStore над одним файлом бросают IllegalStateException: There are multiple DataStores active for the same file.
**@Binds тоже**: ставьте скоуп на метод @Binds или на класс реализации; биндинг интерфейса без скоупа на класс без скоупа каждый раз даёт новый объект.
Диагностика: логировать System.identityHashCode(this) в init или посчитать экземпляры в heap dump.
Не перегибайте в обратную сторону, помечая @Singleton всё подряд: мапперы, форматтеры и use case без состояния дешёвые и должны умирать вместе с владельцем.
@ViewModelScoped значит «один экземпляр на ViewModel», а не «один на все ViewModel»: каждый @HiltViewModel получает свой ViewModelComponent, поэтому ProductViewModel и CartBadgeViewModel держат два разных объекта CartState, и бейдж следит за пустым.
Почему: Hilt создаёт новый ViewModelComponent для каждой ViewModel, а @ViewModelScoped-биндинг кэшируется в этом компоненте. Его разделяют только зависимости одной ViewModel: её use case и хелперы.
Почему всё собирается: каждая ViewModel просит тип, который граф умеет создать, так что граф корректен. Неверно только предполагаемое время жизни.
Исправление: повесить на общее состояние @ActivityRetainedScoped. ActivityRetainedComponent является родителем всех ViewModelComponent этой Activity, он переживает поворот экрана и уничтожается, когда Activity действительно завершается. Если корзина должна пережить Activity, используйте @Singleton, а лучше храните её в Room и наблюдайте как Flow.
**Не @ActivityScoped**: ActivityComponent не предок ViewModelComponent, поэтому Dagger отклонит такой биндинг при сборке; а даже там, где он виден, объект пересоздавался бы при каждом повороте.
Как поймать в следующий раз: залогировать System.identityHashCode(cart) в обоих блоках init: два разных числа означают два экземпляра. На ревью считайте багом «@ViewModelScoped-состояние, которое читает другой экран».
Сборка падает с [Dagger/MissingBinding]: ViewModelComponent находится под ActivityRetainedComponent, который переживает любую отдельную Activity, поэтому @ActivityContext ему взять неоткуда. Этой ошибкой Hilt предотвращает утечку. Вместо этого берите @ApplicationContext Context.
Исправление: принимать @ApplicationContext Context. Он привязан в SingletonComponent, виден во всех компонентах, и именно он нужен SharedPreferences, Room, DataStore и WorkManager. Application тоже можно внедрить без квалификатора.
Почему это важно: ViewModel переживает поворот экрана. Удерживая Activity, она держала бы в памяти уничтоженную Activity со всем деревом вью.
**Где @ActivityContext есть**: в ActivityComponent, FragmentComponent и ViewComponent: у объектов, которые живут не дольше Activity.
Обратная ловушка: у контекста приложения нет window token и темы Activity. Показ диалога с ним падает с WindowManager$BadTokenException, а инфлейт вью с темой Activity даёт неверные стили. Всё, что рисует, получает Activity.
Утечку, которую Dagger не поймает, пишут руками: @Singleton с сеттером, сохраняющим Activity. Граф выглядит нормально, а Activity удерживается всё время жизни процесса.
@Qualifier говорит Dagger, какой из нескольких биндингов одного типа вы имеете в виду, а мультибиндинг собирает много биндингов в один Set или Map.
Квалификаторы
Одна и та же аннотация ставится и на биндинг, и на точку внедрения.
Типичные случаи: авторизованный и обычный OkHttpClient, два экземпляра Retrofit с разными base URL, @IoDispatcher и @DefaultDispatcher.
Предпочитайте свою аннотацию вместо @Named("io"): опечатка в строке всплывает как отсутствующий биндинг с непонятным именем, а опечатка в имени аннотации просто не скомпилируется.
Мультибиндинги
@IntoSet добавляет биндинг в Set<T>; @IntoMap с @StringKey или @ClassKey добавляет его в Map<K, V>.
Внедряйте коллекцию как Set<@JvmSuppressWildcards T>, иначе дженерики Kotlin превращают её в Set<? extends T>, и Dagger не находит совпадения.
Именно это делает фича-модули составными: каждая фича добавляет свои маршруты, пункты настроек или инициализаторы старта, а приложение получает их объединение, не импортируя ни одну фичу.
Захардкоженный диспетчер оказывается замаскированной зависимостью: внедрите CoroutineDispatcher с квалификатором, чтобы тест мог передать TestDispatcher и управлять временем.
Заведите по квалификатору на диспетчер (@IoDispatcher, @DefaultDispatcher) и предоставьте их из модуля в SingletonComponent.
Принимайте CoroutineDispatcher в конструкторе и оставьте withContext(io) внутри репозитория: вызывающему коду не нужно знать, что работа уходит с главного потока.
В тесте передайте StandardTestDispatcher(testScheduler) из runTest. Dispatchers.IO работает на настоящих потоках с настоящими часами, поэтому виртуальное время runTest на него не действует, и каждый delay или бэкофф ретрая сжигает реальные секунды.
Главный диспетчер внедрять не нужно: его покрывает Dispatchers.setMain(...) из kotlinx-coroutines-test.
Работе, которая должна пережить экран, например отложенной загрузке, нужен CoroutineScope на весь процесс, построенный с SupervisorJob(). Предоставьте его как @Singleton со своим квалификатором, а не создавайте CoroutineScope(Dispatchers.IO) внутри класса, где ни один тест не сможет его подменить или отменить.
Значение, известное только в рантайме, не может быть биндингом, поэтому оно приходит через SavedStateHandle или через assisted-фабрику.
SavedStateHandle: первый ответ для ViewModel
Hilt передаёт его в конструктор любой @HiltViewModel.
Аргументы навигации и extras Activity попадают туда автоматически; с типобезопасными маршрутами savedStateHandle.toRoute<OrderRoute>() возвращает уже разобранные аргументы.
Он переживает смерть процесса, а аргумент фабрики не переживает.
Assisted injection для всего остального
Пометьте конструктор @AssistedInject, а параметры времени выполнения отметьте @Assisted; остальное по-прежнему приходит из графа.
Объявите интерфейс @AssistedFactory с одним методом, принимающим assisted-значения. Dagger его реализует, вы внедряете фабрику.
Двум assisted-параметрам одного типа нужны ключи: @Assisted("from"), @Assisted("to").
Для ViewModel, чьё значение не является аргументом навигации, используйте @HiltViewModel(assistedFactory = ...) и hiltViewModel<VM, VM.Factory> { it.create(id) } в Compose.
Избегайте недособранного объекта с вызовом init(orderId) после создания и id, спрятанного в синглтон, чтобы ViewModel прочитала его обратно. Оба способа прячут зависимость и ломаются, как только открыты два таких экрана одновременно.
Оба откладывают создание до вызова get(); Provider разрешает биндинг при каждом вызове, а Lazy создаёт объект один раз при первом вызове и кэширует результат.
**Provider<T>.get(): без скоупа это новый экземпляр на каждый вызов, а со скоупом это единственный экземпляр скоупа. Нужен, когда требуется несколько экземпляров или значение меняется со временем.
Lazy<T>.get(): мемоизация на точку внедрения. Подходит для дорогого объекта, который класс часто не трогает: база данных, загрузчик картинок, тяжёлый SDK.
Биндинг не меняется**: вы меняете только тип параметра на Provider<Foo> или Lazy<Foo>; Dagger поддерживает оба для любого биндинга. dagger.Lazy не то же самое, что by lazy из Kotlin.
Стоимость старта: внедрение Lazy для дорогих синглтонов переносит их создание с запуска приложения на первое использование.
Циклы: раз ссылка разрешается в момент вызова, Provider может разорвать цикл зависимостей. Это крайняя мера: цикл обычно означает проблему в дизайне.
AuthInterceptor читает AuthToken один раз, в конструкторе, а @Singleton OkHttpClient создаёт этот интерсептор тоже один раз. Unscoped значит «новый экземпляр на каждое внедрение», а не на каждое использование, поэтому токен застывает в том значении, которое было в момент создания клиента.
Почему: unscoped @Provides выполняется каждый раз, когда что-то внедряется, а не каждый раз, когда значение читают. Синглтон-клиент внедряется один раз, значит authToken() вызывается один раз, и долгоживущий объект держит короткоживущее значение всё время жизни процесса («захваченная зависимость», captive dependency).
Почему Dagger молчит: проверка скоупов запрещает биндингу только зависеть от более узкого скоупа. Unscoped-биндинг допустим в любом компоненте, поэтому сборка ничего не ловит.
Исправление: внедрить Provider<AuthToken> и вызывать get() внутри intercept, тогда биндинг резолвится на каждый запрос. Lazy<AuthToken> не поможет: он кэширует первое значение точно так же, как конструктор.
Ещё чище: убрать биндинг AuthToken, внедрять TokenStore и вызывать current() на каждый запрос. Снимку изменяемого состояния вообще не место в графе как биндингу.
Как поймать в следующий раз: тест с фейковым TokenStore и MockWebServer: обновить токен между двумя вызовами и проверить, что takeRequest().getHeader("Authorization") изменился.
Цикл такой: OkHttpClient -> AuthInterceptor -> TokenRefresher -> AuthApi -> OkHttpClient; разорвать его нужно отдельным клиентом с квалификатором для AuthApi без auth-интерсептора, а не прятать петлю за Provider.
Почему падает: Dagger отвергает цикл при компиляции, потому что ни один из четырёх невозможно создать первым.
Быстрое исправление: внедрить в интерсептор Provider<TokenRefresher> (или Lazy). TokenRefresher будет получен при первом запросе, когда клиент уже существует, и код скомпилируется. Но запрос обновления теперь идёт через тот же клиент и тот же интерсептор, так что неудачное обновление может уйти в рекурсию.
Правильное исправление: эндпоинту обновления не нужен токен, который он обновляет. Дайте AuthApi отдельный клиент с квалификатором и без auth-интерсептора. Цикл исчезает, потому что дизайн перестаёт врать о том, кто от кого зависит.
Рядом: для обновления по 401 Authenticator из OkHttp вызывается только когда сервер отклонил запрос, и обычно это лучшая точка, чем интерсептор.
Считайте цикл зависимостей прежде всего сигналом о дизайне. Provider прячет цикл, но не убирает его.
У Hilt есть отдельная интеграция для воркеров и запасной выход @EntryPoint для всего остального.
WorkManager
Добавьте androidx.hilt:hilt-work и его компилятор, пометьте воркер @HiltWorker и используйте @AssistedInject с @Assisted Context и @Assisted WorkerParameters; ваши собственные зависимости приходят из графа.
Пусть Application реализует Configuration.Provider и строит конфигурацию с внедрённой HiltWorkerFactory.
Уберите из манифеста инициализатор WorkManager по умолчанию; иначе он инициализируется сам через App Startup с фабрикой по умолчанию, и ваши воркеры не смогут создаться.
Entry points
Объявите интерфейс с @EntryPoint и @InstallIn(SingletonComponent::class), по геттеру на каждый нужный биндинг.
Получите его через EntryPointAccessors.fromApplication(context, MyEntryPoint::class.java).
Это путь для ContentProvider: @AndroidEntryPoint провайдеры не поддерживает, потому что они создаются до Application.onCreate().
Он же подходит для классов, которые за вас создаёт библиотека, и для легаси-кода, который вы переводите экран за экраном.
Entry point в глубине кода приложения превращается в замаскированный service locator. Используйте его на границе, а дальше передавайте зависимости через конструкторы.
Dagger дошёл до типа, для которого у него нет рецепта, а цепочка запросов под сообщением показывает, какой компонент его запросил и через кого.
Читайте цепочку снизу вверх. Последние строки называют компонент, который выполнял поиск (SingletonC, ActivityRetainedC, ViewModelC); каждая строка выше уходит на один параметр конструктора глубже, вплоть до типа, который Dagger не смог собрать.
Частые причины, от самой частой
Его никто не биндит. Если класс ваш, добавьте @Inject constructor. Если это интерфейс или сторонний тип, нужен @Binds для реализации или @Provides.
Несовпадение квалификатора. Потребитель просит @AuthClient OkHttpClient, а модуль предоставляет обычный. Ошибка печатает квалификатор: сравните его точно.
Не тот компонент. Модуль установлен в @InstallIn(ActivityComponent::class), а потребитель живёт в SingletonComponent. Биндинги видны вниз по иерархии, но не вверх: перенесите модуль или потребителя.
Модуля нет в сборке. Биндинги фича-модуля попадают в граф, только если модуль приложения зависит от этого Gradle-модуля.
Соседние ошибки: один тип, привязанный дважды в одном компоненте, даёт [Dagger/DuplicateBindings]; Hilt-модуль без @InstallIn падает с сообщением, требующим эту аннотацию.
В unit-тестах Hilt не нужен: вызывайте конструкторы с фейками; в инструментальных и Robolectric-тестах используйте @HiltAndroidTest с @TestInstallIn, @UninstallModules или @BindValue, чтобы подменить биндинги.
Unit-тесты
Создавайте ViewModel или репозиторий напрямую с фейками. Так быстрее, и тест не зависит от DI-графа, и это ещё одна причина предпочитать внедрение через конструктор.
Инструментальные тесты и UI-тесты на Compose
Пометьте класс @HiltAndroidTest, добавьте HiltAndroidRule с order = 0, чтобы оно срабатывало раньше Compose-правила, и вызовите hiltRule.inject() в @Before.
**@TestInstallIn(components = [...], replaces = [...]) в тестовом source set подменяет модуль для всех тестов, которые его видят; это обычный выбор.
@UninstallModules(NetworkModule::class) на одном тестовом классе убирает модуль только для этого класса.
@BindValue** на поле теста кладёт в граф именно этот экземпляр, так что тест может передать фейк и потом проверить его. В Kotlin добавьте @JvmField (или используйте lateinit var): привязываемое поле не может быть private.
Кастомный AndroidJUnitRunner должен создавать HiltTestApplication; в Robolectric его задают через @Config(application = HiltTestApplication::class).
Для Compose разместите экран в пустой ComponentActivity с @AndroidEntryPoint, объявленной в debug-манифесте, и управляйте им через createAndroidComposeRule.
Hilt строит и проверяет весь граф при компиляции; Koin разрешает зависимости в рантайме из реестра: это проще настроить, но отсутствующий биндинг переезжает из сборки в работающее приложение.
Hilt
Кодогенерация через KSP: отсутствующий, задублированный или неправильно заскоупленный биндинг роняет сборку.
Никакой рефлексии и поиска в рантайме; родная интеграция с ViewModel, Navigation, Compose, WorkManager и тестами.
Больше аннотаций, которые надо выучить, и время сборки растёт вместе с графом. Только Android.
Koin
Модули описываются обычными Kotlin-функциями (single, factory, viewModel); генерировать нечего.
Разрешение в рантайме: отсутствующее определение бросает NoDefinitionFoundException, когда экран впервые открывается. Закройте это тестом с verify() из koin-test или используйте Koin Annotations с KSP и опцией KOIN_CONFIG_CHECK, чтобы падала сборка.
Мультиплатформа: те же модули работают в общем KMP-модуле, куда Hilt не попасть.
Вызов by inject() внутри класса относится к стилю service locator; передача get() в конструкторы внутри модуля оставляет сами классы пригодными для DI.
Выбор
Приложение только под Android, большой граф, много фича-модулей → Hilt ради гарантий на этапе компиляции.
Общий KMP-код → Koin, kotlin-inject или Metro.
Небольшое приложение или прототип → Koin или просто ручной DI.
Если каждый класс получает зависимости через конструктор, смена фреймворка позже затронет только связывание.
Dagger пишет фабрику на каждый биндинг и реализации компонентов, а Gradle-плагин Hilt переписывает каждый класс с @AndroidEntryPoint так, чтобы он наследовал сгенерированный базовый класс Hilt_, который и выполняет внедрение. В рантайме ничего не разрешается через рефлексию.
Что попадает в build/generated
Foo_Factory на каждый @Inject constructor и Foo_MembersInjector на каждый класс с внедряемыми полями.
App_HiltComponents с вложенным компонентом на каждый уровень: SingletonC, ActivityRetainedC, ActivityC, FragmentC, ViewModelC. Биндинги со скоупом обёрнуты в DoubleCheck (double-checked locking); @Reusable получает SingleCheck, который кэширует без блокировки.
Hilt_MainActivity, который регистрирует OnContextAvailableListener и выполняет внедрение, когда тот срабатывает (внутри super.onCreate()). Вы пишете MainActivity : AppCompatActivity(), а байткод-трансформация плагина подменяет суперкласс, поэтому вручную наследоваться от сгенерированного класса не нужно.
Почему так устроено
Отсутствующий или задублированный биндинг становится ошибкой сборки, а не крашем на экране, который никто не тестировал.
Нет рефлексии: R8 видит весь граф и может его инлайнить и вырезать, а старт приложения не тратит время на сборку контейнера.
Цена: время сборки (модуль приложения перегенерируется при изменении любого биндинга) и граф, зафиксированный при компиляции, поэтому значениям времени выполнения нужны @AssistedInject или @BindsInstance.
ActivityRetainedComponent переживает поворот экрана, потому что Hilt хранит его во ViewModel внутри ViewModelStore Activity.
Сабкомпонент генерируется внутри родителя и наследует все его биндинги; зависимость компонента представляет собой отдельно сгенерированный компонент, который видит только то, что родитель явно открывает через provision-методы.
Сабкомпоненты
Объявляются через @Subcomponent; родитель регистрирует их через @Module(subcomponents = ...), открывает их фабрику, а сгенерированный класс вложен в реализацию родителя.
Дочерний компонент видит все биндинги родителя и может добавлять элементы в его мультибиндинги Set и Map.
Родитель должен знать о потомке в момент генерации кода, поэтому оба генерируются вместе, в модуле, которому принадлежит корень. Всё дерево Hilt устроено именно так.
Зависимости компонентов
@Component(dependencies = [CoreDeps::class]): фабрика дочернего компонента принимает экземпляр CoreDeps; это может быть компонент или любой интерфейс, часто @EntryPoint из Hilt.
Через границу проходят только provision-методы. Это бойлерплейт, но и явный, проверяемый на ревью API между модулями.
Каждый компонент генерируется в своём Gradle-модуле, поэтому граф фичи пересобирается без перегенерации графа приложения.
Мультибиндинги через границу не объединяются.
Выбор
Вложенные времена жизни в одном графе приложения → сабкомпоненты, то есть на практике Hilt.
Dynamic feature-модули, которые базовое приложение не видит при компиляции, и SDK со своим графом → зависимости компонентов. Это задокументированный способ использовать Dagger в dynamic feature-модуле Hilt-приложения.
@Singleton переживает пользовательскую сессию: репозиторий, закэшировавший заказы пользователя A, оказывается тем же самым объектом, который получает пользователь B, потому что ничто в графе не привязано к факту входа.
Два способа исправить
Компонент сессии. @DefineComponent(parent = SingletonComponent::class) со своим @SessionScope и @DefineComponent.Builder, принимающий User через @BindsInstance. Менеджер-@Singleton создаёт компонент при входе и отпускает его при выходе, и все объекты со скоупом сессии становятся мусором. Подвох: @AndroidEntryPoint не умеет внедрять из кастомного компонента, поэтому его объекты достают через @EntryPoint, который вы вызываете явно.
Явная очистка. Всё остаётся @Singleton, а логаут вызывает clear() у каждого кэша. Механизмов гораздо меньше, но это список, который кто-то должен не забыть пополнить, и следующий кэш будет утекать данными пользователя, пока тестировщик не заметит.
Вариант, который убирает проблему
Не держите состояние пользователя в памяти: привязывайте таблицы Room к id пользователя, читайте сессию как Flow из DataStore и дайте изменению пройти через слой данных. Логаут становится одной записью, и инвалидировать нечего.
Правило в основе: скоуп служит утверждением о времени жизни. Если данные принадлежат сессии, @Singleton обещает, что они проживут столько же, сколько процесс, и Dagger это обещание сдержит.
Hilt собирает все @InstallIn-модули и entry points из classpath приложения и генерирует все компоненты один раз, в модуле приложения, поэтому по мере роста графа этот модуль становится узким местом сборки.
Что из этого следует
Любой библиотечный модуль может объявлять биндинги; достаточно добавить его в зависимости приложения, чтобы они попали в граф. Ничего не регистрируется вручную.
Изменение биндинга где угодно инвалидирует генерацию Hilt в модуле приложения, поэтому инкрементальные сборки замедляются с ростом графа. Держите модуль приложения тонким, в основном связывание, чтобы после него перекомпилировалось мало.
Запускайте hilt-compiler через KSP. kapt сначала генерирует Java-стабы для каждого модуля, и это добавляет медленный шаг в каждую сборку.
Как это структурировать
Фича-модули открывают интерфейсы и биндят реализации в собственных Hilt-модулях, так что потребители компилируются только против интерфейса.
Следите, куда устанавливаете: @InstallIn(SingletonComponent::class) в фича-модуле делает биндинг глобальным для процесса. Если биндинг относится к экрану, выбирайте ViewModelComponent или другой более узкий компонент.
Общие фейки положите в модуль :testing с @TestInstallIn, чтобы тесты всех модулей получали одни и те же подмены.
Dynamic feature-модули вообще не могут добавлять биндинги в граф Hilt; они используют зависимости компонентов.
Hilt работает только на Android. Общий KMP-модуль использует внедрение через конструктор, Koin, kotlin-inject или Metro, а Hilt связывает результат на границе с Android.
Издержки Dagger: время сборки, кодогенератор, ориентированный на Java, и слой Hilt только для Android; Metro (плагин компилятора Kotlin) и kotlin-inject (KSP) сохраняют проверку на этапе компиляции, но работают внутри тулчейна Kotlin и поддерживают мультиплатформу.
Что болит в Dagger
Время сборки: обработка аннотаций плюс компиляция сгенерированного Java, а с Hilt ещё и полная перегенерация в модуле приложения при изменении любого биндинга. kapt делает хуже; Dagger под KSP помогает, но по-прежнему генерирует Java.
Сообщения об ошибках: длинные цепочки запросов, которые нужно научиться читать.
Трение с Kotlin: @JvmSuppressWildcards на дженерик-мультибиндингах, модули-object и companion object.
Нет KMP: общий модуль не может использовать ни Dagger, ни Hilt.
Альтернативы
Anvil: расширение Dagger от Square, которое автоматически объединяет модули с @ContributesTo и @ContributesBinding. Anvil объявлен устаревшим: работает только с K1, а Square перевёл свой Android-код на Metro.
Metro: плагин компилятора Kotlin с семантикой как у Dagger и агрегацией в стиле Anvil. Он не генерирует исходники, которые потом надо компилировать, работает в KMP и поддерживает аннотации Dagger (интероп), чтобы мигрировать постепенно.
kotlin-inject: на KSP, Kotlin-first, мультиплатформенный; kotlin-inject-anvil добавляет объединение контрибуций и в режиме поддержки.
Когда переезжать
У приложения только под Android на Hilt с KSP срочной причины нет: вы потеряете @HiltViewModel, @HiltWorker и тестовые API Hilt и будете связывать всё это сами. Переезд оправдан, когда граф общий с KMP или когда DI-генерация доминирует в профилях сборки. Мигрируйте модуль за модулем и замеряйте до и после.