Сейчас практически любой проект имеет в своём составе как минимум одну библиотеку или решение для разруливания зависимостей, но далеко не каждый разработчик действительно понимает как устроены эти самые решения. Поэтому в этой статье я хотел бы прояснить некоторые базовые моменты, на которых построены такие известные библиотеки как Dagger, Hilt и Koin, а также показать на практическом примере как можно написать свое DI решение.
Золотое трио: Dagger, Hilt и Koin
В Dagger самой базовой штукой является компонент:
@Component(modules = [DaggerModule::class]) interface DaggerComponent { ... }
Это своего рода контейнер, содержащий в себе фабрики, которые и создают зависимости проекта.
Для чего используются фабрики спросите вы, а всё просто — для отложенного создания зависимостей (объектов) :
interface Factory<T> { fun create(): T } val factory = Factory { Repository(...) } val repository = factory.create()
Обычно в DI библиотеках используются два вида фабрик, мы к ним ещё вернёмся, когда будем писать своё решение, главное держите в уме, что зависимости не создаются прямо сразу, а только по требованию.
Вернёмся к Dagger компоненту и посмотрим на сгенерированный код:
class DaggerComponent(...) { private val okHttpFactory = ... private val roomDatabaseFactory = ... private val viewModelFactory = ... fun inject(activity: MainActivity) { activity.viewModel = viewModelFactory.create() } } class App : Application() { val component by lazy { DaggerComponent(...) } } class MainActivity : ComponentActivity() { lateinit var viewModel: ViewModel override fun onCreate(...) { super.onCreate(...) (applicationContext as App).component.inject(this) } }
Для простоты я опустил ненужные детали, основная идея в том, что Dagger компонент хранит в себе список фабрик для получения зависимостей, которые обычно прописываются через @Inject
аннотацию в конструкторе или объявляются в Dagger модулях. Важно добавить, что Dagger компонент может принимать на вход зависимости при инициализации, например Application контекст.
По такому принципу можно хранить зависимости в HashMap’е например:
class SomeComponent(...) { private val dependencies = hashMapOf<String, Factory<*>>() fun add(key: String, factory: Factory<*>) { dependencies[key] = factory } fun <T> instance(key: String): T { dependencies[key].create() as T } } class MainActivity { lateinit var viewModel: ViewModel override fun onCreate(...) { super.onCreate(...) viewModel = (applicationContext as App).component.instance("viewModel") } }
Только в таком случае придётся вручную складывать зависимости в контейнер, а потом также вручную их доставать.
Похожий способ реализован как раз в Koin, где нет кодогенерации:
val appModule { single<OkHttpClient> { ... } single<RoomDatabase> { ... } } val koin = startKoin { modules( appModule, ... ) }.koin val okHttpClient = koin.instance<OkHttpClient>()
Модуль в Koin это практически тоже самое, что и Dagger компонент — контейнер с фабриками.
Теперь немного поговорим о такой штуке как Scope или область видимости в DI. В Dagger нет такого механизма в явном виде, но зато он есть в Hilt (кстати это одна из причин возникновения этой библиотеки):
Если вам нужен объект, который будет жить пока живет ViewModel, то вы помечаете его аннотацией @ViewModelComponent
, если пока живет Activity — аннотацией @ActivityComponent
и тд.
Здесь нет никакой магии, так как на самом деле Scope это всего лишь место, где лежит Dagger компонент:
class MainViewModel : ViewModel { val component by lazy { DaggerComponent(...) } } class MainActivity : ComponentActvity { val component by lazy { DaggerComponent(...) } } class App : Application() { val component by lazy { DaggerComponent(...) } }
Из собственных наблюдений и опыта я понял, что объекты (зависимости) в большинстве случаев создаются там где они действительно нужны, то есть по дефолту в правильной области видимости (Scope), а следовательно отпадает смысл в использовании того же Hilt’а, простой пример для подтверждения:
class PostDetailViewModel : ViewModel() { // insertUseCase создаётся через фабрику и живёт пока // не будет уничтожена PostDetailViewModel private val insertUseCase : PostInsertUseCase = DI.instance() }
К тому же Hilt генерирует наследников для Application, Activity и других классов, в итоге результирующий кодген становится очень грязным, избыточным и более подверженным ошибкам нежели кодген от Dagger. В последнем однозначно рекомендую покопаться или глянуть мою статью на Github’е по этой теме.
Давайте подведём итоги:
-
Dagger компонент или Koin модуль это некоторый контейнер с фабриками зависимостей (объектов)
-
Scope или область видимости в DI это всего лишь место, где лежат зависимости, например если Dagger компонент положить в Application класс, то область видимости будет глобальной для Android приложения
-
Hilt усложняет результирующий код и лично по мне избыточен в проектах.
Пишем свой DI контейнер
Прежде чем начать писать свое решение хотел бы отметить что в Dagger и Koin есть возможность создавать целые графы зависимостей:
val koin = startKoin { modules( appModule, coreModule, ... ) }.koin
В итоге два модуля будут объедины в один DI контейнер и если вдруг в coreModule
понадобится Application контекст например, он будет взят из appModule
.
Dagger как таковой фичи по объединению компонентов не имеет (dependencies не в счёт, он работает немного по другому), но зато можно использовать модули:
@Component( moduls = [ AppModule::class, CoreModule::class, ... ] ) interface DaggerComponent { ... }
Мы же не будем делать сложную иерархию из DI контейнеров, а сделаем один глобальный:
interface Factory<T> { fun create() : T } object DI { val map: MutableMap<KClass<*>, Factory<*>> = mutableMapOf() }
В качестве ключа для простоты я решил использовать KClass
для типа объекта, простыми словами для каждого класса можно будет создать только один вариант объекта. Если вам вдруг нужно иметь два OkHttpClient’а с разными настройками, то необходимо сделать более сложный ключ, например как в Koin:
// помимо KClass можно добавить Qualifier и scoped Qualifier fun indexKey(clazz: KClass<*>, typeQualifier: Qualifier?, scopeQualifier: Qualifier): String { val tq = typeQualifier?.value ?: "" return "${clazz.getFullName()}:$tq:$scopeQualifier" }
DI контейнер готов, теперь нужно создать фабрики, как я уже говорил их чаще всего два типа, начнём с первого:
object DI { ... // reified нужен чтобы получить KClass<*> для ключа хэш-таблицы inline fun <reified T : Any> factory(crossinline dependencyProducer: () -> T) { map[T::class] = object : Factory<T> { override fun create(): T { return dependencyProducer.invoke() } } } ... }
Простая фабрика, возвращает новый объект при каждом вызове метода Factory.create()
Второй тип посложнее:
object DI { ... inline fun <reified T : Any> singleton(crossinline dependencyProducer: () -> T) { map[T::class] = object : Factory<T> { private var _dependency: T? = null /* распространённый паттерн для создания потокобезопасного Singleton'а объекта, называется Double-checked locking вообще можно обойтись без паттерна, если уверены на 100% что код будет выполняться только на главном потоке */ override fun create(): T { _dependency?.let { return it } synchronized(this) { _dependency?.let { return it } val dependency = dependencyProducer.invoke() _dependency = dependency return dependency } } // вариант без паттерна Double-checked locking override fun create(): T { _dependency?.let { return it } val dependency = dependencyProducer.invoke() _dependency = dependency return dependency } } } ... }
Создаёт объект когда это нужно и хранит на него ссылку, чтобы не пересоздавать заново, в нашем случае это полноценный Singleton, так как у нас глобальный DI контейнер. В Dagger и Koin такая штука применяется только к модулю или компоненту, а как вы уже знаете последние могут находиться где угодно: в Activity, в Application и других частях приложения.
Ну и последняя изюминка — нам нужен удобный метод для получения зависимостей из DI контейнера:
object { ... @Suppress("UNCHECKED_CAST") inline fun <reified T> instance(): T { return map[T::class]?.create() as T } ... }
В результате получим более менее полноценный DI контейнер:
interface Factory<T> { fun create() : T } object DI { val map: MutableMap<KClass<*>, Factory<*>> = mutableMapOf() @Suppress("UNCHECKED_CAST") inline fun <reified T> instance(): T { return map[T::class]?.create() as T } inline fun <reified T : Any> factory(crossinline dependencyProducer: () -> T) { map[T::class] = object : Factory<T> { override fun create(): T { return dependencyProducer.invoke() } } } inline fun <reified T : Any> singleton(crossinline dependencyProducer: () -> T) { map[T::class] = object : Factory<T> { private var _dependency: T? = null override fun create(): T { _dependency?.let { return it } synchronized(this) { _dependency?.let { return it } val dependency = dependencyProducer.invoke() _dependency = dependency return dependency } } } } }
Тадам! Мы написали собственное DI решение, можно пилить проект!
Использование только что написанного DI
Добавим первую зависимость в DI контейнер:
DI.singleton { Room.databaseBuilder( applicationContext, AppDatabase::class.java, AppDatabase.NAME ).build() }
Такие объекты как база данных лучше хранить в глобальной области видимости, чтобы эффективно переиспользовать соединение с базой и не открывать его на каждый чих.
Для большинства других объектов лучше использовать простые фабрики:
DI.factory { PostInsertUseCase(instance()) }
Обратите внимание, что фабрика ничего извне не принимает, все нужные зависимости извлекаются из DI контейнера через DI.instance()
метод, в Koin кстати также сделано, это очень удобная штука.
Добавлять зависимости мы научились, осталось только построить полноценный граф. Для этого надо взять все фабрики зависимостей из других модулей и положить их в наш DI контейнер:
class App : Application() { override fun onCreate() { super.onCreate() // строим граф зависимостей DI.initAppDependencies(applicationContext) DI.initCoreDependencies() } } // модуль app fun DI.initAppDependencies(applicationContext: Context) { singleton { Room.databaseBuilder( applicationContext, AppDatabase::class.java, AppDatabase.NAME ).build() } factory { instance<AppDatabase>().postDao() } } // модуль core fun DI.initCoreDependencies() { factory { PostInsertUseCase(instance()) } factory { PostDeleteUseCase(instance()) } factory { PostFetchAllUseCase(instance()) } factory { PostFetchByIdUseCase(instance()) } }
Построение происходит в app
модуле, а сам DI контейнер лежит в core
, чтобы можно было получить зависимости в любом модуле.
Важный момент: от app модуля нельзя зависеть, это сборочный модуль, он собирает остальные модули в конечный артефакт — apk архив или aab файл.
Пришло время написать простой функционал для списка постов в отдельном модуле post_list
, разумеется создаём для этого дела PostListViewModel
:
internal class PostListViewModel : ViewModel() { // вот таким элегантным способом мы получаем нужную зависимость private val fetchAllUseCase: PostFetchAllUseCase = DI.instance() private val fetchDeleteUseCase: PostDeleteUseCase = DI.instance() private val _state = MutableStateFlow(persistentListOf<PostModel>()) val state = _state.asStateFlow() private val _effect = MutableSharedFlow<PostListEffect>() val effect = _effect.asSharedFlow() fun handleEvent(event: PostListEvent) { when(event) { is PostListEvent.FetchAll -> handleEvent(event) is PostListEvent.Delete -> handleEvent(event) is PostListEvent.View -> handleEvent(event) is PostListEvent.Add -> handleEvent(event) } } private fun handleEvent(event: PostListEvent.FetchAll) = viewModelScope.launch { _state.value = fetchAllUseCase.execute().toPersistentList() } private fun handleEvent(event: PostListEvent.Delete) = viewModelScope.launch { fetchDeleteUseCase.execute(event.model) handleEvent(PostListEvent.FetchAll) } private fun handleEvent(event: PostListEvent.View) = viewModelScope.launch { _effect.emit(PostListEffect.View(event.model)) } private fun handleEvent(event: PostListEvent.Add) = viewModelScope.launch { _effect.emit(PostListEffect.Add) } }
PostListViewModel
даже не нужно принимать зависимости в конструкторе, это явный плюс, так как можно спокойно забыть про кастомные фабрики для ViewModel.
Также хотел бы отметить, что в нашем самописном решении нет такой штуки как механизм Scopes, напомню что в Dagger его тоже нет, и как я ранее говорил в таком случае всё зависит от места получения зависимости, в качестве примера возьмём какую-нибудь ViewModel:
class PostDetailViewModel : ViewModel() { // объект insertUseCase будет жить пока жива PostDetailViewModel private val insertUseCase : PostInsertUseCase = DI.instance() }
Как по мне так намного логичнее, чем пытаться через Hilt аннотацию обозначить нужную область видимости для PostInsertUseCase
экземпляра. Безусловно есть специфичные кейсы, но это уже зависит от проекта и задач, вы всегда можете адаптировать решение, если оно изначательно было хорошо спроектировано.
Суммируем:
-
Тяжелые зависимости такие как база данных или OkHttpClient лучше хранить в глобальной области видимости
-
Остальные зависимости в большинстве случаев можно смело хранить через простые фабрики
-
Намного проще и логичнее получать зависимости, когда они нужны и там где они нужны, вследствии чего отпадает использование такого механизма как Scopes.
Заключение
Статья получилась достаточно информативной и надеюсь вы узнали что-то новое. Самое главное пробуйте придумывать свои решения и пытайтесь их реализовать, даже если не до конца понимаете что делаете: проектирование библиотек — это очень полезный навык, особенно если вам нравится заниматься архитектурой проектов или у вас есть стремление двигаться в этой области.
Полезные ссылки:
Пишите в комментах ваше мнение и всем хорошего кода.
ссылка на оригинал статьи https://habr.com/ru/articles/843258/
Добавить комментарий