Глубину осознания той или иной библиотеки можно проверить, написав её самостоятельно. Возможно, новорожденное решение будет ограниченным и лишённым всякой аудитории, но оно будет навеки принадлежать её автору и подтверждать реальное понимание работы технологии.
Так произошло и со мной. Решил написать полноценный функционал для работы с зависимостями и, так как я Android-разработчик, адаптировать для работы в моей привычной среде и для моих таких же привычных задач.
А делюсь я написанным творением с наивной мыслью, что это сделает кого-то лучше в техническом плане.
Важно помнить, что данная статья является моим творчеством и схожесть с другими технологиями и подходами намеренна. А также статья является выжимкой сути из написанной мной библиотеки, полный код которой можно найти на моём GitHub.
Постановка задачи
Всё началось с постановки задач и примерного пути развития кода. Путь был выстроен примерно такой:
сохранение зависимостей в контейнере
возможность держать в контейнере зависимости одного типа
фабрики для создания зависимостей
автосоздаваемые зависимости
модули для более удобного заполнения зависимостями контейнер. Расширения для Android
зависимости между контейнерами
коллекции в контейнере
публикация библиотеки
Сохранение зависимостей в контейнере
У меня изначально было чёткое понимание, что, говоря о хранении зависимостей, нужно сразу закладывать работу с продолжительностью жизни контейнера и всех его зависимостей. По этой причине я сразу отошёл от статики и создал MutableMap
для моих зависимостей в обычном классе с тем соображением, что следить за жизнью экземпляра класса будет проще, нежели возиться со статикой.
interface DiContainer {
val container: RootContainer
}
class DiContainerImpl : DiContainer {
override val container: RootContainer = RootContainer()
}
class RootContainer {
private val dependencies: MutableMap<Class<out Any>, Any> = mutableMapOf()
...
}
Выше представлен фундамент моего подхода, позволяющий как использовать готовую реализацию контейнера, так и переопределить его в будущем, когда RootContainer
начнёт принимать зависимости и настройки извне.
Наполнять данный контейнер зависимостями очень просто:
fun <T : Any> provide(clazz: Class<out T>, dependency: T) {
dependencies[clazz] = dependency
}
Возможность держать в контейнере зависимости одного типа
Для решения данной задачи я избрал путь создания отдельной коллекции для содержания зависимостей, где ключом для их получения будет являться аннотация, с которой соответствующая зависимость ассоциирована.
class RootContainer {
private val qualifiedDependencies: MutableMap<Class<out Annotation>, Any> = mutableMapOf()
...
}
Данная задача требовала небольших правок имеющегося кода: мне требовалось понять, содержит ли класс переданной зависимости нужную аннотацию, и если да, то положить в другую коллекцию.
fun <T : Any> provide(clazz: Class<out T>, dependency: T) {
val qualifiedAnnotation = findQualifiedAnnotation(clazz)
if (qualifiedAnnotation != null) {
qualifiedDependencies[qualifiedAnnotation.annotationClass.java] = dependency
} else {
dependencies[clazz] = dependency
}
}
За методом findQualifiedAnnotation
скрыта незначительная логика по поиску аннотации у класса. В остальном поставленную задачу можно считать выполненной.
Фабрики для создания зависимостей
Здесь пришлось поломать голову, потому что было непонятно, где хранить данную зависимость и нужно ли будет создавать отдельную коллекцию. Создавать отдельную не хотелось, и выход был очень простой: положить в имеющуюся коллекцию лямбду, создающую объект, а ключом указать создаваемый лямбдой объект.
data class DependencyFactory<T>(
val factory: () -> T,
)
fun <T: Any> T.tryFactory(): T {
return if (this is DependencyFactory<*>) {
this.factory.invoke() as T
} else {
this
}
}
Заполнение контейнера фабричной зависимостью выглядит так:
inline fun <reified T : Any> factory(noinline factory: () -> T) {
val dependencyFactory = DependencyFactory(factory)
provide(T::class.java, dependencyFactory)
}
После этого, когда запрашивается зависимость, происходит вызов на ней метода tryFactory, который по необходимости вызовет лямбду, если запрашиваемая зависимость окажется фабричной.
Автосоздаваемые зависимости
Автосоздаваемая зависимость — это зависимость, инстанс которой можно создать на основании других зависимостей при условии, что они уже находятся в контейнере. То есть если для создания класса Z
нужно ему в конструктор передать A
и B
, которые уже есть в контейнере, моя система должна уметь находить эти A
и B
, инстанциирую на их основе Z
.
private fun <T> create(constructor: Constructor<T>): T {
val parameters = constructor.parameters
val parametersList = mutableListOf<Any>()
parameters.forEach { parameter ->
val qualifiedAnnotation = findQualifiedAnnotation(parameter)
val value = getInternal(
parameter.type,
qualifiedAnnotation?.annotationClass?.java,
)
parametersList.add(value)
}
return constructor.newInstance(*parametersList.toTypedArray()) as T
}
Задача решилась очень элегантно, на мой взгляд, потребовав всего лишь пройтись по параметрам конструктора и попытаться достать зависимости нужного типа из контейнера, а затем просто передать все собранные объекты в метод newInstance
.
На данный момент мой метод внутреннего получения данных из контейнера стал выглядеть примерно так:
private fun <T : Any> getInternal(
clazz: Class<T>,
qualifierClazz: Class<out Annotation>? = null,
): T {
return getQualifiedDependency<T>(qualifierClazz)?.tryFactory()
?: (dependencies[clazz] as? T)?.tryFactory()
?: createDependency(clazz, qualifierClazz)
?: throw IllegalStateException("...")
}
Модули для более удобного заполнения зависимостями контейнер. Расширения для Android
Мне хотелось сразу решить проблему смены конфигурации, поэтому я написал делегат для хранения экземпляра моего контейнера внутри ViewModel
.
class DiContainerStoreViewModel(
val container: RootContainer,
) : ViewModel()
Далее настроил получение DiContainerStoreViewModel
через делегат:
fun ViewModelStoreOwner.retainContainer(
modules: List<DiModule> = emptyList(),
overrideViewModelStore: ViewModelStore? = null,
): Lazy<RootContainer>
DiModule
— это дата класс, помогающий мне сохранить все вызовы по работе с зависимостями, чтобы потом инициировать их на конкретном экземпляре контейнера.
fun module(module: RootContainer.() -> Unit): DiModule {
return DiModule(module)
}
data class DiModule(
val module: RootContainer.() -> Unit,
)
После данного шага я мог наслаждаться простотой работы с модулями, поставлять которые можно было в любом количестве и разбивать зависимости между ними в любом порядке.
val sampleModule = module {
provide(SomeDep("hello"))
factory { FirstInteractorImpl() }
factory { SecondInteractorImpl() }
}
Зависимости между контейнерами
Данная задача прекрасно вписывалась в имеющийся код и требовала лишь передать в конструктор RootContainer
другой контейнер, вызываемый в рамках getInternal
. Естественно последним вызовом, чтобы в начале проверить наличие зависимости в текущем контейнере, а только потом переходить к поиску в зависимом.
Коллекции в контейнере
А вот данный пункт оказался не только заключительным, но и весьма нетривиальным. Признаться, думал над решением я несколько дней и вот почему — я хотел работать только с коллекцией Map
и разделять экземпляры коллекций в зависимости от типов ключа и значения.
Это значит, что если я положил несколько элементов в коллекцию Map<String, Int>
, а потом положу Map<Int, String>
, то мой контейнер должен держать в системе уже две коллекции, и, запрашивая коллекцию Map<String, Int>
, я не хочу получать элементы из Map<Int, String>
. И в этом была вся сложность, так как в рантайме вытащить типы дженериков коллекции Map
не было возможно.
В итоге решил задачу созданием ещё одной коллекции, а также аннотации, в аргументы которой я передавал нужные типы.
private val dependencyMaps: MutableMap<DependencyMapIdentifier, MutableMap<Any, Any>> = mutableMapOf()
Data class для хранения типов ключа и значения для соответствующей Map
:
private data class DependencyMapIdentifier(
val keyClass: Class<out Any>,
val valueClass: Class<out Any>,
)
Заполнение коллекции данными выглядит так:
fun <K : Any, V : Any> intoMap(key: K, value: V) {
val mapIdentifier = DependencyMapIdentifier(key.javaClass, value.javaClass)
val existedMap = dependencyMaps[mapIdentifier]
if (existedMap != null) {
existedMap[key] = value
} else {
val newMap: MutableMap<Any, Any> = mutableMapOf(key to value)
dependencyMaps[mapIdentifier] = newMap
}
}
На этапе заполнения коллекции создавался data class, хранящий значения классов и использовавший его в качестве ключа.
А для запроса зависимости требовалась такая конструкция:
class AutoCreatedDependency @Inject constructor(
@MapDependency(String::class, String::class) stringMap: Map<String, String>,
@MapDependency(String::class, Boolean::class) booleanMap: Map<String, Boolean>,
)
Была небольшая проблема с поиском коллекции, потому что классы примитивов трансформировались из классов оберток, таких как java.lang.Boolean
в boolean
, что потребовало данного способа получения из KClass
класса обертки: mapDependencyAnnotation.keyClass.javaObjectType
.
Публикация библиотеки
Для публикации я воспользовался git-командами:
git tag 1.0
git push --tags
После этого на GitHub открыл вкладку Reseases
-> Draft a new release
, выбрал нужный тег и нажал Publish release
. Практически сразу моя библиотеки нашлась в jitpack, где помимо ссылок и версий можно найти строку для badge в рамках GitHub, чтобы вставить в README.md и видеть в нём всегда актуальную версию публикуемой библиотеки.
Итог
Я написал свою первую полноценную библиотеку и прошёлся по интересным для себя проблемам, что дало более глубокое понимания работы с зависимостями, а также доставило удовольствие. Весь код библиотеки можно найти в GitHub.