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, то она будет выглядеть вот так:

9b5af0aaa26f16eaa50d73b635b9a73f.png

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

Мы хотим использовать Kodein везде, где есть код на Kotlin, а не только в androidApp. Поэтому нужно отрефакторить структуру проекта примерно следующим образом:

58e9a24e47c7537c7673a6d64d0e9990.png

На схеме видно, что мы добавим 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 и др.

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

  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-специфичными зависимостями у нас будут:

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-специфичными зависимостями. Если проиллюстрировать, то на данном этапе мы сделали так:

4d90f758f728e7e00439f6441ccb592c.png

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. Теперь наша схема выросла до такой:

abfcfdb6b99c291a32e373f89fb2bc78.png

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 и многим другим. Теперь наш граф выглядит так:

09b1a32b6111feafe680b69edf2a3d9b.png

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. Так что здесь оставим его в таком виде и не будем пробуждать холиварный спор.

Давайте визуализируем итоговую схему. У нас получилось что-то такое:

48c52869984f933b66f301eee50abc6a.png

Что мы сделали

Давайте пройдёмся по тому, что мы сделали:

  • в каждом модуле и 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*(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

© Habrahabr.ru