Пишем простенький DI для Android приложения
Сейчас практически любой проект имеет в своём составе как минимум одну библиотеку или решение для разруливания зависимостей, но далеко не каждый разработчик действительно понимает как устроены эти самые решения. Поэтому в этой статье я хотел бы прояснить некоторые базовые моменты, на которых построены такие известные библиотеки как Dagger, Hilt и Koin, а также показать на практическом примере как можно написать свое DI решение.
Золотое трио: Dagger, Hilt и Koin
В Dagger самой базовой штукой является компонент:
@Component(modules = [DaggerModule::class])
interface DaggerComponent {
...
}
Это своего рода контейнер, содержащий в себе фабрики, которые и создают зависимости проекта.
Для чего используются фабрики спросите вы, а всё просто — для отложенного создания зависимостей (объектов) :
interface Factory {
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>()
fun add(key: String, factory: Factory<*>) {
dependencies[key] = factory
}
fun 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 { ... }
single { ... }
}
val koin = startKoin {
modules(
appModule,
...
)
}.koin
val okHttpClient = koin.instance()
Модуль в Koin это практически тоже самое, что и Dagger компонент — контейнер с фабриками.
Теперь немного поговорим о такой штуке как Scope или область видимости в DI. В Dagger нет такого механизма в явном виде, но зато он есть в Hilt (кстати это одна из причин возникновения этой библиотеки):
Hilt Scopes
Если вам нужен объект, который будет жить пока живет 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 {
fun create() : T
}
object DI {
val map: MutableMap, 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 factory(crossinline dependencyProducer: () -> T) {
map[T::class] = object : Factory {
override fun create(): T {
return dependencyProducer.invoke()
}
}
}
...
}
Простая фабрика, возвращает новый объект при каждом вызове метода Factory.create()
Второй тип посложнее:
object DI {
...
inline fun singleton(crossinline dependencyProducer: () -> T) {
map[T::class] = object : Factory {
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 instance(): T {
return map[T::class]?.create() as T
}
...
}
В результате получим более менее полноценный DI контейнер:
interface Factory {
fun create() : T
}
object DI {
val map: MutableMap, Factory<*>> = mutableMapOf()
@Suppress("UNCHECKED_CAST")
inline fun instance(): T {
return map[T::class]?.create() as T
}
inline fun factory(crossinline dependencyProducer: () -> T) {
map[T::class] = object : Factory {
override fun create(): T {
return dependencyProducer.invoke()
}
}
}
inline fun singleton(crossinline dependencyProducer: () -> T) {
map[T::class] = object : Factory {
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().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())
val state = _state.asStateFlow()
private val _effect = MutableSharedFlow()
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.
Заключение
Статья получилась достаточно информативной и надеюсь вы узнали что-то новое. Самое главное пробуйте придумывать свои решения и пытайтесь их реализовать, даже если не до конца понимаете что делаете: проектирование библиотек — это очень полезный навык, особенно если вам нравится заниматься архитектурой проектов или у вас есть стремление двигаться в этой области.
Полезные ссылки:
Мой телеграм канал
Исходники к статье
Другие мои статьи
Пишите в комментах ваше мнение и всем хорошего кода.