Dependency Injection: Hilt, Dagger и тестируемость

Про DI спрашивают всех, кто претендует выше junior. И почти всегда первым вопросом идёт не «как», а «зачем».

Dependency Injection — приём, при котором объект не создаёт свои зависимости сам, а получает их извне. Это делает его независимым от способа создания зависимостей и, как следствие, тестируемым.

Вопрос 1: «Зачем нужен DI? Разве нельзя просто создать объект внутри?»

Что проверяет интервьюер

Может ли кандидат объяснить пользу без слова «фреймворк». Ответ «чтобы использовать Hilt» — не ответ.

Развёрнутый ответ

Сравним два варианта одного класса:

// БЕЗ DI: класс сам решает, откуда берутся зависимости
class ProfileViewModel : ViewModel() {
    private val repo = ProfileRepository(
        api = RetrofitClient.create(),
        db = AppDatabase.getInstance()
    )
}

// С DI: зависимости приходят снаружи
class ProfileViewModel(
    private val repo: ProfileRepository
) : ViewModel()

Первый вариант нельзя протестировать без реальной сети и реальной базы: конструктор жёстко зашит. Второй тестируется тривиально — в тест передаётся fake-репозиторий. Плюс исчезает дублирование настройки Retrofit по всему проекту и появляется контроль над временем жизни объектов: база данных создаётся один раз, а не в каждом экране.

Формулировка, которую стоит произнести: DI — это применение принципа инверсии зависимостей. Класс зависит от абстракции, а решение о конкретной реализации принимает внешний код — вручную или через фреймворк.

Вопрос 2: «Чем Hilt отличается от Dagger, а Dagger от Koin?»

Dagger 2HiltKoin
Когда собирается графкомпиляция, кодогенерациякомпиляция, поверх Daggerрантайм
Ошибки конфигурациина этапе сборкина этапе сборкипри запуске
Настройкакомпоненты пишутся рукамиготовые компоненты под Androidминимальная, DSL
Влияние на время сборкизаметноезаметноенет

Ключевое отличие Dagger и Hilt от Koin: Dagger — генерация кода на этапе компиляции. Забытая зависимость — ошибка сборки, а не падение у пользователя. Koin формально не DI-фреймворк, а service locator: зависимости разрешаются в рантайме по типу, поэтому опечатка в графе всплывёт только при запуске экрана.

Hilt — это надстройка над Dagger, которая избавляет от ручного описания компонентов: он уже знает про Application, Activity, Fragment, ViewModel и их времена жизни.

@HiltAndroidApp
class App : Application()

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

    @Provides
    @Singleton
    fun provideDb(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()

    @Provides
    fun provideDao(db: AppDatabase): ArticleDao = db.articleDao()
}

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

    // @Binds для случая «интерфейс → реализация»: без тела метода, дешевле @Provides
    @Binds
    abstract fun bindArticleRepository(impl: ArticleRepositoryImpl): ArticleRepository
}

@HiltViewModel
class FeedViewModel @Inject constructor(
    private val repo: ArticleRepository
) : ViewModel()

@AndroidEntryPoint
class FeedFragment : Fragment()

Разница @Binds и @Provides — частый уточняющий вопрос. @Provides — обычный метод, который сам создаёт объект; нужен, когда объект строится (Retrofit, Room). @Binds — абстрактный метод, который просто говорит «когда просят интерфейс, дай вот эту реализацию»; кода генерируется меньше.

Про scope-аннотации: @Singleton — один объект на весь процесс, @ActivityRetainedScoped — переживает поворот, @ViewModelScoped — живёт вместе с ViewModel, @ActivityScoped — вместе с экраном. Отсутствие аннотации означает «новый объект на каждое внедрение», и это разумный дефолт: скоуп нужен там, где объект дорогой или обязан быть общим.

Вопрос 3: «Как DI влияет на тестируемость?»

Прямо: без внедрения зависимостей юнит-тест придётся заменять инструментальным. С внедрением почти всё тестируется на JVM, за миллисекунды.

class FakeArticleRepository(
    private val articles: List<Article> = emptyList()
) : ArticleRepository {
    override fun observeArticles() = flowOf(articles)
    override suspend fun refresh() = Unit
}

@Test
fun `состояние становится Success после загрузки`() = runTest {
    val vm = FeedViewModel(FakeArticleRepository(listOf(article)))
    assertEquals(UiState.Success(listOf(article)), vm.state.value)
}

Отдельно стоит упомянуть внедрение диспетчеров: если ViewModel и репозиторий получают CoroutineDispatcher через конструктор, в тесте подставляется StandardTestDispatcher, и тесты перестают зависеть от реального времени. Хардкод Dispatchers.IO внутри класса делает тест недетерминированным.

Типичные ошибки кандидатов

  • Отвечают «DI нужен, чтобы не писать new» — цель не в этом, а в замене реализаций и контроле времени жизни.
  • Используют внедрение в поля (@Inject lateinit var) там, где возможен конструктор. Конструкторное внедрение делает зависимости явными и не оставляет объект в полусобранном состоянии.
  • Вешают @Singleton на всё подряд — это утечки и лишняя память.
  • Не различают DI-фреймворк и service locator, называя Koin «тем же Dagger, только проще».
  • Внедряют Context туда, где нужен ресурс: это тянет Android в слои, которые должны быть чистыми.

Как ответить кратко (20–30 секунд)

«DI — это когда класс получает зависимости снаружи, а не создаёт их сам. Даёт две вещи: подмену реализаций в тестах и контроль времени жизни объектов. Dagger и Hilt строят граф на этапе компиляции через кодогенерацию, поэтому ошибка конфигурации — это ошибка сборки, а не падение у пользователя; Koin разрешает зависимости в рантайме и по сути является service locator. Hilt — надстройка над Dagger с готовыми компонентами под Android: @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel. @Provides использую, когда объект надо собрать, @Binds — когда нужно связать интерфейс с реализацией. Предпочитаю конструкторное внедрение и внедряю диспетчеры, чтобы тесты были детерминированными.»

Проверьте себя
1. В чём принципиальное отличие Dagger/Hilt от Koin?
ADagger работает только с Java, а Koin — с Kotlin
BDagger строит граф зависимостей на этапе компиляции, поэтому ошибки конфигурации ловятся при сборке; Koin разрешает зависимости в рантайме
CKoin поддерживает scope-аннотации, а Dagger нет
DDagger не умеет внедрять зависимости в ViewModel
2. Когда в Hilt-модуле применяют @Binds вместо @Provides?
Aкогда объект нужно собрать вручную с параметрами, например Retrofit
Bкогда нужно связать интерфейс с его конкретной реализацией без создания объекта вручную
Cкогда зависимость должна быть @Singleton
Dкогда зависимость внедряется в Composable-функцию
3. Почему CoroutineDispatcher рекомендуют внедрять, а не писать Dispatchers.IO прямо в классе?
ADispatchers.IO недоступен в модулях без Android-зависимости
Bвнедрённый диспетчер можно заменить на TestDispatcher, и тесты станут детерминированными
Cвнедрение диспетчера ускоряет выполнение корутин
Dиначе Hilt не сможет создать ViewModel