
Быть в авангарде в разработке — жизненная необходимость. Поэтому многие проекты уже переходят на Jetpack Compose, а самые смелые и продвинутые даже выпускают приложения на KMP. Мы в проекте Дринкит тоже активно переходим на Jetpack Compose (с KMP пока не сделали подход).
Ну и как же жить со всем этим без DI? Правильно, никак. Поэтому в этой статье я расскажу, как применять DI Kodein в Kotlin Multiplatform и Jetpack Compose.
Это вторая статья из цикла статьей про Kodein DI для Android:
Часть 1: Kodein DI для Android. Основы API
Часть 2: Kodein DI для Android. KMP и Compose
Часть 3: Kodein DI для Android. Целостность графа и тесты на DI
Погнали.
Kotlin Multiplatform
Kotlin Multiplatform врывается в нашу жизнь, выбивая дверь ногой. Сколько на самом деле компаний и проектов внедрили себе KMP — непонятно, но, судя по темам конференций последних лет, создаётся впечатление, будто все перешли на KMP, кроме одного тебя. Шучу, перешли не все. Но мы, Android-разработчики и любители DI, должны иметь представление, что использовать в KMP в качестве DI.
На сегодняшний день есть несколько Kotlin DI, которые отлично подойдут для мультиплатформ. Самый популярный в текущий момент — это Koin. Но у нас цикл статей про Kodein, поэтому именно Kodein в KMP мы и рассмотрим.
Как подключить
Всё, что надо сделать в KMP проекте, — добавить зависимость Kodein в common source set:
sourceSets { val commonMain by getting { dependencies { implementation(libs.kodein) ... } } }
Тренируемся на кошках
Чтобы нормально разобраться, как применять Kodein в KMP проекте, рассмотрим готовый тестовый проект и порефакторим его. Я взял официальный пример от JetBrains KMM RSS Reader.
Если изобразить структуру проекта с точки зрения DI, то она будет выглядеть вот так:

По схеме видно, что в модуле нативного Android-приложения androidApp есть DI (в данном случае там используется Koin), и он берёт все зависимости, которые ему нужны, напрямую из shared модуля из source set’ов androidMain и commonMain.
Мы хотим использовать Kodein везде, где есть код на Kotlin, а не только в androidApp. Поэтому нужно отрефакторить структуру проекта примерно следующим образом:

На схеме видно, что мы добавим DI Kodein в каждый модуль, где есть код на Kotlin.
Получается DI граф (контейнер) в каждом модуле или source set:
в commonMain будет DI с самыми базовыми зависимостями — они общие для всех и не зависят ни от одной из платформ;
в androidMain и iosMain будут DI контейнеры с общими зависимостями для каждой платформы. Эти DI будут дочерними к базовому из commonMain;
androidApp будет содержать свой DI, который является дочерним по отношению к DI из shared модуля androidMain;
iosApp мы оставим напоследок, потому что Kodein в iOS не поддерживается.
shared-commonMain
Начнём с базового DI из shared модуля в commonMain исходниках.
fun sharedCommonDI() = DI { import(sharedCommonModule()) } fun sharedCommonModule() = DI.Module("sharedCommonModule") { bind<Json>() with singleton { Json { ignoreUnknownKeys = true isLenient = true encodeDefaults = false } } bind<FeedStorage>() with singleton { FeedStorage( settings = instance(), json = instance(), ) } bind<FeedLoader>() with singleton { FeedLoader( httpClient = instance(), parser = instance(), ) ...
Что мы сделали:
создали базовый DI
sharedCommonDIc единственным модулемsharedCommonModule;добавили все общие зависимости, например,
JSON,FeedStorage,FeedLoaderи др.
Здесь очень важно отметить, что этот граф получился не целостным.
FeedStorageимеет зависимостьJSON, которая есть в этом же DI, но также имеет и зависимостьsettings, которой нет в данном DI. РеализацияSettingsбудет добавлена в платформенных source set’ах.Аналогично
FeedLoader. Он имеет обе зависимости, которые не описаны в этом DI графе. РеализацииHttpClientиFeedParserбудут добавлены в своих платформенных source set’ах.
Такую штуку нам позволит сделать то, что мы потом в модуле androidApp в extend-методе добавим копирование зависимостей (дальше в статье я это покажу).
Можно этого не делать и оставить в sharedCommonDI только зависимости, созданные на чистом мультиплатформенном Kotlin коде, без Android или iOS специфики, а все специфичные классы уже добавить в платформенных source set’ах. Но я решил описать граф именно так, чтобы показать, что так можно делать.
shared-androidMain
Теперь напишем DI для Android-специфичных классов в shared-модуле. Android-специфичными зависимостями у нас будут:
SharedPreferencesSettings,
AndroidHttpClient.
AndroidFeedParser.
fun sharedAndroidDI() = DI { import(sharedAndroidModule()) extend(di = sharedCommonDI()) } fun sharedAndroidModule() = DI.Module("sharedAndroidModule") { bind<Settings>() with singleton { SharedPreferencesSettings( delegate = instance(arg = RSS_READER_PREF_KEY), ) } bind<HttpClient>() with singleton { AndroidHttpClient(withLog = true) } bind<FeedParser>() with singleton { AndroidFeedParser() } }
sharedAndroidDI — это дочерний DI по отношению к sharedCommonDI. Дочерним мы его делаем через метод extend. Помимо этого, sharedAndroidDI имеет и собственный модуль со своими Android-специфичными зависимостями. Если проиллюстрировать, то на данном этапе мы сделали так:

shared-iosMain
Теперь очередь части iosMain. Здесь структура выглядит аналогично Android DI графу. У iOS-части будут свои iOS-зависимости:
NSUserDefaultsSettings,
IosHttpClient,
IosFeedParser.
fun sharedIosDI() = DI { import(sharedIosModule()) extend(di = sharedCommonDI()) } fun sharedIosModule() = DI.Module("sharedIosModule") { bind<Settings>() with singleton { NSUserDefaultsSettings( delegate = NSUserDefaults.standardUserDefaults(), ) } bind<HttpClient>() with singleton { IosHttpClient(withLog = true) } bind<FeedParser>() with singleton { IosFeedParser() } }
Здесь также sharedIosDI — это дочерний DI по отношению к sharedCommonDI. Теперь наша схема выросла до такой:

androidApp
Переходим к модулю приложения androidApp. Это будет наш главный DI всего Android-приложения, мы его создаём в Application и реализуем сразу интерфейсе DIAware.
class App : Application(), Configuration.Provider, DIAware { override val di: DI = AppDI(app = this) ... } object AppDI { operator fun invoke(app: App) = DI { import(androidXModule(app)) extend(di = sharedAndroidDI(), copy = All) } }
AppDI будет дочерним DI по отношению к sharedAndroidDI. Помимо всего прочего, мы добавим сюда специфичные для Android-приложения зависимости через androidXModule. androidXModule — это модуль, который идёт вместе с Kodein и добавляет зависимости ко всем основным платформенным классам, таким как Resources, ContentResolver, Looper, AlarmManager, NotificationManager и многим другим. Теперь наш граф выглядит так:

iosApp
Перейдём к приложению на iOS. Здесь всё будет посложнее: Kodein под iOS не работает, потому что не работает ничего Kotlin-специфичного, главным образом inline-функции с reified-типами. Поэтому нужно обернуть наш Kodein DI граф в некую абстракцию. Покажу простой пример, как это может быть.
Введём интерфейс Dependencies, который будет предоставлять зависимости. На самом деле в нашем примере модулю приложения iosApp (как и androidApp) нужна только одна зависимость — FeedStore. Всё остальное — это внутренние зависимости. Поэтому сделаем метод provideFeedStore, который возвращает FeedStore.
Реализация DependenciesImpl будет уже использовать обычный Kodein — так же, как и в androidApp.
object DependenciesFactory { fun create(): Dependencies = DependenciesImpl() } interface Dependencies { fun provideFeedStore(): FeedStore } class DependenciesImpl : Dependencies { private val di: DI by lazy { sharedIosDI() } override fun provideFeedStore(): FeedStore { return di.direct.instance() } }
Теперь переходим в Xcode и меняем RSSApp. Нужно добавить Dependencies и создать его через метод create. Затем можем запрашивать наш FeedStore через provideFeedStore.
@main class RSSApp: App { let dependencies: Dependencies let store: ObservableFeedStore required init() { deps = DependenciesFactory.shared.create() store = ObservableFeedStore(store: dependencies.provideFeedStore()) } var body: some Scene { WindowGroup { RootView().environmentObject(store) } } }
В данном случае получилось похоже на паттерн Service Locator. Но Kodein и в Android- классах (Activity, Fragment) работает как Service Locator. Так что здесь оставим его в таком виде и не будем пробуждать холиварный спор.
Давайте визуализируем итоговую схему. У нас получилось что-то такое:

Что мы сделали
Давайте пройдёмся по тому, что мы сделали:
в каждом модуле и source set’е мы создали свой DI контейнер;
в итоге весь Kotlin код покрыт Kodein’ом, а не только androidApp;
для iosApp мы создали интерфейс, который наружу отдавал зависимости. Но реализация работала обычный DI контейнер.
Теперь можно запустить приложение и убедиться, что оно работает, все зависимости прокинуты правильно.
Можно посмотреть этот проект на GitHub: https://github.com/makzimi/kmm-sample-with-kodein-di
Также могу порекомендовать хорошее видео от Ани Жарковой, которая рассказывает в целом про DI в KMM и разбирает разные фреймворки: https://www.youtube.com/watch?v=JtUJc4WYObo
Kodein в Compose
В отличие от KMM, Jetpack Compose — это уже не что-то суперновое и неизвестное. На Jetpack Compose перешли многие проекты или, как и наш, находятся в активном переходе. Давайте рассмотрим, как можно прикрутить DI Kodein к нашему Compose коду.
Туть есть 2 варианта для разных ситуаций.
Первый — если наше приложение ещё на Fragment/View и мы используем Compose через
ComposeView.Второй — если приложение полностью на Compose.
ComposeView
Если мы используем ComposeView, то мы работаем с фрагментами или активити. Чтобы получить ViewModel во фрагменте, Kodein предоставляет свой фабричный метод viewModel.
inline fun <F, reified VM> F.viewModel( noinline ownerProducer: () -> ViewModelStoreOwner = { this }, tag: Any? = null, ): Lazy<VM> where F : Fragment, F : DIAware, VM : ViewModel { return createViewModelLazy( viewModelClass = VM::class, storeProducer = { ownerProducer().viewModelStore }, factoryProducer = { object : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { val vmProvider = direct.provider<VM>(tag) return vmProvider() as T } } } ) }
Обратите внимание на F : DIAware. Это означает, что фабрику можно использовать, когда у вас фрагмент реализует DIAware интерфейс.
И дальше, на строчке val vmProvider = *direct*.*provider*<VM>(tag), происходит получение ViewModel из DI графа. Таким образом Kodein умеет строить ViewModel со всеми нужными зависимостями.
Дальше мы получаем стейт (например, через collectAsStateWithLifecycle) из ViewModel и передаём в ComposeView.
view.setContent { val uiState by viewModel.uiState.collectAsStateWithLifecycle() setContent { AppTheme { MainScreen( state = uiState, di = di, ) } } }
Для Activity тоже есть аналогичное расширение. Правда оно привязано к AppCompatActivity, а не к ComponentActivity. Но можно написать своё аналогичное расширение, с этим нет проблем.
На этом для многих DI в презентационном слое может и закончится. Но давайте посмотрим дальше и глубже: например, что делать, если мы хотим использовать DI в Composable-методах.
Compose экран
Первый вопрос: а зачем оно нам? Зачем мы хотим пихать Kodein в наш Compose-код?
Отвечу на этот вопрос так: в общем случае это, может, и не надо. Код будет чище и не нужно тащить за собой внешний фреймворк. Но, с другой стороны, это может быть очень удобным решением в определённых случаях. Например, мы можем захотеть что-то часто передавать через CompositionLocal и в итоге прийти к тому, что будем писать вот такие портянки в разных экранах:
CompositionLocalProvider( LocalExoPlayerFactory provides exoPlayerFactory, LocalTimeformatter provides timeFormatter, LocalRetryCondition provides retryCondition, ... LocalListFilter provides listFilter, ) { MainScreen(uiState) }
Это и так очень похоже на DI. Поэтому можно использовать DI, который специально для этого предназначен.
Kodein тоже работает через CompositionLocal. Напомню, что CompositionLocal — это инструмент для неявной передачи данных через композицию.
Чтобы использовать Compose-инструменты Kodein, нужно подключить отдельный модуль.
implementation 'org.kodein.di:kodein-di-framework-compose:7.18.0'
Добавить зависимости в Compose виджете
Есть 3 основных варианта, как прокинуть зависимости в Compose:
через
withDI, передав DI;через
withDI, передав модули;через
subDI.
Рассмотрим первый способ. Обернём наш виджет в метод withDI.
@Composable fun MainScreen( state: UiState, di: DI, ) = withDI(di) { Column { ContentView() BorromView() } }
Теперь все дочерние виджеты ContentView и BottomView будут иметь возможность получить зависимости из Kodein.
Второй способ: передаём не весь DI, а конкретные модули. Это выглядит так:
@Composable fun MainScreen(state: UiState) = withDI(aModule, bModule) { Column { ContentView() BottomView() } }
Не могу сказать, в каком случае такой вариант более предпочтительный. Возможно, для совсем небольших объектов, которые не имеют большого количества зависимостей.
Третий вариант — subDI. Он работает по тому же принципу, что и обычный subDI или subDI для фрагментов. subDI создаёт дочерний DI от текущего DI и будет иметь доступ ко всем зависимостям родительского DI. Если вы не знакомы с методом subDI, то можете представить, что это как SubComponent из Dagger.
@Composable fun ContentView() { subDI( diBuilder = { bindSingleton { UserDataRepository() } } ) { Column { Row { //... } } } }
В примере выше мы добавили subDI к текущему DI, который должен был быть объявлен в одном из родительских виджетов через withDI(). Если мы забудем это сделать, то получим IllegalStateException.
Получить зависимости в Сompose-виджете
Для получения зависимостей у нас есть три варианта.
Первый вариант — localDI(). Когда мы вызываем метод localDI(), он вернёт нам DI-контейнер из CompositionLocal. Далее можем вызывать обычные методы instance() для получения конкретных зависимостей.
@Composable fun ContentView() { ... val di = localDI() val service: MyService by di.instance() ... }
Второй вариант — можно воспользоваться Composable-функцией rememberDI, которая работает под капотом с обычным remember и сохранит нашу зависимость в композиции, чтобы каждый раз не дёргать контейнер.
@Composable fun ContentView() { ... val service: MyService by rememberDI { instance() } ... }
Третий вариант — методы rememberInstance, rememberNamedInstance, rememberFactory, rememberProvider. Они сразу возвращают нам зависимость. Не буду расписывать все эти методы по отдельности, они работают одинаково.
@Composable fun ContentView() { val service: MyService by rememberInstance() }
Важно понимать, что это всё только для Jetpack Compose, а не Compose Multiplatform. Но думаю, дело не за горами.
Краткий итог про Kodein в Compose
Давайте резюмируем, зачем и как использовать Kodein в Compose.
Использовать DI в Compose не обязательно. Но это может быть очень удобно в определённых случаях.
Kodein довольно легко прокачивает наши Compose-виджеты своим контекстом. Буквально одной строчкой кода мы делаем так, что все дочерние виджеты имеют доступы ко всем нужным зависимостям.
Kodein позволяет создавать subDI, т.е. дочерние DI-компоненты. Это может быть очень удобно, чтобы не добавлять все зависимости сразу в базовый родительский DI.
Заключение
Эта статья состоит, на первый взгляд, из двух не совсем связанных между собой тем: Kodein в КМP и Kodein в Jetpack Compose. Но я их объединил, потому что эти темы — будущее Android-разработки. Для кого-то это уже стало настоящим, но для многих — это самое ближайшее будущее. Поэтому важно разбираться в этих областях, в том числе с позиции организации DI.
Мы увидели, что Kodein отлично справляется с предоставлением зависимостей по всему Kotlin-коду, а не только в модулях Android-приложений.
Также разобрались, что с помощью Kodein можно легко прокачать свой Compose-код так, чтобы все виджеты получили доступ ко всем нужным зависимостям. Получилось примерно так же просто, как и для фрагментов.
Буду ли я сам использовать Kodein в KMM проектах? Да, почему бы и нет. Хотя надо следить и пробовать разные инструменты. Koin — отличный инструмент для KMP. И все мы все будем следить за тем, перейдёт ли Dagger на KMM.
Будем ли мы использовать Kodein в Jetpack Compose? Пока мы используем Kodein во фрагментах. Но соблазн заменить CompositionLocal провайдеры на одну строчку Kodein очень велик. Если Kodein начнёт поддерживать и Compose Multiplatform, то это может изменить наше мнение.
Спасибо за то, что дочитали статью! А если вам лень читать такие большие тексты, как этот, подписывайтесь на мой ТГ канал Мобильное чтиво. Там я делюсь своими мыслями, в основном про Android-разработку, но не только.
А новостями мобильной разработки в Dodo в коротком формате мы делимся в Dodo Mobile.
Это вторая статья из цикла статьей про Kodein DI для Android:
Часть 1: Kodein DI для Android. Основы API
Часть 2: Kodein DI для Android. KMP и Compose
