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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Какими технологиями вы пользуетесь на вашем рабочем проекте?

22.73% Я впереди планеты всей: KMP и Compose Multiplatform5
4.55% Я уже экономлю код: только KMP1
31.82% Делаем быстро и красиво: только Jetpack Compose7
40.91% Тише едешь — дальше будешь: пока ни KMP, ни Compose9

Проголосовали 22 пользователя. Воздержались 2 пользователя.

ссылка на оригинал статьи https://habr.com/ru/articles/766058/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *