Все мы знаем, что Dagger - бич современного общества стандарт индустрии, если это касается Dependency Injection. Все мы знаем, что Dagger хоть и является мощным фреймворком, но сборка проекта с ним занимает довольно много времени, Dagger - страшный сон для многих. А что если отказаться от него? Но в пользу чего? Koin и другие сервис локаторы - так себе идея, ведь весь injection происходит в рантайме и рано или поздно приложение из-за этого упадет. Может быть писать все руками?
Именно так я подумал и решил реализовать ручной DI в своем небольшом проекте.
Дисклеймер
Моя реализация не претендует на роль лучшей или даже хорошей. Мои исходники не являются идеальными и далеко не всегда следуют советам дядюшки Боба, не надо задавать вопросы по типу "А почему у тебя есть WallpaperProvider, WallpaperManager и WallpaperRepository?"
Однако, я всегда открыт к улучшениям и буду рад, если вы поделитесь своими идеями по её улучшению.
Итерация 1: один Gradle модуль, один граф
Концепция такова: у нас есть единый граф для всего приложения, в котором есть все зависимости приложения. Он хранится в application-классе, что дает нам единственность графа для всего приложения. (Почти) все зависимости создаются только по надобности: достигается это путем делегата lazy. Зависимости, которые нуждаются в activity(PermissionHandler), изначально имеют Noopреализацию. Они доставляются только при создании Activity. Естественно, это несет за собой и минусы: что если кто-то возьмет Noopреализацию до замены на нормальную? Это хороший (и пока не решенный) вопрос.
class Graph(private val app: Application) { val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) } var permissionHandler: PermissionHandler = PermissionHandler.NoopPermissionHandler val navController by lazy { createMaterialMotionNavController(app) } val wallpaperRepository: WallpaperRepository by lazy { WallpaperRepositoryImpl(app, navController) } // ... } class WallManApp: Application() { val graph by lazy { Graph(this) } override fun onCreate() { super.onCreate() // ... } }
Зависимости без начальной реализации создаются внутри activity. Так же мы прокидываем граф через CompositionLocal в compose для дальнейшего использования:
class MainActivity : ComponentActivity() { private val graph by lazy { (this.application as WallManApp).graph } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) graph.apply { permissionHandler = AndroidPermissionHandler(this@MainActivity) // ... } setContent { CompositionLocalProvider(LocalGraph provides graph) { // ... } } } }
Как создаются вьюмодели? Очень просто! Создается extension-функция над графом для создания вьюмодели. Это дает нам разгрузить граф от ненужных функций. Так же прова��дить зависимости становится легче, когда эта функция лежит в одном файле с самой вьюмоделью:
fun Graph.MainViewModel() = MainViewModel(wallpapersRepository) class MainViewModel( private val repo: WallpapersRepository ) : ViewModel() { // ... }
Очень элегантно, правда? Все зависимости видны в конструкторе, а их внедрение не доставляет трудностей.
Как же доставить вьюмодель в ui? Здесь тоже все довольно просто. Создаем Composable функцию для предоставления зависимостей:
@Suppress("UNCHECKED_CAST") class ViewModelFactory(val viewModel: () -> ViewModel) : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { return viewModel() as T } } @Composable inline fun <reified T : ViewModel> viewModel(noinline block: @DisallowComposableCalls Graph.() -> T): T { val graph = LocalGraph.current return androidx.lifecycle.viewmodel.compose.viewModel(factory = remember { ViewModelFactory { graph.block() } }) }
Этот подход создает жесткую связь между ui и графом, поэтому мы разделяем экран на две функции: с вьюмоделью и без нее. В моем случае это MVI архитектура:
@Composable fun MainScreen(modifier: Modifier = Modifier) { val viewModel = viewModel { MainViewModel() } val state by viewModel.state.collectAsStateWithLifecycle() MainScreen(state, modifier) } @Composable private fun MainScreen( state: MainViewModel.MainScreenState, modifier: Modifier = Modifier ) { // ... }
Кроме того, при использовании такого подхода можно легко передавать параметры. Представим, что у нас есть экран с подробной информацией об обоях, и мы хотим передать хешкод выбранной обоины из списка, который хранится в репозитории. Делается это очень просто:
fun Graph.WallpaperDetailsViewModel(wallpaperHashCode: Int) = WallpaperDetailsViewModel(wallpaperHashCode, /* ... */) class WallpaperDetailsViewModel( private val wallpaperHashCode: Int, // ... ) : ViewModel() { // ... } @Composable fun WallpaperDetailsScreen(wallpaperHashCode: Int, modifier: Modifier = Modifier) { val viewModel = viewModel { WallpaperDetailsViewModel(wallpaperHashCode) } val state by viewModel.state.collectAsStateWithLifecycle() WallpaperDetailsScreen(state, modifier) }
Однако, такой подход имеет свои недостатки. При росте проекта становится все труднее поддерживать один Gradle-модуль, поэтому следует разделять фичи на отдельные модули.
Исходники итерации 1: gitlab.
Итерация 2: разделение на Gradle модули, 1 граф
Вот здесь все становится интереснее. Так как все фичи разделены на разные модули, мы как-то должны связать их в один граф. Для этого мы разделяем наш начальный граф на интерфейс и реализацию(в :di:api и :di:impl, например). Соответственно все фичи делятся на :api, :impl и :ui(опционально) примерно как на диаграмме:

Как было сказано ранее, все фичи делятся на несколько модулей:
:api- интерфейсы и extension-фунции/проперти к этим интерфейсам(чтоб жизнь слаще была):impl- реализации интерфейсов из:api:ui- опционально, графический интерфейс фичи. Может зависеть от:di:apiдля внедрения зависимостей во вьюмодели. Этот модуль не зависит от:impl!
В остальном все остается тем же самым.
И снова минусы: при росте проекта граф может сильно разрастись, поэтому его нужно разделить на подграфы.
Исходники итерации 2: gitlab.
Итерация 3: разделение графа на модули
Чтобы основной граф не выглядел так страшно, можно разделить его на feature-модули:
interface Graph { val coreModule: CoreModule val wallpapersModule: WallpapersModule // ... }
Тогда реализация модуля будет выглядеть так:
interface WallpapersModule: CoreModule { val wallpapersRepository: WallpapersRepository // ... } class WallpapersModuleImpl( coreModule: CoreModule, application: Application ) : WallpapersModule, CoreModule by coreModule { // ... }
Теперь мы можем менять зависимости вьюмодели с графа на соответствующий модуль:
fun WallpapersModule.MainViewModel() = MainViewModel( wallpapersRepository ) class MainViewModel( private val repo: WallpapersRepository, ) : ViewModel() { // ... }
Как сделать так, чтобы можно было запустить фичу без всего графа? Убрать граф из зависимостей фичи. Здесь есть 2 стула варианта.
Вариант 1: прокидывание модуля через Compose
Для каждого модуля будем добавлять CompositionLocal, который будет проброшен где-то сверху compose-дерева.
Рядом с WallpapersModule добавляем соответствующий CompositionLocal:
interface WallpapersModule: CoreModule { val wallpapersRepository: WallpapersRepository } val LocalWallpapersModule = compositionLocalOf<WallpapersModule> { error("WallpapersModule is not provided") }
Чтобы CompositionLocals всех модулей подтянулись, нужно создать специальный провайдер для них в :di:api и вставить его в наше Activity:
@Composable fun ProvideGraphModules(content: @Composable () -> Unit) { val graph = LocalGraph.current CompositionLocalProvider( LocalCoreModule provides graph.coreModule, LocalWallpapersModule provides graph.wallpapersModule, // ... ) { content() } } class MainActivity : ComponentActivity() { private val graph by lazy { (application as WallManApp).graph } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { CompositionLocalProvider(LocalGraph provides graph) { ProvideGraphModules { // ... } } } } }
Чтобы вызвать эту вьюмодель, нам нужно сменить прошлый вызов новой функцией в :my_feature:ui:
@Composable inline fun <reified T : ViewModel> viewModel(noinline block: @DisallowComposableCalls WallpapersModule.() -> T): T { return viewModelWithReceiver(LocalWallpapersModule.current, block) }
И создать viewModelWithReceiver в :core:api:
@Composable inline fun <reified T : ViewModel, R> viewModelWithReceiver( receiver: R, noinline block: @DisallowComposableCalls R.() -> T ): T { return androidx.lifecycle.viewmodel.compose.viewModel(factory = remember { ViewModelFactory { receiver.block() } }) }
С помощью этих изменений мы можем убрать зависимость :di:api из :my_feature:ui. Это дает нам возможность запускать фичу без создания всего графа.
Опять минусы: можно забыть запровайдить какой-нибудь модуль в ProvideGraphModules, из-за чего приложение может крашнуться. Не очень благоприятный поворот событий.
Исходники варианта 1: gitlab.
Вариант 2: использовать context receivers
Этот вариант гарантирует нам предоставление зависимостей.
Что такое этот ваш context receivers?
Context receivers многим похожи extension-функции, но имеют смысловые и функциональные отличия. На примерах:
fun Logger.allLogsByTag(tag: String): List<String> { return allLogs().filter { it.tag == tag } } context(Logger) fun Storage.storeAppState() { log("Storage", "Starting storing...") val logs = allLogsByTag("tag") // ... }
Функция allLogsByTag может выполнять операцию над Logger, в то время как storeAppState может выполняться только в том скоупе, где есть Logger.
Подробнее можно почитать на сайте Jetbrains
На момент написания статью context receivers на стадии prototype. Пока эта функция ограничена Kotlin/JVM, а Jetbrains не рекомендуют использовать ее в продакшене:
The feature is a prototype available only for Kotlin/JVM. With
-Xcontext-receivers
enabled, the compiler will produce pre-release binaries that cannot be
used in production code. Use context receivers only in your toy
projects. We appreciate your feedback in YouTrack.
Если вы готовы к context receivers, то подключаем флаг в build.gradle.kts:
tasks.withType(KotlinCompile::class.java) { kotlinOptions.freeCompilerArgs += listOf("-Xcontext-receivers") }
Убираем из composable-функции вьюмодели какие-либо зависимости от графа:
@Composable inline fun <reified T : ViewModel> viewModel(noinline block: @DisallowComposableCalls () -> T): T { return androidx.lifecycle.viewmodel.compose.viewModel(factory = remember { ViewModelFactory { block() } }) }
Добавляем контекст в функцию с вызовом вьюмодели в ui:
context(WallpapersModule) @Composable fun WallpaperDetailsScreen(wallpaperHashCode: Int, modifier: Modifier = Modifier) { val viewModel = viewModel { WallpaperDetailsViewModel(wallpaperHashCode) } val state by viewModel.state.collectAsStateWithLifecycle() WallpaperDetailsScreen(state, modifier) }
В месте вызова(в навигации, например) добавляем блок with для добавления контекста:
with(graph.wallpapersModule) { composable("WallpaperDetails/{hashcode}") { val hashCode = ... WallpaperDetailsScreen(hashCode) } // ... }
Так как под капотом context receivers - еще один (или несколько) параметр в функции, а модули не являются стабильными, то посмотрим, что нам выдал compose об этой функции:
restartable scheme("[androidx.compose.ui.UiComposable]") fun WallpaperDetailsScreen( unstable _context_receiver_0: WallpapersModule stable wallpaperHashCode: Int stable modifier: Modifier? = @static Companion ) restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun WallpaperDetailsScreen( stable state: WallpaperDetailsScreenState stable modifier: Modifier? = @static Companion )
Видим, что composable-функция, использующая context receivers, non-skippable. Критично ли это? Нет, ведь composable-функция, принимающая state - skippable. То есть при обходе дерева compose всегда будет вызывать функцию с вьюмоделью, а функцию со стейтом - только при необходимости. Подробности про оптимизацию compose-кода: статья от Ozon Tech.
А на самом деле как?
Хороший вопрос. На практике я не заметил разницы, да и layout inspector не показывал ненужных рекомпозиций.
Из минусов: context receivers пока нестабильны(ожидается стабилизация после прихода K2 компилятора) и работают только в JVM (то есть нет поддержки IOS и браузера). Если Jetbrains решит изменить способ вызова, то придется адаптировать весь проект под изменения.
Как костыль альтернативное решение - самим добавлять параметры в функции. Например:
context(WallpapersModule) @Composable fun WallpaperDetailsScreen(wallpaperHashCode: Int, modifier: Modifier = Modifier) { // ... }
Заменяем на:
@Composable fun WallpaperDetailsScreen( module: WallpapersModule, wallpaperHashCode: Int, modifier: Modifier = Modifier ) { // ... }
Исходники варианта 2: gitlab.
Заключение
Более перспективным вариантом, по-моему, здесь является context receivers, они дают гарантию предоставления зависимостей.
Что мы имеем от этой реализации:
Разрешение зависимости на этапе компиляции
Уменьшенное время компиляции по сравнению с Dagger
Подсветка в IDE при отсутствии каких-либо зависимостей (уменьшение feedback loop)
Независимость от изменений в Dagger
Про минусы не забываем:
Возможное падение приложения при использовании
CompositionLocalНевозможность использовать на context receivers на IOS и в браузере без костылей
Легко ли было реализовать ручной DI? Довольно просто. А было ли нужно? Я оставлю этот вопрос для вас.
Советую ознакомиться с альтернативными реализациями ручного DI:
Что делать в вашем проекте - решать прежде всего вам.
