Всем привет! Меня зовут Базикалов Илья, я являюсь Андроид разработчиком в компании Broniboy. В нашем клиентском приложении мы используем библиотеку Koin для внедрения зависимостей. В своей статье я хочу вам показать, с какими проблемами мы столкнулись при работе с данной библиотекой и каким образом их решили (хоть и не полностью).

Koin довольно простой DI фреймворк, который позволяет быстро организовать внедрение зависимостей. Но по мере роста проекта, поддерживать граф зависимостей становится все труднее. Я уверен, что каждый, кто хоть раз использовал эту библиотеку, сталкивался с такой ситуацией:

factory {
 BasketPresenter(
   get(),
   get(),
   get(),
   get(),
   get(),
   get(),
   get(),
   get(),
   get(),
   get(),
 )
}

Чем больше подобных классов, тем больше становится проблем с поддержанием DI. Мы стали чаще сталкиваться с падениями в рантайме из-за невнимательного рефакторинга графа зависимостей. Помимо этого, становится проблематично ответить на 2 вопроса: что? и откуда?. Что идет на вход данному классу (в данном случае BasketPresenter)? Откуда эти зависимости приходят? Где объявлены? И объявлены ли вообще? Давайте шаг за шагом начнем исправлять ситуацию, чтобы ответить на эти вопросы стало легче. Начнем с использования именованных аргументов.

Именованные аргументы

Это самое простое решение, которое позволит нам при беглом просмотре модуля понять, какие зависимости используется тем или иным классом.

factory {
 BasketPresenter(
   basketRepository = get(),
   analytics = get(),
   appResources = get(),
   checkoutDelegate = get(),
   navigator = get(),
   authStateRepository = get(),
   resultsBuffer = get(),
   scheduler = get(),
   moneyAmountFormatter = get(),
   localRepository = get(),
 )
}

Уже лучше, хотя тут есть проблема. Имя аргумента не всегда корректно дает нам понять, какая зависимость требуется классу. Хотелось, чтобы вместо get() мы имели конкретный ключ, по которому всегда будет приходить связанный с этим ключом класс.

Qualifier

Qualifier - это фишка Koin, позволяющая объявить ключ, по которому будет вестись поиск класса в графе зависимостей. По сути своей - это интерфейс, в котором необходимо переопределить текстовое поле, являющееся тем самым ключом. Создавать отдельные реализации этого интерфейса не требуется, воспользуемся методом named, поставляемым вместе с Koin.

factory(named(BASKET_PRESENTER)) {
 BasketPresenter(
   basketRepository = get(named(BASKET_REPOSOTORY)),
   analytics = get(named(ANALYTICS)),
   appResources = get(named(APP_RESOURCES)),
   checkoutDelegate = get(named(CHECKOUT_DELEGATE)),
   navigator = get(named(NAVIGATOR)),
   authStateRepository = get(named(AUTH_STATE_REPOSITORY)),
   resultsBuffer = get(named(RESULTS_BUFFER)),
   scheduler = get(named(SCHEDULER)),
   moneyAmountFormatter = get(named(MONEY_AMOUNT_FORMATTER)),
   localRepository = get(named(LOCAL_REPOSITORY)),
 )
}

Помимо строки, в метод named можно передать тип класса, либо Enum. Вроде как уже лучше, но у такого подхода есть серьезная проблема. Очень легко перепутать ключи, либо можно объявить неправильный класс в factory/single методе модуля, например так (обратите внимание на название ключа и создаваемого класса):

factory(named(BASKET_REPOSITORY)) {
 BasketInteractor()
}

В обоих случаях мы получим падение в рантайме.

Kotlin Delegates

Чтобы снизить риск ошибки при объявлении классов в модулях Koin, мы решили воспользоваться делегатами. Но прежде чем объявлять делегат, надо определиться, что он будет возвращать. 

Основой нашего решения является класс KoinQualifier:

class KoinQualifier<T: Any>(
 private val named: Qualifier,
 private val clazz: KClass<T>
) : Qualifier by named, KoinComponent {

 fun get(
   scope: Scope? = null,
   params: ParametersDefinition? = null
 ): T = ...

 fun inject(
   scope: Scope? = null,
   params: ParametersDefinition? = null
 ): Lazy<T> = ...

 inline fun<reified R> factory(
   module: Module,
   override: Boolean = false,
   noinline definition: Definition<R>
 ) where R: T = ...

 inline fun<reified R> single(
   module: Module,
   createdAtStart: Boolean = false,
   override: Boolean = false,
   noinline definition: Definition<R>
 ) where R: T = ...

 override fun toString(): String {
   return named.toString()
 }
}

По сути он является оберткой вокруг основных методов получения зависимостей из Koin’а: get/inject - для получения экземпляра класса, factory/single - для его объявления. При этом он наследуется от интерфейса Qualifier, что позволит использовать этот класс в качестве ключа для объявления и получения зависимостей в Koin. Чтобы самим не реализовать интерфейс, делегируем его реализацию входному Qualifier.

Для получения экземпляра KoinQualifier, создадим делегат KoinQualifierDelegate:

class KoinQualifierDelegate<T: Any>(
 private val clazz: KClass<T>
) : ReadOnlyProperty<Any, KoinQualifier<T>> {
 private var qualifier: KoinQualifier<T>? = null

 override fun getValue(
   thisRef: Any,
   property: KProperty<*>
 ): KoinQualifier<T> {
   if (qualifier == null) {
     qualifier = KoinQualifier(
       named("${thisRef::class.simpleName}-${property.name}"),
       clazz
     )
   }
   return requireNotNull(qualifier)
 }
}

При создании KoinQualifier, в качестве ключа передается строка с названием класса и поля, в которой будет хранится созданный делегат. Осталось только сделать метод для получения делегата:

inline fun<reified T: Any> koinKey() = KoinQualifierDelegate(T::class)

Как это работает на практике?

Для начала надо объявить ключ, через который мы будет объявлять и получать необходимую зависимость. В качестве примера возьмем BasketRepository:

object CoreKoinKeys {
 val BASKET_REPOSITORY by koinKey<BasketRepository>()
}

После создания ключа необходимо использовать его при объявлении класса в модуле:

CoreKoinKeys.BASKET_REPOSITORY.single(this) {
 BasketRepository()
}

this в данном случае - это ссылка на модуль, которую мы получаем при объявлении модуля. И воспользоваться данным ключом для получения зависимости:

PresenterModule.BASKET_PRESENTER.factory(this) {
 BasketPresenter(
   basketRepository = CoreKoinKeys.BASKET_REPOSITORY.get(),
   ...
 )
}

С таким подходом мы решили для себя несколько проблем. Во-первых стало проще ориентироваться в графе зависимостей. Через поиск мест использования легко найти место объявления класса. Во-вторых внедрение зависимостей стало более типобезопасным. Метод get() класса KoinQualifier возвращает тот класс, что был объявлен в делегате by koinKey<Тип>(). В-третьих, благодаря обертке вокруг методов single/factory нельзя ошибиться при объявлении класса. Компилятор не даст создать класс, которые не является наследником, либо прямой реализацией того типа, что был объявлен в koinKey.

Подводные камни

В начале своей статьи я отметил, что мы смогли только частично решить проблемы, связанные с внедрением зависимостей через Koin. Первая и на мой взгляд самая важная проблема - это возможность объявить и использовать ключ без реализации класса внутри модуля Koin. Как было изначально, есть вероятность забыть объявить класс, либо в процессе рефакторинга удалить объявление класса. 

Вторая проблема, которая уже стала актуальной для нашего подхода, связано с объявлением типа в koinKey и создаваемым классом внутри модуля. Рассмотрим такую ситуацию:

interface A

class B: A

object Keys {
 val A_KEY by koinKey<A>()
}

val module = module {
 Keys.A.factory(this) {
   B()
 }
}

У нас есть интерфейс A, который реализует класс B. Ключ A_KEY возвращает класс, который реализует интерфейс A. В модуле метод factory возвращает наследника интерфейса A. На первый взгляд кажется, что код исправен, но если вы попытаетесь его запустить, то получите ошибку в рантайме: org.koin.core.error.NoBeanDefFoundException: No definition found for class:'com.broniboy.app.A' & qualifier:'Keys-A_KEY'. Связано это с тем, что Koin ведет поиск не только по qualifier’у, но и по названию класса. Исправить ситуацию можно, если явно указать тип в методе factory/single:

val module = module {
 Keys.A.factory<A>(this) {
   B()
 }
}

factory и single - это inline методы, которым передается reified тип создаваемого класса. Из-за этого нет возможности использовать тот тип, что был указан в koinKey.

Выводы

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

  1. Упростилась навигация по графу зависимостей.

  2. Снизилось количество NoBeanDefFoundException в крашлитиксе, из-за строгой связки ключ-возвращаемый тип.

  3. Нельзя указать неверный класс в factory/single методах Koin’а (за исключением ситуации в разделе “Подводные камни”)

Ссылки

Полный код KoinNamedDelegate

Комментарии (7)


  1. oOKIBrTlUTohw4Sc
    03.01.2022 15:41
    +5

    Мыши кололись но продолжали есть кактус. Koin это же худшая библитеока для DI ever.

    Цель DI контейнера - избавить от рутинного написания main, где приложение собирается в кучу. А Коин дает такой подход, при котором код мало того что почти не отличается от варианта где мы все инстанцируем вручную, так он еще и нечитабельный - с чем вы собственно попытались бороться непонятным неудобным велосипедом. Кстати получилось отвратительно. Я бы такое переписал бы на просто вызовы конструктора - кода тупо меньше бы получилось, да и проще он был бы.

    Есть же Dagger, который прекрасно справлявется со своей задачей, зачем это убожество подключать. Или Guice, да тот же Spring в конце концов.

    Эмоциональненько высказался, но так как сказать наболело, качество некоторых котлин либ расстраивает, что абсолютно не мешает им собирать 1К+ звезд на гитхабе.


    1. audiserg
      04.01.2022 11:46

      Возможно что то и справляется со своей задачей, но это точно не Dagger. Если бы Даггер справлялся, ни Koin, ни Kodein просто не было бы. Но и проблематика статьи кмк надумана, всего пару хоткеев в IDE способны показать все места инициализации и использования зависимостей.


      1. oOKIBrTlUTohw4Sc
        04.01.2022 18:45

        Просто интересно... Что не так с Дагером? Нет, окей, там есть нюансы, нет предела совершенству. Но я не могу найти ни одного преимущества у Koin и Kodein даже по сравнению с просто руками написать всю инициализацию, я уже молчу про dagger.


        1. Bromles
          06.01.2022 09:00

          Мультиплатформа и куча бойлерплейта. Даже Гугл советует использовать не Dagger, а Hilt, надстройку над ним. И оба они не поддерживают KMM

          Koin удобнее того же Kodein, и при этом популярнее, но имеет гораздо худшую документацию

          В целом мне они оба не нравятся после прихода из мира бэкенда (работаю как раз на Спринге, тут пет для себя пилю). Но тащить всё из шейред модуля наружу или цеплять раздутый тормозной Спринг - еще хуже. Вот и приходится есть кактус с резолвами инжектов в рантайме


  1. Warble
    05.01.2022 04:36
    +1

    А в чем сложность написать так?

    factory {
        BasketPresenter(
            get<BasketRepository>(),
            get<Analytics>(),
            get<AppResources>(),
            ...
        )
    }

    И понятно что принимает конструктор и сразу видно тип + можно еще добавить именованные аргументы добавить.


    1. ilya842 Автор
      05.01.2022 09:14

      Можно сделать так, но остается проблема, связанная с тем, что мы не знаем какие зависимости вообще есть в графе. То есть например у нас есть модуль Core и основной модуль App. В таком варианте мы не знаем, какие классы из Core мы можем использовать в App, либо вручную проверять, либо уже в рантайме ловить падения, если какого-то класса нет в графе.

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

      object CoreKoinKeys {
       val DEPENDENCY_1 by koinKey<Dependency1>()
       ...
       val DEPENDENCY_N by koinKey<DependencyN>()
      }

      Тогда у нас появляется следующее: мы при работе с графом видим не только какая зависимость используется в классе, но из какого модуля она поставляется.

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


      1. Borz
        05.01.2022 20:26

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

        И модули лучше создавать там же, где и их "бины", а не где-то типа централизованно. А в самом сервисе собирать уже модули, которые он будет использовать.

        Ну и не плодиьт "сложные" классы (больше 7 зависимостей в конструкторе). Да тот же Detekt на это тоже ругается