Всем привет! Меня зовут Евгений, и я — Android-разработчик.

Работая над очередным многомодульным проектом, я снова и снова сталкивался с неудобством и, не побоюсь этого слова, ужасом настройки Dependency Injection. И каждый раз я задавался одним и тем же вопросом: почему до сих пор не существует DI-фреймворка, который бы комфортно работал именно в многомодульной среде?

Так, чтобы каждый feature-модуль становился по-настоящему независимым.

“Но ведь есть Dagger!” — скажете вы. Да, есть. Но, на мой взгляд, он не делает модули по-настоящему независимыми. Чтобы все заработало, вам все равно нужно создавать компонент в главном :app модуле, который связывает все зависимости вместе. А это значит, что каждый feature-модуль неявно зависит от общей конфигурации и не является полностью автономным. (Сейчас я знаю, что у Dagger есть Subcomponents, а у Koin — изолированные Koin-инстансы! Но на момент ресерча я этого не нашёл.)

Недавно передо мной встала еще более интересная задача: сделать модуль/библиотеку, которая будет собираться и подключаться зависимостью к нескольким совершенно разным проектам. И тут же возник главный вопрос: как быть с Dependency Injection?

Совсем без него не хотелось — это сложно, больно, нарушает все законы физики и вообще не по фэншую.

Какие есть варианты? Можно было бы взять Koin, но это значит навязать его всем, кто будет использовать мою библиотеку. Это плохой подход, ведь мы не можем заставлять другие проекты переходить на наш DI-фреймворк.

Но главная техническая проблема глубже. Все популярные DI-фреймворки (Dagger, Hilt, Koin) стартуют из класса Application. Этого класса нет в обычном Android-модуле, а значит, у библиотеки нет своей точки для инициализации DI. Попытка же запустить в одном приложении второй, отдельный DI-контейнер — один для приложения, другой для библиотеки — неизбежно приведет к крэшу.

Так и родилась идея: а что, если написать свой DI? Я сформулировал для себя несколько ключевых требований к будущему инструменту:

  • Он не должен зависеть от класса Application.

  • Он должен позволять каждому модулю или библиотеке работать со своим, полностью независимым DI-контейнером.

  • Он должен требовать минимум ручных объявлений зависимостей. В идеале — вообще никаких. (Koin, прости, но это камень в твой огород).

  • Он должен ловить ошибки конфигурации на этапе сборки, а не через крэш у пользователя.

Если мы не хотим писать этот код сами, остается только один вариант — его должен написать кто-то за нас. И я знаю только один (относительно) бесплатный способ это сделать — KSP (Kotlin Symbol Processing).

Итоговый набор требований к первой версии:

  • Точка входа: простой способ, как inject(), чтобы получить необходимый объект в любом месте приложения.

  • Разрешение по типу: возврат объекта и по его собственному типу, и по интерфейсу, который он реализует.

  • Поддержка Singleton-ов: механизм для хранения и переиспользования единственного экземпляра.

  • Работа с Context: явный, предсказуемый способ передать Context внутрь Android-специфичных зависимостей.

  • Изоляция: у каждого модуля/библиотеки — свой контейнер, не путающийся с чужими.

  • Проверка на этапе компиляции: если граф зависимостей не собирается — сборка должна упасть с понятной ошибкой, а не тихо оставить дырку, которая рванёт в runtime.

Работу с ViewModel мы рассмотрим как следующий шаг.

Границы магии: случай с Room и Retrofit

Однако практически сразу стало понятно, что моя мечта о “DI совсем без кода” сталкивается с реальностью.

В стандартной ситуации, когда у нас есть интерфейс и его единственная реализация, генератор может сам догадаться, как создать объект. Но как быть с такими библиотеками, как Room или Retrofit? Мы описываем только интерфейс (например, @Dao или @GET), а конкретную реализацию создает сама библиотека во время сборки. Мы не можем просто написать MyApiImpl() для создания экземпляра.

Для таких случаев пришлось предусмотреть отдельный механизм.

Как это работает: объявление зависимостей

Стандартные компоненты: @KoGenComponent

Для того чтобы наш DI-контейнер узнал о ваших обычных классах (репозиториях, сервисах, юзкейсах), достаточно просто пометить нужный класс аннотацией @KoGenComponent. Параметр singleton указывает, должен ли объект переиспользоваться при каждом запросе.

@KoGenComponent(singleton = true)
class UserProfileServiceImpl(
    private val source: UserProfileSource,
) : UserProfileService {
    // ...
}

KSP-процессор находит все классы, отмеченные этой аннотацией, и на их основе генерирует enum-справочник — сердце нашего DI-контейнера:

enum class KoGenComponentsImpl(
    override val singleton: Boolean,
) : kz.evko.kogen_di.injector.KoGenComponents {
    common_data_service_AuthServiceImpl(true),
    common_data_service_BonusHistoryServiceImpl(true),
    // ...

    override fun getComponentObject(): Any = when (this) {
        common_data_service_AuthServiceImpl ->
            common.data.service.AuthServiceImpl(source = inject())
        common_data_service_BonusHistoryServiceImpl ->
            common.data.service.BonusHistoryServiceImpl(/* ... */)
    }
}

А отдельно — фабрика, которая знает, какой тип каким элементом enum-а обслуживается:

class KoGenComponentsFactoryImpl : kz.evko.kogen_di.injector.KoGenComponentsFactory() {
    override fun createComponentsMap(): Map<Class<*>, kz.evko.kogen_di.injector.KoGenComponents> = mapOf(
        common.data.service.AuthServiceImpl::class.java to KoGenComponentsImpl.common_data_service_AuthServiceImpl,
        common.data.service.AuthService::class.java to KoGenComponentsImpl.common_data_service_AuthServiceImpl,
        // ...
    )
}

Небольшое признание: в первой версии я сравнивал типы по строкам (Class.simpleName + имя пакета) — и это было ошибкой. R8/ProGuard при обфускации релизной сборки переименовывает классы, и мой инжект молча ломался, без единого предупреждения на этапе компиляции — самое неприятное, что может случиться с DI. Переписал на сравнение по Class-идентичности (тот самый Map<Class<*>, KoGenComponents> выше) — теперь ключи в мапе живут своей жизнью независимо от того, как R8 переименовал сами классы.

Логика получения объекта теперь простая: когда запрашивается зависимость (например, по интерфейсу AuthService), контейнер ищет в этой мапе запись по Class, и возвращает нужный объект, учитывая его singleton-статус.

Сложные случаи: “Бины” (@KoGenBean)

А как быть с зависимостями, которые мы не можем просто создать через конструктор, например, из Retrofit или Room? Для этого я ввел понятие “Бина” (да, Spring рулит!).

@KoGenBean(singleton = true)
fun userProfileSource(
    context: Context,
    preferencesUtils: SharedPreferencesUtils,
): UserProfileSource {
    // ... логика создания объекта
}

Разрешение зависимостей

И в том, и в другом случае, если у создаваемого объекта есть свои зависимости в конструкторе (как UserProfileSource у UserProfileServiceImpl), DI-контейнер получает их рекурсивно, автоматически находя и подставляя все необходимое.

Точка входа: функция inject()

Нам нужна та самая “точка входа” — простая функция для получения нужной зависимости. Назовем ее inject().

Это inline-функция с reified-типом, что позволяет легко получить тип запрашиваемой зависимости (T::class.java). Внутри мы получаем доступ к нашему DI-контейнеру (KoGenScope) и просим его выдать компонент нужного типа:

inline fun <reified T> inject(): T {
    val reference = T::class.java

    return kz.evko.kogen_di.injector.KoGenScope.getScope(
        scopeId = "com.myawesome.project.di",
        beansFactoryClass = KoGenBeansFactoryImpl::class.java,
        componentsFactoryClass = KoGenComponentsFactoryImpl::class.java,
    ).run {
        if (reference == android.content.Context::class.java) {
            return@run this.applicationContext as T
        }
        return@run this.getComponent(reference) as T
    }
}

scopeId — это, собственно, реализация того требования “своя изолированная коробочка на модуль” из вступления: он равен пакету, в который сгенерирован код для конкретного модуля, и по нему кэшируется отдельный KoGenScope со своими фабриками бинов/компонентов. Два модуля, каждый со своим @KoGenComponent UserProfileService, никогда не увидят чужие реализации друг друга — у каждого свой scopeId, свой контейнер.

Второй генерируемый рядом с inject() метод — setApplicationContext(). Его нужно вызвать один раз, до первого inject<Context>() — обычно в Application.onCreate():

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        setApplicationContext(this)
    }
}

Забыли вызвать — получите ContextNotFoundException в runtime, а не тихий null. Раз это модуль/библиотека без своего Application, а не приложение — этот шаг вообще можно пропустить, если ничему из ваших зависимостей Context не нужен.

Проверка на этапе компиляции

Раз уж контейнер строится генератором, ему ничего не мешает проверить весь граф зависимостей до того, как приложение вообще запустится — а не ловить ComponentNotFoundException у пользователя в проде.

Сейчас KSP-процессор при сборке гоняет по всем @KoGenComponent/@KoGenBean/@KoGenViewModel два простых, но неприятных на практике класса ошибок:

  • Missing dependency — классу нужен тип, который никто не предоставляет:

    Missing dependency: 'UserProfileSource' is required by 'UserProfileServiceImpl' but is not provided.
    
  • Ambiguous dependency — тип реально кем-то запрошен, но предоставляют его сразу несколько кандидатов:

    Ambiguous dependency: Type 'ApiService' is required, but provided by multiple candidates: ApiServiceImpl, ApiServiceFakeImpl
    

Обе ошибки указывают на конкретный класс — ошибку видно и правится до первого запуска приложения. Признаюсь, довести эту проверку до вида, когда она не сходит с ума на реальном проекте, оказалось отдельным приключением: сначала она путала любой общий интерфейс (типа Serializable) с настоящей неоднозначностью, потом на время пропадала вовсе — сейчас репортит только то, что реально кем-то запрошено и реально пересекается.

Работа с ViewModel: третья точка входа

Ну и какой же DI в Android без полноценной работы с ViewModel? Это ещё одна и, пожалуй, самая частая точка входа в граф зависимостей — именно с ViewModel обычно начинается построение дерева объектов для конкретного экрана.

Для этого добавляем третью аннотацию, @KoGenViewModel, которой помечаем классы ViewModel. По схожей логике KSP-процессор создает enum со списком всех ViewModel в проекте.

Для получения ViewModel в Composable-экране — koGenViewModel():

@androidx.compose.runtime.Composable
inline fun <reified T : androidx.lifecycle.ViewModel> koGenViewModel(): T {
    val viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner = checkNotNull(
        androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner.current
    ) {
        "No ViewModelStoreOwner was provided"
    }
    return androidx.compose.runtime.currentComposer.run {
        androidx.compose.runtime.remember {
            val scope = kz.evko.kogen_di.viewModel.KoGenViewModelScope.getInstance(
                scopeId = "com.myawesome.project.di",
                reference = KoGenViewModelScopeImpl::class.java,
            )
            androidx.lifecycle.ViewModelProvider(
                store = viewModelStoreOwner.viewModelStore,
                factory = KoGenViewModelFactory(scope),
            )[T::class.java]
        }
    }
}

А для классического Fragment/ComponentActivity, где by — не декорация, а реально ленивый delegate:

class MyFragment : Fragment() {
    private val viewModel: MyScreenViewModel by koGenViewModel()
}

Оба варианта под капотом используют одну и ту же ViewModelProvider.Factory, которую тоже генерирует процессор:

class KoGenViewModelFactory(
    private val scope: kz.evko.kogen_di.viewModel.KoGenViewModelScope
) : androidx.lifecycle.ViewModelProvider.Factory {
    override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
        return scope.getViewModel(modelClass) as T
    }
}

ViewModel намеренно живёт в отдельном от inject() scope (KoGenViewModelScope, не KoGenComponentsFactory/KoGenBeansFactory) — чтобы получить нормальный Android lifecycle (переживает пересоздание конфигурации, привязан к своему owner-у), а не “plain instance/singleton” семантику остальных зависимостей.

Что мы получили в итоге?

На этом реализация готова. Всех поставленных вначале целей мы достигли:

  • DI-контейнер не нужно нигде инициализировать (кроме одного вызова setApplicationContext(), если вообще нужен Context).

  • Он изолирован по модулям через scopeId и без проблем используется в любой библиотеке или feature-модуле.

  • Нам практически не надо писать никакого кода для объявления зависимостей, за исключением “бинов” для внешних библиотек.

  • Ошибки конфигурации графа ловятся на сборке, а не в проде у пользователя.

Ограничения и важные замечания

  1. Код появляется после первой сборки. Функции inject(), koGenViewModel() и другие сгенерированные классы физически отсутствуют в коде, пока вы не соберете проект хотя бы раз. Не пугайтесь, если IDE будет “ругаться” на их отсутствие в новом модуле — просто соберите проект.

  2. Поддержка ViewModel — опциональна и раздельна. Compose-хелпер (includeViewModelInjector) и Fragment/ComponentActivity-delegate (includeFragmentInjector) включаются отдельно, по умолчанию оба выключены — чтобы модули-библиотеки, которым ViewModel не нужна, не тащили за собой лишние зависимости.

  3. KSP иногда “сходит с ума”. В редких случаях стандартное лечение — ./gradlew clean и пересборка.

Как установить

Библиотека опубликована на Maven Central, полный гайд по установке — в README репозитория (ссылка в конце статьи). Если совсем коротко: подключаете KSP-плагин и сверху — typed Gradle-плагин koGenDi { }, который сам подтягивает нужные версии рантайма и компилятора, так что от вас требуется буквально один блок настроек. Есть и вариант без лишнего плагина — через сырой ksp { arg(...) } — он тоже описан в README, для тех, кому не хочется подключать ещё один плагин.

Вместо заключения

Спасибо, что дочитали эту историю до конца!

KoGen Di родился из реальной боли в многомодульной разработке — и с тех пор заметно подрос: компиляционная валидация графа, изоляция по модулям, поддержка Fragment/ComponentActivity, а недавно — и собственный typed Gradle-плагин. Я уже перевёл на него несколько своих рабочих проектов, и количество рутинного DI-кода в них сократилось в разы. Раньше в открытом доступе было только демо-приложение; теперь открыт весь проект целиком — рантайм, KSP-процессор и Gradle-плагин.

Попробовать библиотеку, посмотреть исходники и прочитать документацию можно в репозитории на GitHub: https://github.com/EugenProg/KoGen-Di

Надеюсь, эта “магия” KSP сделает и вашу работу немного проще.