Всем привет! Меня зовут Базикалов Илья, я являюсь Андроид разработчиком в компании 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 библиотеки в тяжело поддерживаемого монстра, где очень легко выстрелить себе в ногу. Благодаря нашему подходу, мы получили следующие преимущества:
Упростилась навигация по графу зависимостей.
Снизилось количество
NoBeanDefFoundException
в крашлитиксе, из-за строгой связки ключ-возвращаемый тип.Нельзя указать неверный класс в
factory/single
методах Koin’а (за исключением ситуации в разделе “Подводные камни”)