Skip to content

Commit bce53e9

Browse files
committed
Added Store#stateFlow(Lifecycle) and Store#labelsChannel(Lifecycle) API, promoted to stable
1 parent f23a901 commit bce53e9

File tree

8 files changed

+328
-12
lines changed

8 files changed

+328
-12
lines changed

mvikotlin-extensions-coroutines/api/android/mvikotlin-extensions-coroutines.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ public final class com/arkivanov/mvikotlin/extensions/coroutines/StoreExtKt {
6666
public static final fun getLabels (Lcom/arkivanov/mvikotlin/core/store/Store;)Lkotlinx/coroutines/flow/Flow;
6767
public static final fun getStateFlow (Lcom/arkivanov/mvikotlin/core/store/Store;)Lkotlinx/coroutines/flow/StateFlow;
6868
public static final fun getStates (Lcom/arkivanov/mvikotlin/core/store/Store;)Lkotlinx/coroutines/flow/Flow;
69+
public static final fun labelsChannel (Lcom/arkivanov/mvikotlin/core/store/Store;Lcom/arkivanov/essenty/lifecycle/Lifecycle;I)Lkotlinx/coroutines/channels/ReceiveChannel;
6970
public static final fun labelsChannel (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;I)Lkotlinx/coroutines/channels/ReceiveChannel;
71+
public static synthetic fun labelsChannel$default (Lcom/arkivanov/mvikotlin/core/store/Store;Lcom/arkivanov/essenty/lifecycle/Lifecycle;IILjava/lang/Object;)Lkotlinx/coroutines/channels/ReceiveChannel;
7072
public static synthetic fun labelsChannel$default (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;IILjava/lang/Object;)Lkotlinx/coroutines/channels/ReceiveChannel;
73+
public static final fun stateFlow (Lcom/arkivanov/mvikotlin/core/store/Store;Lcom/arkivanov/essenty/lifecycle/Lifecycle;)Lkotlinx/coroutines/flow/StateFlow;
7174
public static final fun stateFlow (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;)Lkotlinx/coroutines/flow/StateFlow;
7275
public static synthetic fun stateFlow$default (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow;
7376
}

mvikotlin-extensions-coroutines/api/jvm/mvikotlin-extensions-coroutines.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ public final class com/arkivanov/mvikotlin/extensions/coroutines/StoreExtKt {
6666
public static final fun getLabels (Lcom/arkivanov/mvikotlin/core/store/Store;)Lkotlinx/coroutines/flow/Flow;
6767
public static final fun getStateFlow (Lcom/arkivanov/mvikotlin/core/store/Store;)Lkotlinx/coroutines/flow/StateFlow;
6868
public static final fun getStates (Lcom/arkivanov/mvikotlin/core/store/Store;)Lkotlinx/coroutines/flow/Flow;
69+
public static final fun labelsChannel (Lcom/arkivanov/mvikotlin/core/store/Store;Lcom/arkivanov/essenty/lifecycle/Lifecycle;I)Lkotlinx/coroutines/channels/ReceiveChannel;
6970
public static final fun labelsChannel (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;I)Lkotlinx/coroutines/channels/ReceiveChannel;
71+
public static synthetic fun labelsChannel$default (Lcom/arkivanov/mvikotlin/core/store/Store;Lcom/arkivanov/essenty/lifecycle/Lifecycle;IILjava/lang/Object;)Lkotlinx/coroutines/channels/ReceiveChannel;
7072
public static synthetic fun labelsChannel$default (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;IILjava/lang/Object;)Lkotlinx/coroutines/channels/ReceiveChannel;
73+
public static final fun stateFlow (Lcom/arkivanov/mvikotlin/core/store/Store;Lcom/arkivanov/essenty/lifecycle/Lifecycle;)Lkotlinx/coroutines/flow/StateFlow;
7174
public static final fun stateFlow (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;)Lkotlinx/coroutines/flow/StateFlow;
7275
public static synthetic fun stateFlow$default (Lcom/arkivanov/mvikotlin/core/store/Store;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow;
7376
}

mvikotlin-extensions-coroutines/src/commonMain/kotlin/com/arkivanov/mvikotlin/extensions/coroutines/StoreExt.kt

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package com.arkivanov.mvikotlin.extensions.coroutines
22

3+
import com.arkivanov.essenty.lifecycle.Lifecycle
4+
import com.arkivanov.essenty.lifecycle.doOnDestroy
35
import com.arkivanov.mvikotlin.core.rx.observer
46
import com.arkivanov.mvikotlin.core.store.Store
5-
import com.arkivanov.mvikotlin.core.utils.ExperimentalMviKotlinApi
67
import kotlinx.coroutines.CoroutineScope
78
import kotlinx.coroutines.ExperimentalCoroutinesApi
89
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
@@ -19,15 +20,15 @@ import kotlinx.coroutines.launch
1920
import kotlin.coroutines.CoroutineContext
2021

2122
/**
22-
* Returns a [Flow] that emits [Store] states.
23+
* Creates and returns a [Flow] that emits [Store] states.
2324
*
2425
* Please note that the actual collection of the [Flow] may not be synchronous depending on [CoroutineContext] being used.
2526
*/
2627
val <State : Any> Store<*, State, *>.states: Flow<State>
2728
get() = toFlow(Store<*, State, *>::states)
2829

2930
/**
30-
* Returns a [StateFlow] that emits [Store] states. The returned [StateFlow] is hot,
31+
* Creates and returns a [StateFlow] that emits [Store] states. The returned [StateFlow] is hot,
3132
* started in the given coroutine [scope], sharing the most recently emitted state from
3233
* a single subscription to the [Store] with multiple downstream subscribers.
3334
*
@@ -42,7 +43,25 @@ fun <State : Any> Store<*, State, *>.stateFlow(
4243
): StateFlow<State> = states.stateIn(scope, started, state)
4344

4445
/**
45-
* Returns a [StateFlow] that emits [Store] states.
46+
* Creates and returns a [StateFlow] that emits [Store] states. The returned [StateFlow] is hot,
47+
* sharing the most recently emitted state from a single subscription to the [Store]
48+
* with multiple downstream subscribers.
49+
*
50+
* Please note that the actual collection of the [StateFlow] may not be synchronous
51+
* depending on [CoroutineContext] being used.
52+
*
53+
* @param lifecycle a [Lifecycle] used for cancelling the underlying [MutableStateFlow].
54+
*/
55+
fun <State : Any> Store<*, State, *>.stateFlow(lifecycle: Lifecycle): StateFlow<State> {
56+
val stateFlow = MutableStateFlow(state)
57+
val disposable = states(observer(onNext = { stateFlow.value = it }))
58+
lifecycle.doOnDestroy { disposable.dispose() }
59+
60+
return stateFlow
61+
}
62+
63+
/**
64+
* Creates and returns a [StateFlow] that emits [Store] states.
4665
*
4766
* This API is experimental because [StateFlow] interface is not stable for inheritance in 3rd party libraries.
4867
* Please mind binary compatibility when using this API.
@@ -53,6 +72,7 @@ fun <State : Any> Store<*, State, *>.stateFlow(
5372
val <State : Any> Store<*, State, *>.stateFlow: StateFlow<State>
5473
get() = StoreStateFlow(store = this)
5574

75+
@Suppress("UnnecessaryOptInAnnotation")
5676
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
5777
private class StoreStateFlow<out State : Any>(
5878
private val store: Store<*, State, *>,
@@ -74,7 +94,7 @@ private class StoreStateFlow<out State : Any>(
7494
}
7595

7696
/**
77-
* Returns a [Flow] that emits [Store] labels.
97+
* Creates and returns a [Flow] that emits [Store] labels.
7898
*
7999
* Please note that the actual collection of the [Flow] may not be synchronous depending on [CoroutineContext] being used.
80100
*/
@@ -90,12 +110,12 @@ val <Label : Any> Store<*, *, Label>.labels: Flow<Label>
90110
*
91111
* Due to the nature of how channels work, it is recommended to have one [Channel] per subscriber.
92112
*
93-
* Please note that the actual collection of the [Flow] may not be synchronous depending on [CoroutineContext] being used.
113+
* Please note that the actual collection of the [ReceiveChannel] may not be synchronous depending on
114+
* [CoroutineContext] being used.
94115
*
95116
* @param scope a [CoroutineScope] used for cancelling the underlying [Channel].
96117
* @param capacity a capacity of the underlying [Channel], default value is [Channel.BUFFERED].
97118
*/
98-
@ExperimentalMviKotlinApi
99119
fun <Label : Any> Store<*, *, Label>.labelsChannel(
100120
scope: CoroutineScope,
101121
capacity: Int = Channel.BUFFERED,
@@ -115,3 +135,32 @@ fun <Label : Any> Store<*, *, Label>.labelsChannel(
115135
return channel
116136
}
117137

138+
/**
139+
* Returns a [ReceiveChannel] that emits [Store] labels. Unlike [labels] that returns a [Flow], this API
140+
* is useful when labels must not be skipped while there is no subscriber. Please keep in mind that labels
141+
* still may be skipped if they are dispatched synchronously on [Store] initialization. If that's the case,
142+
* you can disable the automatic initialization by passing `autoInit = false` parameter when creating a [Store],
143+
* see [StoreFactory.create][com.arkivanov.mvikotlin.core.store.StoreFactory.create] for more information.
144+
*
145+
* Due to the nature of how channels work, it is recommended to have one [Channel] per subscriber.
146+
*
147+
* Please note that the actual collection of the [ReceiveChannel] may not be synchronous depending on
148+
* [CoroutineContext] being used.
149+
*
150+
* @param lifecycle a [Lifecycle] used for cancelling the underlying [Channel].
151+
* @param capacity a capacity of the underlying [Channel], default value is [Channel.BUFFERED].
152+
*/
153+
fun <Label : Any> Store<*, *, Label>.labelsChannel(
154+
lifecycle: Lifecycle,
155+
capacity: Int = Channel.BUFFERED,
156+
): ReceiveChannel<Label> {
157+
val channel = Channel<Label>(capacity = capacity)
158+
val disposable = labels(observer(onNext = channel::trySend))
159+
160+
lifecycle.doOnDestroy {
161+
disposable.dispose()
162+
channel.cancel()
163+
}
164+
165+
return channel
166+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.arkivanov.mvikotlin.extensions.coroutines
2+
3+
import com.arkivanov.essenty.lifecycle.Lifecycle
4+
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
5+
import com.arkivanov.essenty.lifecycle.destroy
6+
import com.arkivanov.mvikotlin.core.rx.Disposable
7+
import com.arkivanov.mvikotlin.core.rx.Observer
8+
import com.arkivanov.mvikotlin.core.store.Store
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.DelicateCoroutinesApi
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.launch
13+
import kotlin.test.Test
14+
import kotlin.test.assertContentEquals
15+
import kotlin.test.assertNull
16+
import kotlin.test.assertTrue
17+
18+
@Suppress("TestFunctionName")
19+
class LabelChannelWithLifecycleTest {
20+
21+
@Test
22+
fun WHEN_label_emitted_THEN_label_collected() {
23+
val store = TestStore()
24+
val scope = CoroutineScope(Dispatchers.Unconfined)
25+
val channel = store.labelsChannel(LifecycleRegistry())
26+
val labels = ArrayList<Int>()
27+
28+
store.labelObserver?.onNext(1)
29+
30+
scope.launch {
31+
for (label in channel) {
32+
labels += label
33+
}
34+
}
35+
36+
store.labelObserver?.onNext(2)
37+
store.labelObserver?.onNext(3)
38+
39+
assertContentEquals(listOf(1, 2, 3), labels)
40+
}
41+
42+
@Test
43+
fun WHEN_lifecycle_destroyed_THEN_unsubscribed_from_store() {
44+
val store = TestStore()
45+
val scope = CoroutineScope(Dispatchers.Unconfined)
46+
val lifecycle = LifecycleRegistry(Lifecycle.State.CREATED)
47+
val channel = store.labelsChannel(lifecycle)
48+
49+
scope.launch {
50+
while (true) {
51+
channel.receive()
52+
}
53+
}
54+
55+
lifecycle.destroy()
56+
57+
assertNull(store.labelObserver)
58+
}
59+
60+
@OptIn(DelicateCoroutinesApi::class)
61+
@Test
62+
fun WHEN_lifecycle_destroyed_THEN_channel_cancelled() {
63+
val store = TestStore()
64+
val scope = CoroutineScope(Dispatchers.Unconfined)
65+
val lifecycle = LifecycleRegistry(Lifecycle.State.CREATED)
66+
val channel = store.labelsChannel(lifecycle)
67+
68+
scope.launch {
69+
while (true) {
70+
channel.receive()
71+
}
72+
}
73+
74+
lifecycle.destroy()
75+
76+
assertTrue(channel.isClosedForReceive)
77+
}
78+
79+
private class TestStore : Store<Int, Int, Int> {
80+
override val state: Int = 0
81+
override val isDisposed: Boolean = false
82+
83+
var labelObserver: Observer<Int>? = null
84+
private set
85+
86+
override fun states(observer: Observer<Int>): Disposable = error("Not required")
87+
88+
override fun labels(observer: Observer<Int>): Disposable {
89+
labelObserver = observer
90+
91+
return Disposable { labelObserver = null }
92+
}
93+
94+
override fun accept(intent: Int) {
95+
// no-op
96+
}
97+
98+
override fun init() {
99+
// no-op
100+
}
101+
102+
override fun dispose() {
103+
// no-op
104+
}
105+
}
106+
}
Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package com.arkivanov.mvikotlin.extensions.coroutines
33
import com.arkivanov.mvikotlin.core.rx.Disposable
44
import com.arkivanov.mvikotlin.core.rx.Observer
55
import com.arkivanov.mvikotlin.core.store.Store
6-
import com.arkivanov.mvikotlin.core.utils.ExperimentalMviKotlinApi
76
import kotlinx.coroutines.CoroutineScope
87
import kotlinx.coroutines.DelicateCoroutinesApi
98
import kotlinx.coroutines.Dispatchers
@@ -14,9 +13,8 @@ import kotlin.test.assertContentEquals
1413
import kotlin.test.assertNull
1514
import kotlin.test.assertTrue
1615

17-
@OptIn(ExperimentalMviKotlinApi::class)
1816
@Suppress("TestFunctionName")
19-
class LabelChannelTest {
17+
class LabelChannelWithScopeTest {
2018

2119
@Test
2220
fun WHEN_label_emitted_THEN_label_collected() {

mvikotlin-extensions-coroutines/src/commonTest/kotlin/com/arkivanov/mvikotlin/extensions/coroutines/StateFlowTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ class StateFlowTest {
1919
@Test
2020
fun WHEN_state_emitted_THEN_state_collected() {
2121
val store = TestStore()
22-
val flow = store.stateFlow
2322
val scope = CoroutineScope(Dispatchers.Unconfined)
23+
val flow = store.stateFlow
2424
val items = ArrayList<Int>()
2525

2626
scope.launch {
@@ -37,8 +37,8 @@ class StateFlowTest {
3737
@Test
3838
fun WHEN_collection_cancelled_THEN_unsubscribed_from_store() {
3939
val store = TestStore()
40-
val flow = store.stateFlow
4140
val scope = CoroutineScope(Dispatchers.Unconfined)
41+
val flow = store.stateFlow
4242

4343
scope.launch {
4444
flow.collect {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.arkivanov.mvikotlin.extensions.coroutines
2+
3+
import com.arkivanov.essenty.lifecycle.Lifecycle
4+
import com.arkivanov.essenty.lifecycle.LifecycleRegistry
5+
import com.arkivanov.essenty.lifecycle.destroy
6+
import com.arkivanov.mvikotlin.core.rx.Disposable
7+
import com.arkivanov.mvikotlin.core.rx.Observer
8+
import com.arkivanov.mvikotlin.core.store.Store
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.launch
12+
import kotlin.test.Test
13+
import kotlin.test.assertContentEquals
14+
import kotlin.test.assertNull
15+
16+
@Suppress("TestFunctionName")
17+
class StateFlowWithLifecycleTest {
18+
19+
@Test
20+
fun WHEN_state_emitted_THEN_state_collected() {
21+
val store = TestStore()
22+
val scope = CoroutineScope(Dispatchers.Unconfined)
23+
val flow = store.stateFlow(LifecycleRegistry())
24+
val items = ArrayList<Int>()
25+
26+
scope.launch {
27+
flow.collect { items += it }
28+
}
29+
30+
store.stateObserver?.onNext(1)
31+
store.stateObserver?.onNext(2)
32+
store.stateObserver?.onNext(3)
33+
34+
assertContentEquals(listOf(0, 1, 2, 3), items)
35+
}
36+
37+
@Test
38+
fun WHEN_lifecycle_destroyed_THEN_unsubscribed_from_store() {
39+
val store = TestStore()
40+
val lifecycle = LifecycleRegistry(Lifecycle.State.CREATED)
41+
val scope = CoroutineScope(Dispatchers.Unconfined)
42+
val flow = store.stateFlow(lifecycle)
43+
44+
scope.launch {
45+
flow.collect {}
46+
}
47+
48+
lifecycle.destroy()
49+
50+
assertNull(store.stateObserver)
51+
}
52+
53+
private class TestStore : Store<Int, Int, Int> {
54+
override val state: Int = 0
55+
override val isDisposed: Boolean = false
56+
57+
var stateObserver: Observer<Int>? = null
58+
private set
59+
60+
override fun states(observer: Observer<Int>): Disposable {
61+
stateObserver = observer
62+
63+
return Disposable { stateObserver = null }
64+
}
65+
66+
override fun labels(observer: Observer<Int>): Disposable = error("Not required")
67+
68+
override fun accept(intent: Int) {
69+
// no-op
70+
}
71+
72+
override fun init() {
73+
// no-op
74+
}
75+
76+
override fun dispose() {
77+
// no-op
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)