Быть в авангарде в разработке — жизненная необходимость. Поэтому многие проекты уже переходят на 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<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 sharedCommonDI c единственным модулем sharedCommonModule;

  • добавили все общие зависимости, например, JSON, FeedStorage, FeedLoader и др.

Здесь очень важно отметить, что этот граф получился не целостным.

  1. FeedStorage имеет зависимость JSON, которая есть в этом же DI, но также имеет и зависимость settings, которой нет в данном DI. Реализация Settings будет добавлена в платформенных source set’ах.

  2. Аналогично 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.

  1. Использовать DI в Compose не обязательно. Но это может быть очень удобно в определённых случаях.

  2. Kodein довольно легко прокачивает наши Compose-виджеты своим контекстом. Буквально одной строчкой кода мы делаем так, что все дочерние виджеты имеют доступы ко всем нужным зависимостям.

  3. 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

Комментарии (4)


  1. Rusrst
    09.10.2023 21:22

    Интересен опыт вашего использования lazy list в списках. И загрузки картинок. После 1.5.0 все стало лучше? И стоит ли переписывать кастомные modifiers (composed) на новый тип api (element/node)?


    1. maxkachinkin Автор
      09.10.2023 21:22

      Интересен опыт вашего использования lazy list в списках.

      Готовим статью про то как мы делали наш первый сложный экран на Copmose.

      После 1.5.0 все стало лучше?

      Не значительно да. Мы, если честно, больше страдает от времени загрузки Compose Runtime.

      И стоит ли переписывать кастомные modifiers (composed) на новый тип api (element/node)?

      У нас мало было кастомных modifiers, написанных через composed. Мы пока не переписывали свои.


      1. Rusrst
        09.10.2023 21:22

        А если не секрет, то как загрузку картинок сделали - coil, свое решение? Если свое решение то из чего оно состоит?


  1. maxkachinkin Автор
    09.10.2023 21:22

    Любопытно, что используют или KMP + Compose Multiplatform или Jetpack Сompose больше половины проголосовавших. Я думал сообществе более консервативно, а мы достаточно прогрессивны :)