Skip to content

Commit ebd0b3a

Browse files
committed
Reactive dependency injection
1 parent f542cca commit ebd0b3a

File tree

17 files changed

+474
-55
lines changed

17 files changed

+474
-55
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Next release (6.0.0 preview)
44

5+
* Added reactive dependency injection solution (see `DI`) with graph tracking that can even re-create ViewModels when a DI module/node is changed at runtime.
56
* Added `ContextualVal`/`ContextualValSuspend` for coroutine-local variables (like thread-locals or `CompositionLocal` for coroutines).
67
* Made the experimental `ReactiveViewModel` more flexible by building it around `ContextualVal`.
78
* Changed `loading` and all `withLoading` parameters from `MutableValueFlow` to `MutableStateFlow`.
@@ -14,6 +15,7 @@
1415
* Fixed `MutableStateFlow.compareAndSet` for `.toMutable`/`.beforeUpdate`/`.afterUpdate`.
1516
* Added `Flow.stateOnDemand` variant with `initial` value.
1617
* `CoroutineLauncher.launch` now synchronously increments the loading counter to avoid UI flickering issues in edge cases.
18+
* Added `childReactiveState` variant which takes an arbitrary event handler, so the parent ReactiveState doesn't have to implement the whole events interface.
1719
* Removed direct dependency on JUnit 4, so you can choose more freely which JUnit version to use.
1820

1921
## 5.13.0

build-logic/build-logic-base/src/main/kotlin/com/ensody/buildlogic/KotlinMultiplatformExt.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ fun KotlinMultiplatformExtension.applyKmpHierarchy(block: KotlinHierarchyBuilder
8383
withWasmJs()
8484
withWasmWasi()
8585
withIos()
86-
withMacos()
8786
withJvm()
8887
withAndroidTarget()
8988
}

build-logic/build-logic/src/main/kotlin/com/ensody/buildlogic/BuildLogicPlugin.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ fun Project.setupBuildLogic(block: Project.() -> Unit) {
102102
setupVersionCatalog()
103103
}
104104
extensions.findByType<MavenPublishBaseExtension>()?.apply {
105+
configureBasedOnAppliedPlugins(sourcesJar = true, javadocJar = System.getenv("RUNNING_ON_CI") == "true")
105106
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
106107
if (System.getenv("ORG_GRADLE_PROJECT_signingInMemoryKey")?.isNotBlank() == true) {
107108
signAllPublications()

reactivestate-android/src/androidMain/kotlin/com/ensody/reactivestate/AutoRunLiveData.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import androidx.lifecycle.Observer
55

66
/** Returns [LiveData.getValue] and tracks the observable. */
77
public fun <T> Resolver.get(data: LiveData<T>): T? =
8-
track(data) { LiveDataObservable(data, autoRunner) }.value
8+
track(data) { LiveDataObservable(data) }.value
99

1010
private class LiveDataObservable<T>(
1111
private val data: LiveData<T>,
12-
autoRunner: BaseAutoRunner,
1312
) : AutoRunnerObservable<T?> {
13+
private lateinit var autoRunner: BaseAutoRunner
1414
private var ignore = false
1515
private val observer = Observer<T> {
1616
if (!ignore) {
@@ -20,14 +20,15 @@ private class LiveDataObservable<T>(
2020

2121
override val value: T? get() = data.value
2222

23-
override fun addObserver() {
23+
override fun addObserver(autoRunner: BaseAutoRunner) {
2424
// Prevent recursion and assume the value is already set correctly
2525
ignore = true
26+
this.autoRunner = autoRunner
2627
data.observeForever(observer)
2728
ignore = false
2829
}
2930

30-
override fun removeObserver() {
31+
override fun removeObserver(autoRunner: BaseAutoRunner) {
3132
data.removeObserver(observer)
3233
}
3334
}

reactivestate-compose/src/composeMain/kotlin/com/ensody/reactivestate/compose/ViewModelExt.kt

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package com.ensody.reactivestate.compose
22

33
import androidx.compose.runtime.Composable
44
import androidx.compose.runtime.LaunchedEffect
5-
import androidx.compose.runtime.mutableStateMapOf
5+
import androidx.compose.runtime.State
6+
import androidx.compose.runtime.collectAsState
67
import androidx.compose.runtime.saveable.rememberSaveable
7-
import androidx.compose.runtime.snapshots.SnapshotStateMap
88
import androidx.lifecycle.ViewModel
99
import androidx.lifecycle.ViewModelStoreOwner
1010
import androidx.lifecycle.viewModelScope
@@ -14,32 +14,43 @@ import com.ensody.reactivestate.ContextualErrorsFlow
1414
import com.ensody.reactivestate.ContextualStateFlowStore
1515
import com.ensody.reactivestate.ContextualValRoot
1616
import com.ensody.reactivestate.CoroutineLauncher
17+
import com.ensody.reactivestate.DI
1718
import com.ensody.reactivestate.ExperimentalReactiveStateApi
1819
import com.ensody.reactivestate.InMemoryStateFlowStore
1920
import com.ensody.reactivestate.ReactiveStateContext
21+
import com.ensody.reactivestate.invokeOnCompletion
2022
import com.ensody.reactivestate.triggerOnInit
23+
import com.ensody.reactivestate.withSpinLock
2124
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.MainScope
26+
import kotlinx.coroutines.cancel
27+
import kotlinx.coroutines.flow.StateFlow
2228
import kotlinx.coroutines.plus
29+
import kotlinx.coroutines.sync.Mutex
2330

2431
/**
2532
* Creates a multiplatform ViewModel. The [provider] should instantiate the object directly.
2633
*
2734
* You have to pass loading and error effect handlers, so the most basic functionality is taken care of.
35+
*
36+
* Instead of returning the raw ViewModel this returns a Compose [State] object containing the ViewModel.
37+
* This way the UI can keep up to date with a dynamically changing dependency injection graph.
38+
* If the ViewModel depends on a [DI] object it gets destroyed and re-created whenever that DI module is replaced.
2839
*/
2940
@ExperimentalReactiveStateApi
3041
@Composable
3142
public inline fun <reified VM : CoroutineLauncher> reactiveViewModel(
3243
key: String? = null,
3344
crossinline onError: (Throwable) -> Unit,
3445
crossinline provider: ReactiveStateContext.() -> VM,
35-
): VM =
46+
): State<VM> =
3647
onViewModel(key = key) {
3748
provider().also {
3849
it.triggerOnInit()
3950
}
4051
}.also { viewModel ->
41-
LaunchedEffect(viewModel) {
42-
ContextualErrorsFlow.get(viewModel.scope).collect { onError(it) }
52+
LaunchedEffect(viewModel.value) {
53+
ContextualErrorsFlow.get(viewModel.value.scope).collect { onError(it) }
4354
}
4455
}
4556

@@ -58,20 +69,38 @@ public inline fun <reified T : Any?> onViewModel(
5869
},
5970
key: String? = null,
6071
crossinline provider: ReactiveStateContext.() -> T,
61-
): T {
72+
): State<T> {
6273
// TODO: Use qualifiedName once JS supports it
6374
val fullKey = (key ?: "") + ":onViewModel:${T::class.simpleName}"
64-
val storage = rememberSaveable<SnapshotStateMap<String, Any?>> { mutableStateMapOf() }
75+
val storage = rememberSaveable<MutableMap<String, Any?>> { mutableMapOf() }
6576
return viewModel(viewModelStoreOwner = viewModelStoreOwner, key = fullKey) {
66-
WrapperViewModel { scope ->
67-
ReactiveStateContext(
68-
scope + ContextualValRoot() + ContextualStateFlowStore.valued { InMemoryStateFlowStore(storage) },
69-
).provider()
77+
WrapperViewModel { viewModelScope ->
78+
// The viewModelScope can't be used directly because we have to destroy and re-create the ViewModel whenever
79+
// the DI graph gets modified.
80+
val mutex = Mutex()
81+
var scope: CoroutineScope? = null
82+
viewModelScope.invokeOnCompletion {
83+
mutex.withSpinLock {
84+
scope?.cancel()
85+
scope = null
86+
}
87+
}
88+
DI.derived {
89+
val vmScope = MainScope()
90+
mutex.withSpinLock {
91+
scope?.cancel()
92+
scope = vmScope
93+
}
94+
ReactiveStateContext(
95+
vmScope + ContextualValRoot() + ContextualStateFlowStore.valued { InMemoryStateFlowStore(storage) },
96+
this,
97+
).provider()
98+
}
7099
}
71-
}.value
100+
}.value.collectAsState()
72101
}
73102

74103
/** A wrapper ViewModel used to hold an arbitrary [value]. */
75-
public class WrapperViewModel<T : Any?>(provider: (CoroutineScope) -> T) : ViewModel() {
76-
public val value: T = provider(viewModelScope)
104+
public class WrapperViewModel<T : Any?>(provider: (CoroutineScope) -> StateFlow<T>) : ViewModel() {
105+
public val value: StateFlow<T> = provider(viewModelScope)
77106
}

reactivestate-core/src/commonMain/kotlin/com/ensody/reactivestate/Annotations.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ public annotation class ExperimentalReactiveStateApi
1515
@Retention(value = AnnotationRetention.BINARY)
1616
@Target(AnnotationTarget.PROPERTY)
1717
public annotation class DependencyAccessor
18+
19+
/** Marks internal ReactiveState APIs. */
20+
@MustBeDocumented
21+
@RequiresOptIn(
22+
message = "Internal API. Don't use this.",
23+
level = RequiresOptIn.Level.ERROR,
24+
)
25+
@Retention(value = AnnotationRetention.BINARY)
26+
public annotation class InternalReactiveStateApi

reactivestate-core/src/commonMain/kotlin/com/ensody/reactivestate/AutoRun.kt

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ public fun CoroutineScope.coAutoRun(
126126

127127
/** Just the minimum interface needed for [Resolver]. No generic types. */
128128
public abstract class BaseAutoRunner : AttachedDisposables {
129-
internal abstract val resolver: Resolver
129+
internal abstract val resolver: ResolverImpl
130130
public abstract val launcher: CoroutineLauncher
131131

132132
public abstract fun triggerChange()
@@ -140,7 +140,7 @@ public abstract class InternalBaseAutoRunner(
140140
private val immediate: Boolean,
141141
) : BaseAutoRunner() {
142142
override val attachedDisposables: DisposableGroup = DisposableGroup()
143-
override var resolver: Resolver = Resolver(this)
143+
override var resolver: ResolverImpl = ResolverImpl(this)
144144
internal set
145145
protected open val withLoading: MutableStateFlow<Int>? = null
146146

@@ -182,7 +182,7 @@ public abstract class InternalBaseAutoRunner(
182182
/** Stops watching observables. */
183183
override fun dispose() {
184184
flowConsumer?.cancel()
185-
resolver = Resolver(this).also(resolver::switchTo)
185+
resolver = ResolverImpl(this).also(resolver::switchTo)
186186
flowConsumer = null
187187
super.dispose()
188188
}
@@ -233,10 +233,10 @@ public class AutoRunner<T>(
233233
}
234234

235235
private fun <T> observe(once: Boolean, observer: AutoRunCallback<T>): T {
236-
if (once) return Resolver(this, once).observer()
236+
if (once) return ResolverImpl(this, once).observer()
237237

238238
val previousResolver = resolver
239-
val nextResolver = Resolver(this)
239+
val nextResolver = ResolverImpl(this)
240240
try {
241241
return nextResolver.observer()
242242
} finally {
@@ -285,7 +285,7 @@ public class CoAutoRunner<T>(
285285
) : InternalBaseAutoRunner(launcher, flowTransformer, immediate) {
286286
override val attachedDisposables: DisposableGroup = DisposableGroup()
287287
private val listener: CoAutoRunOnChangeCallback<T> = onChange ?: { run() }
288-
override var resolver: Resolver = Resolver(this)
288+
override var resolver: ResolverImpl = ResolverImpl(this)
289289

290290
init {
291291
init()
@@ -303,10 +303,10 @@ public class CoAutoRunner<T>(
303303
}
304304

305305
private suspend fun <T> observe(once: Boolean, observer: CoAutoRunCallback<T>): T {
306-
if (once) return Resolver(this, once).observer()
306+
if (once) return ResolverImpl(this, once).observer()
307307

308308
val previousResolver = resolver
309-
val nextResolver = Resolver(this)
309+
val nextResolver = ResolverImpl(this)
310310
try {
311311
return nextResolver.observer()
312312
} finally {
@@ -323,8 +323,31 @@ public class CoAutoRunner<T>(
323323
}
324324

325325
/** Tracks observables for [AutoRunner] and [CoAutoRunner]. */
326-
public class Resolver(public val autoRunner: BaseAutoRunner, public val once: Boolean = false) {
327-
internal val observables = mutableMapOf<Any, FrozenAutoRunnerObservable<*, *>>()
326+
public interface Resolver {
327+
public val autoRunner: BaseAutoRunner
328+
329+
@InternalReactiveStateApi
330+
public val InternalResolver.once: Boolean
331+
332+
public fun <S : Any, T : AutoRunnerObservable<V>, V> track(
333+
underlyingObservable: S,
334+
getObservable: () -> T,
335+
): FrozenAutoRunnerObservable<V, T>
336+
337+
@InternalReactiveStateApi
338+
public val InternalResolver.observables: Map<Any, FrozenAutoRunnerObservable<*, *>>
339+
}
340+
341+
@InternalReactiveStateApi
342+
public object InternalResolver
343+
344+
@OptIn(InternalReactiveStateApi::class)
345+
internal class ResolverImpl(override val autoRunner: BaseAutoRunner, private val once: Boolean = false) : Resolver {
346+
override val InternalResolver.once: Boolean
347+
get() = this@ResolverImpl.once
348+
private val observables = mutableMapOf<Any, FrozenAutoRunnerObservable<*, *>>()
349+
override val InternalResolver.observables: Map<Any, FrozenAutoRunnerObservable<*, *>>
350+
get() = this@ResolverImpl.observables
328351

329352
/**
330353
* Tracks an arbitrary observable.
@@ -337,7 +360,7 @@ public class Resolver(public val autoRunner: BaseAutoRunner, public val once: Bo
337360
*
338361
* @return The instantiated [AutoRunnerObservable] of type [T].
339362
*/
340-
public fun <S : Any, T : AutoRunnerObservable<V>, V> track(
363+
override fun <S : Any, T : AutoRunnerObservable<V>, V> track(
341364
underlyingObservable: S,
342365
getObservable: () -> T,
343366
): FrozenAutoRunnerObservable<V, T> {
@@ -348,16 +371,16 @@ public class Resolver(public val autoRunner: BaseAutoRunner, public val once: Bo
348371
val observable = FrozenAutoRunnerObservable<V, T>(castExisting ?: getObservable())
349372
observables[underlyingObservable] = observable
350373
if (!once && autoRunner.isActive && castExisting == null) {
351-
observable.observable.addObserver()
352-
existing?.observable?.removeObserver()
374+
observable.observable.addObserver(autoRunner)
375+
existing?.observable?.removeObserver(autoRunner)
353376
}
354377
return observable
355378
}
356379

357-
internal fun switchTo(next: Resolver) {
380+
internal fun switchTo(next: ResolverImpl) {
358381
for ((underlyingObservable, item) in observables) {
359382
if (item.observable != next.observables[underlyingObservable]?.observable) {
360-
item.observable.removeObserver()
383+
item.observable.removeObserver(autoRunner)
361384
}
362385
}
363386
}
@@ -371,8 +394,8 @@ public class Resolver(public val autoRunner: BaseAutoRunner, public val once: Bo
371394
public interface AutoRunnerObservable<T> {
372395
public val value: T
373396
public val revisionedValue: Pair<T, ULong> get() = value to 0U
374-
public fun addObserver()
375-
public fun removeObserver()
397+
public fun addObserver(autoRunner: BaseAutoRunner)
398+
public fun removeObserver(autoRunner: BaseAutoRunner)
376399
}
377400

378401
public class FrozenAutoRunnerObservable<T, O : AutoRunnerObservable<T>>(

reactivestate-core/src/commonMain/kotlin/com/ensody/reactivestate/AutoRunStateFlow.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,20 @@ import kotlinx.coroutines.flow.StateFlow
55

66
/** Returns [StateFlow.value] and tracks the observable (on the `MainScope`). */
77
public fun <T> Resolver.get(data: StateFlow<T>): T =
8-
track(data) { StateFlowObservable(data, autoRunner) }.value
8+
track(data) { StateFlowObservable(data) }.value
99

1010
private class StateFlowObservable<T>(
1111
private val data: StateFlow<T>,
12-
private val autoRunner: BaseAutoRunner,
1312
) : AutoRunnerObservable<T> {
1413
private var observer: Job? = null
1514

1615
override val value: T get() = data.value
1716

1817
@Suppress("UNCHECKED_CAST")
1918
override val revisionedValue: Pair<T, ULong>
20-
get() = (data as? RevisionedValue<T>)?.revisionedValue ?: value to 0U
19+
get() = (data as? RevisionedValue<T>)?.revisionedValue ?: (value to 0U)
2120

22-
override fun addObserver() {
21+
override fun addObserver(autoRunner: BaseAutoRunner) {
2322
if (observer == null) {
2423
var ignore: Wrapped<T>? = Wrapped(data.value)
2524
observer = autoRunner.launcher.launch(withLoading = null) {
@@ -33,7 +32,7 @@ private class StateFlowObservable<T>(
3332
}
3433
}
3534

36-
override fun removeObserver() {
35+
override fun removeObserver(autoRunner: BaseAutoRunner) {
3736
observer?.cancel()
3837
observer = null
3938
}

reactivestate-core/src/commonMain/kotlin/com/ensody/reactivestate/AutoRunWhileUsed.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ private class WhileUsedObservable<T>(
1212
) : AutoRunnerObservable<DisposableValue<T>?> {
1313
override var value: DisposableValue<T>? = null
1414

15-
override fun addObserver() {
15+
override fun addObserver(autoRunner: BaseAutoRunner) {
1616
if (value == null) {
1717
value = data.disposableValue()
1818
}
1919
}
2020

21-
override fun removeObserver() {
21+
override fun removeObserver(autoRunner: BaseAutoRunner) {
2222
value?.dispose()
2323
}
2424
}

0 commit comments

Comments
 (0)