Skip to content

Commit

Permalink
Migrate resaca Koin to KMP and CMP
Browse files Browse the repository at this point in the history
  • Loading branch information
sebaslogen committed Aug 20, 2024
1 parent e8747e1 commit 183868b
Show file tree
Hide file tree
Showing 12 changed files with 213 additions and 106 deletions.
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jetbrains-lifecycle = "2.8.0"
hilt = '2.52'
hilt-navigation-compose = '1.2.0'
# Koin
koin = '3.5.6'
koin = '4.0.0-RC1'
# Compose Multiplatform
compose-plugin = "1.7.0-alpha02"
# Test
Expand Down Expand Up @@ -75,6 +75,8 @@ dagger-hilt-android-compiler = { module = 'com.google.dagger:hilt-android-compil
dagger-hilt-android-testing = { module = 'com.google.dagger:hilt-android-testing', version.ref = 'hilt' }
dagger-hilt-navigation-compose = { module = 'androidx.hilt:hilt-navigation-compose', version.ref = 'hilt-navigation-compose' }

koin-core = { module = 'io.insert-koin:koin-core', version.ref = 'koin' }
koin-compose = { module = 'io.insert-koin:koin-compose', version.ref = 'koin' }
koin-android = { module = 'io.insert-koin:koin-android', version.ref = 'koin' }
koin-android-test = { module = 'io.insert-koin:koin-android-test', version.ref = 'koin' }

Expand Down
15 changes: 15 additions & 0 deletions resacakoin/api/resacakoin.klib.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Klib ABI Dump
// Targets: [iosArm64, iosSimulatorArm64, iosX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true

// Library unique name: <io.github.sebaslogen:resacakoin>
final val com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinParametersHolder$stableprop // com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinParametersHolder$stableprop|#static{}com_sebaslogen_resaca_koin_KoinParametersHolder$stableprop[0]
final val com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinViewModelFactory$stableprop // com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinViewModelFactory$stableprop|#static{}com_sebaslogen_resaca_koin_KoinViewModelFactory$stableprop[0]

final fun com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinParametersHolder$stableprop_getter(): kotlin/Int // com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinParametersHolder$stableprop_getter|com_sebaslogen_resaca_koin_KoinParametersHolder$stableprop_getter(){}[0]
final fun com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinViewModelFactory$stableprop_getter(): kotlin/Int // com.sebaslogen.resaca.koin/com_sebaslogen_resaca_koin_KoinViewModelFactory$stableprop_getter|com_sebaslogen_resaca_koin_KoinViewModelFactory$stableprop_getter(){}[0]
final inline fun <#A: reified androidx.lifecycle/ViewModel, #B: kotlin/Any> com.sebaslogen.resaca.koin/koinViewModelScoped(#B, noinline kotlin/Function1<#B, kotlin/Boolean>, org.koin.core.qualifier/Qualifier?, org.koin.core.scope/Scope?, noinline kotlin/Function0<org.koin.core.parameter/ParametersHolder>?, androidx.core.bundle/Bundle?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): #A // com.sebaslogen.resaca.koin/koinViewModelScoped|koinViewModelScoped(0:1;kotlin.Function1<0:1,kotlin.Boolean>;org.koin.core.qualifier.Qualifier?;org.koin.core.scope.Scope?;kotlin.Function0<org.koin.core.parameter.ParametersHolder>?;androidx.core.bundle.Bundle?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§<androidx.lifecycle.ViewModel>;1§<kotlin.Any>}[0]
final inline fun <#A: reified androidx.lifecycle/ViewModel> com.sebaslogen.resaca.koin/koinViewModelScoped(kotlin/Any?, org.koin.core.qualifier/Qualifier?, org.koin.core.scope/Scope?, noinline kotlin/Function0<org.koin.core.parameter/ParametersHolder>?, androidx.core.bundle/Bundle?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): #A // com.sebaslogen.resaca.koin/koinViewModelScoped|koinViewModelScoped(kotlin.Any?;org.koin.core.qualifier.Qualifier?;org.koin.core.scope.Scope?;kotlin.Function0<org.koin.core.parameter.ParametersHolder>?;androidx.core.bundle.Bundle?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§<androidx.lifecycle.ViewModel>}[0]
51 changes: 34 additions & 17 deletions resacakoin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.jetbrains.compose)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kover)
alias(libs.plugins.dokka)
alias(libs.plugins.maven)
alias(libs.plugins.compose.compiler)
}

kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "resacakoin"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
api(project(":resaca"))

api(libs.koin.core)
api(libs.koin.compose)
}
}
}

android {
Expand All @@ -28,9 +60,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
packaging {
resources {
excludes += setOf(
Expand All @@ -48,18 +77,6 @@ android {
}
}

dependencies {

api(project(":resaca"))

implementation(libs.androidx.core.ktx)

implementation(libs.koin.android)

// Integration with ViewModels
implementation(libs.androidx.lifecycle.viewmodel)
}

// Maven publishing configuration
val artifactId = "resacakoin"
val mavenGroup: String by project
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
package com.sebaslogen.resaca.koin

import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.bundle.Bundle
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.CreationExtras
import com.sebaslogen.resaca.KeyInScopeResolver
import com.sebaslogen.resaca.ScopeKeyWithResolver
import com.sebaslogen.resaca.ScopedViewModelContainer
import com.sebaslogen.resaca.ScopedViewModelContainer.ExternalKey
import com.sebaslogen.resaca.ScopedViewModelContainer.InternalKey
import com.sebaslogen.resaca.ScopedViewModelOwner
import com.sebaslogen.resaca.generateKeysAndObserveLifecycle
import org.koin.androidx.viewmodel.factory.KoinViewModelFactory
import org.koin.compose.getKoin
import org.koin.core.annotation.KoinInternalApi
import org.koin.core.context.GlobalContext
import org.koin.core.parameter.ParametersDefinition
import org.koin.core.parameter.ParametersHolder
import org.koin.core.qualifier.Qualifier
import org.koin.core.scope.Scope
import kotlin.reflect.KClass


/**
Expand Down Expand Up @@ -50,9 +54,9 @@ public inline fun <reified T : ViewModel, K : Any> koinViewModelScoped(
key: K,
noinline keyInScopeResolver: KeyInScopeResolver<K>,
qualifier: Qualifier? = null,
scope: Scope = GlobalContext.get().scopeRegistry.rootScope,
scope: Scope = getKoin().scopeRegistry.rootScope,
noinline parameters: ParametersDefinition? = null,
defaultArguments: Bundle = Bundle.EMPTY
defaultArguments: Bundle = Bundle()
): T {
val scopeKeyWithResolver: ScopeKeyWithResolver<K> = remember(key, keyInScopeResolver) { ScopeKeyWithResolver(key, keyInScopeResolver) }
return koinViewModelScoped(
Expand Down Expand Up @@ -90,9 +94,9 @@ public inline fun <reified T : ViewModel, K : Any> koinViewModelScoped(
public inline fun <reified T : ViewModel> koinViewModelScoped(
key: Any? = null,
qualifier: Qualifier? = null,
scope: Scope = GlobalContext.get().scopeRegistry.rootScope,
scope: Scope = getKoin().scopeRegistry.rootScope,
noinline parameters: ParametersDefinition? = null,
defaultArguments: Bundle = Bundle.EMPTY
defaultArguments: Bundle = Bundle()
): T {

val (scopedViewModelContainer: ScopedViewModelContainer, positionalMemoizationKey: InternalKey, externalKey: ExternalKey) =
Expand All @@ -112,3 +116,49 @@ public inline fun <reified T : ViewModel> koinViewModelScoped(
defaultArguments = defaultArguments
)
}

/**
* Note: The two classes below are not yet KMP compatible in Koin 4.0.0-RC1,
* once Koin uses the KMP version of the AndroidX libraries, these classes will be removed.
* TODO: Remove these classes once Koin uses AndroidX KMP compatible libs
*/

/**
* ViewModelProvider.Factory for Koin instances resolution
* @see ViewModelProvider.Factory
*/
@PublishedApi
internal class KoinViewModelFactory(
private val kClass: KClass<out ViewModel>,
private val scope: Scope,
private val qualifier: Qualifier? = null,
private val params: ParametersDefinition? = null
) : ViewModelProvider.Factory {

override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
val koinParams = KoinParametersHolder(params, extras)
return scope.get(kClass, qualifier) { koinParams }
}
}

@Suppress("UNCHECKED_CAST")
@PublishedApi
internal class KoinParametersHolder(
initialValues: ParametersDefinition? = null,
private val extras: CreationExtras,
) : ParametersHolder(initialValues?.invoke()?.values?.toMutableList() ?: mutableListOf()) {

override fun <T> elementAt(i: Int, clazz: KClass<*>): T {
return createSavedStateHandleOrElse(clazz) { super.elementAt(i, clazz) }
}

override fun <T> getOrNull(clazz: KClass<*>): T? {
return createSavedStateHandleOrElse(clazz) { super.getOrNull(clazz) }
}

private fun <T> createSavedStateHandleOrElse(clazz: KClass<*>, block: () -> T): T {
return if (clazz == SavedStateHandle::class) {
extras.createSavedStateHandle() as T
} else block()
}
}
4 changes: 0 additions & 4 deletions resacakoin/src/main/AndroidManifest.xml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import com.sebaslogen.resacaapp.sample.ui.main.data.FakeScopedViewModel
import com.sebaslogen.resacaapp.sample.ui.main.data.FakeSecondInjectedViewModel
import com.sebaslogen.resacaapp.sample.ui.main.data.FakeSimpleInjectedViewModel
import com.sebaslogen.resacaapp.sample.viewModelsClearedGloballySharedCounter
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
import java.util.concurrent.atomic.AtomicInteger

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.sebaslogen.resacaapp.sample.ui.main.compose.screens.ComposeScreenWith
import com.sebaslogen.resacaapp.sample.ui.main.compose.screens.ComposeScreenWithSingleViewModelScopedWithKeys
import com.sebaslogen.resacaapp.sample.ui.main.ui.theme.ResacaAppTheme
import dagger.hilt.android.AndroidEntryPoint
import org.koin.compose.KoinContext

const val emptyDestination = "emptyDestination"
const val rememberScopedDestination = "rememberScopedDestination"
Expand Down Expand Up @@ -103,7 +104,9 @@ fun ScreensWithNavigation(navController: NavHostController = rememberNavControll
ComposeScreenWithHiltViewModelScoped(navController)
}
composable(koinViewModelScopedDestination) {
ComposeScreenWithKoinViewModelScoped(navController)
KoinContext { // This is required to use Koin in ActivityScenario Robolectric tests, see ComposeActivityRecreationTests.kt
ComposeScreenWithKoinViewModelScoped(navController)
}
}
composable(hiltSingleViewModelScopedDestination) { // This destination is only used in automated tests
ComposeScreenWithSingleHiltViewModelScoped(navController)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.compose.KoinContext
import org.koin.core.parameter.parametersOf


Expand All @@ -38,10 +39,12 @@ class AssistedInjectionTest : ComposeTestUtils {
val fakeInjectedViewModelId = 555
var fakeInjectedViewModel: FakeInjectedViewModel? = null
composeTestRule.activity.setContent {
fakeInjectedViewModel = koinViewModelScoped(
defaultArguments = bundleOf(FakeInjectedViewModel.MY_ARGS_KEY to fakeInjectedViewModelId),
parameters = { parametersOf(viewModelsClearedGloballySharedCounter) })
DemoComposable(inputObject = fakeInjectedViewModel!!, objectType = "FakeInjectedViewModel", scoped = true)
KoinContext {
fakeInjectedViewModel = koinViewModelScoped(
defaultArguments = bundleOf(FakeInjectedViewModel.MY_ARGS_KEY to fakeInjectedViewModelId),
parameters = { parametersOf(viewModelsClearedGloballySharedCounter) })
DemoComposable(inputObject = fakeInjectedViewModel!!, objectType = "FakeInjectedViewModel", scoped = true)
}
}
printComposeUiTreeToLog()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.compose.KoinContext

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
Expand Down Expand Up @@ -53,8 +54,10 @@ class ClearScopedViewModelTests : ComposeTestUtils {
fun `when I navigate to nested screen and back, then the Koin scoped ViewModels of the second screen are cleared`() {
// Given the starting screen with Koin injected ViewModel scoped
composeTestRule.activity.setContent {
navController = rememberNavController()
ScreensWithNavigation(navController = navController, startDestination = koinViewModelScopedDestination)
KoinContext {
navController = rememberNavController()
ScreensWithNavigation(navController = navController, startDestination = koinViewModelScopedDestination)
}
}
printComposeUiTreeToLog()
val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get()
Expand Down Expand Up @@ -84,10 +87,12 @@ class ClearScopedViewModelTests : ComposeTestUtils {
var composablesShown by mutableStateOf(true)
val textTitle = "Test text"
composeTestRule.activity.setContent {
Column {
Text(textTitle)
if (composablesShown) {
DemoScopedKoinInjectedViewModelComposable()
KoinContext {
Column {
Text(textTitle)
if (composablesShown) {
DemoScopedKoinInjectedViewModelComposable()
}
}
}
}
Expand Down Expand Up @@ -117,11 +122,13 @@ class ClearScopedViewModelTests : ComposeTestUtils {
var composablesShown by mutableStateOf(true)
val textTitle = "Test text"
composeTestRule.activity.setContent {
Column {
Text(textTitle)
DemoScopedKoinInjectedViewModelComposable()
if (composablesShown) {
KoinContext {
Column {
Text(textTitle)
DemoScopedKoinInjectedViewModelComposable()
if (composablesShown) {
DemoScopedKoinInjectedViewModelComposable()
}
}
}
}
Expand Down Expand Up @@ -150,11 +157,13 @@ class ClearScopedViewModelTests : ComposeTestUtils {
var composablesShown by mutableStateOf(true)
val textTitle = "Test text"
composeTestRule.activity.setContent {
Column {
Text(textTitle)
DemoScopedKoinInjectedViewModelComposable()
if (composablesShown) {
DemoScopedSecondKoinInjectedViewModelComposable()
KoinContext {
Column {
Text(textTitle)
DemoScopedKoinInjectedViewModelComposable()
if (composablesShown) {
DemoScopedSecondKoinInjectedViewModelComposable()
}
}
}
}
Expand Down Expand Up @@ -186,9 +195,11 @@ class ClearScopedViewModelTests : ComposeTestUtils {
var viewModelKey by mutableStateOf("initial key")
val textTitle = "Test text"
composeTestRule.activity.setContent {
Column {
Text(textTitle)
DemoScopedKoinInjectedViewModelComposable(viewModelKey)
KoinContext {
Column {
Text(textTitle)
DemoScopedKoinInjectedViewModelComposable(viewModelKey)
}
}
}
printComposeUiTreeToLog()
Expand Down
Loading

0 comments on commit 183868b

Please sign in to comment.