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 2 | Hilt | Koin | |
| Когда собирается граф | компиляция, кодогенерация | компиляция, поверх 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— когда нужно связать интерфейс с реализацией. Предпочитаю конструкторное внедрение и внедряю диспетчеры, чтобы тесты были детерминированными.»