Kodein DI для Android. KMP и Compose
Быть в авангарде в разработке — жизненная необходимость. Поэтому многие проекты уже переходят на 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
Погнали.
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() with singleton {
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
}
}
bind() with singleton {
FeedStorage(
settings = instance(),
json = instance(),
)
}
bind() with singleton {
FeedLoader(
httpClient = instance(),
parser = instance(),
)
...
Что мы сделали:
создали базовый DI
sharedCommonDI
c единственным модулем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-специфичными зависимостями у нас будут:
fun sharedAndroidDI() = DI {
import(sharedAndroidModule())
extend(di = sharedCommonDI())
}
fun sharedAndroidModule() = DI.Module("sharedAndroidModule") {
bind() with singleton {
SharedPreferencesSettings(
delegate = instance(arg = RSS_READER_PREF_KEY),
)
}
bind() with singleton {
AndroidHttpClient(withLog = true)
}
bind() 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() with singleton {
NSUserDefaultsSettings(
delegate = NSUserDefaults.standardUserDefaults(),
)
}
bind() with singleton {
IosHttpClient(withLog = true)
}
bind() 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.viewModel(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
tag: Any? = null,
): Lazy where F : Fragment, F : DIAware, VM : ViewModel {
return createViewModelLazy(
viewModelClass = VM::class,
storeProducer = { ownerProducer().viewModelStore },
factoryProducer = {
object : ViewModelProvider.Factory {
override fun create(modelClass: Class): T {
val vmProvider = direct.provider(tag)
return vmProvider() as T
}
}
}
)
}
Обратите внимание на F : DIAware
. Это означает, что фабрику можно использовать, когда у вас фрагмент реализует DIAware
интерфейс.
И дальше, на строчке val vmProvider = *direct*.*provider*
, происходит получение 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