Все мы знаем, что 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
(опционально) примерно как на диаграмме:
![Зависимости между модулями Зависимости между модулями](https://habrastorage.org/getpro/habr/upload_files/f77/e98/bbc/f77e98bbccd2db46240d9038d830cd4e.png)
Как было сказано ранее, все фичи делятся на несколько модулей:
: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:
Что делать в вашем проекте - решать прежде всего вам.