Перейти к содержанию

Основы DI

Dependency Injection (DI), или внедрение зависимостей, - это приём проектирования, при котором объект получает зависимости извне, а не создаёт и не ищет их самостоятельно. DI можно реализовать вручную или с помощью Hilt, Dagger, Koin и других фреймворков.

Зачем нужен DI?

DI делает зависимости явными, уменьшает связанность и упрощает тестирование. В Android объект может зависеть от репозиториев, API-клиентов, базы данных, DataStore, аналитики, диспетчеров корутин или feature flags. Если каждый класс сам создаёт следующий, логика сборки расползается по проекту, а реализации становится сложно заменять.

При DI классы описывают, что им требуется, а создание объектов переносится в composition root - место, где приложение собирает граф зависимостей. Это может быть ручной контейнер или DI-фреймворк.

DI не гарантирует хорошую архитектуру. Большое число параметров конструктора может указывать на лишние обязанности, а интерфейс полезен только тогда, когда задаёт осмысленную границу или предполагает несколько реализаций.

Внедрение через конструктор

Для классов, которыми управляет приложение, внедрение через конструктор - предпочтительный вариант:

class UserRepository(
    private val api: ApiService,
    private val dao: UserDao,
    private val ioDispatcher: CoroutineDispatcher
)

Зависимости видны в API класса, объект нельзя создать в некорректном состоянии, а в тестах легко передать fake-реализации:

val repository = UserRepository(
    api = FakeApiService(),
    dao = FakeUserDao(),
    ioDispatcher = StandardTestDispatcher(testScheduler)
)

В Hilt или Dagger аннотация @Inject constructor сообщает фреймворку, как создать класс. Provider нужен, например, для сторонних типов, builder API или нестандартной настройки.

Android сам создаёт такие классы, как Activity и Service, поэтому для их внедрения нужна интеграция с фреймворком. Runtime-значения, например ID документа, являются данными, а не зависимостями графа. Их передают через navigation state, метод или assisted factory.

Ручной DI и контейнеры

Для DI не обязательна библиотека. Простой контейнер может создавать общую инфраструктуру и предоставлять фабрики для объектов с меньшим временем жизни:

class AppContainer {
    private val api = createApiService()
    private val database = createDatabase()

    val userRepository = UserRepository(
        api = api,
        dao = database.userDao(),
        ioDispatcher = Dispatchers.IO
    )
}

Ручной DI прозрачен и подходит для небольших графов. По мере роста проекта увеличивается объём кода для фабрик, scope и интеграции с Android lifecycle. DI-фреймворки сокращают этот код и могут проверять граф.

DI и Service Locator

При DI зависимости передаются классу и видны в его API. При Service Locator класс сам запрашивает их из реестра:

class UserRepository {
    private val api = ServiceLocator.apiService
}

Локатор скрывает зависимости, связывает бизнес-код с глобальной инфраструктурой и усложняет изолированные тесты. Реестр может быть практичным на границе legacy-кода или внутри composition root, но feature-классам обычно не следует обращаться к нему напрямую.

Scope и время жизни объектов

Для binding без scope обычно создаётся новый экземпляр при каждом запросе. Binding со scope переиспользует один экземпляр внутри определённого контейнера или компонента. Поэтому время жизни компонента ограничивает период, в течение которого этот экземпляр может быть общим.

Scope выбирают по владельцу и требуемой идентичности объекта:

Зависимость Типичное время жизни
База данных, настроенный HTTP-клиент Приложение
Stateful-объект, общий для одного пользовательского сценария Flow или retained activity
Объект, общий внутри графа одного ViewModel ViewModel
Stateless mapper или use case Часто без scope

Scope оправдан, когда потребители должны разделять состояние или ресурс, для синхронизации нужен единый экземпляр либо измеренная стоимость создания действительно важна.

Почему не стоит делать всё singleton?

Singleton живёт в графе приложения и предоставляет один экземпляр несвязанным потребителям. Это подходит для действительно общей инфраструктуры, но опасно для изменяемого состояния экрана, временных кэшей, callback-ов и ссылок, зависящих от lifecycle.

Объект уровня приложения не должен удерживать Activity, Fragment или View. Если ему нужен Context, следует предпочесть application context. Избыточные singleton-ы также связывают тесты и сохраняют устаревшее состояние между пользователями или сценариями.

Практичный вариант по умолчанию - внедрение через конструктор и объекты без scope. Scope стоит добавлять только при реальной потребности в общем экземпляре и выбирать максимально узкую подходящую область.

Связанные темы