-
-
Save gmk57/330a7d214f5d710811c6b5ce27ceedaa to your computer and use it in GitHub Desktop.
/** | |
* Starts collecting a flow when the lifecycle is started, and **cancels** the collection on stop. | |
* This is different from `lifecycleScope.launchWhenStarted { flow.collect{...} }`, in which case | |
* the coroutine is just suspended on stop. | |
*/ | |
inline fun <reified T> Flow<T>.collectWhileStarted( | |
lifecycleOwner: LifecycleOwner, | |
noinline action: suspend (T) -> Unit | |
) { | |
object : DefaultLifecycleObserver { | |
private var job: Job? = null | |
init { | |
lifecycleOwner.lifecycle.addObserver(this) | |
} | |
override fun onStart(owner: LifecycleOwner) { | |
job = owner.lifecycleScope.launch { | |
collect { action(it) } | |
} | |
} | |
override fun onStop(owner: LifecycleOwner) { | |
job?.cancel() | |
job = null | |
} | |
} | |
} | |
class MyViewModel : ViewModel() { | |
// You can specify exact buffer size and onBufferOverflow strategy here, or go full blast with Channel.UNLIMITED | |
private val eventChannel = Channel<String>(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST) | |
val eventFlow = eventChannel.receiveAsFlow() | |
fun sendEvent(element: String) = eventChannel.trySend(element) // `trySend` replaces `offer` since Coroutines 1.5 | |
} | |
class MyFragment : Fragment(R.layout.fragment_my) { | |
private val viewModel: MyViewModel by viewModels() | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
// You can collect flow in `onCreate` using `this` as lifecycleOwner | |
// This is a bit more efficient: `LifecycleObserver` is registered only once | |
viewModel.eventFlow.collectWhileStarted(this) { Log.i(TAG, "event: $it") } | |
} | |
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | |
super.onViewCreated(view, savedInstanceState) | |
// Or you can collect flow in `onViewCreated` using `viewLifecycleOwner` | |
// This more closely resembles a typical LiveData observer | |
viewModel.eventFlow.collectWhileStarted(viewLifecycleOwner) { Log.i(TAG, "event: $it") } | |
} | |
} |
The fact that mapNotNull
returns a Flow
and not a SharedFlow
makes sense, because after the map the property of a shared flow may no longer hold:
A hot
Flow
that shares emitted values among all its collectors in a broadcast fashion, so that all collectors get all emitted values.
(source)
In fact, a Flow
might emit to zero or more collectors. That's not too generic, that's just versatile.
You can always create extensions for reuse, e.g.
fun <T : Any, V> SharedFlow<Event<T>>.mapNotNullEvents(transform: suspend (T) -> V) /* : Flow<V> */ =
mapNotNull { it.unconsumedValueOrNull() }.map(transform)
fun <T : Any, V> SharedFlow<Event<T>>.mapEvents(transform: suspend (T) -> V) /* : Flow<V?> */ =
map { it.unconsumedValueOrNull()?.let { value -> transform(value) } }
fun <T : Any> SharedFlow<Event<T>>.onEachEvent(action: suspend (T) -> Unit) /* : Flow<Event<T>> */ =
onEach { it.unconsumedValueOrNull()?.let { value -> action(value) } }
It would've been nicer if onEach
returned a SharedFlow
instead of a Flow
, as it preserves the broadcast property in the type signature.
So, for what it's worth Google's updated their own guidance on this problem yet again. https://developer.android.com/jetpack/guide/ui-layer/events (I'm not quite sure when this updated guide was posted.)
From their guide:
Note: In some apps, you might have seen ViewModel events being exposed to the UI using Kotlin Channels or other reactive streams. These solutions usually require workarounds such as event wrappers in order to guarantee that events are not lost and that they're consumed only once.
Requiring workarounds is an indication that there's a problem with these approaches. The problem with exposing events from the ViewModel is that it goes against the state-down-events-up principle of Unidirectional Data Flow.
The TL;DR of it is for the view to call back to the view model when an event has been processed. This is in-line with their recommendations for unidirectional data flow and feels very Compose-y. It ignores a lot of issues, in my opinion, but it is what it is.
@fergusonm Thanks for the update, I haven't seen this article before.
It's good to see that Google is aware of the issue. I like UDF & try to use it as much as possible. From my point of view, the main problem is an "impedance mismatch" between state-down-events-up principle and event-driven classic Android UI.
Event wrappers and userMessageShown()
are both workarounds for this mismatch, with the latter being much more verbose. Wrapper just encapsulates basically the same logic to avoid repetition.
Compose mostly alleviates this concern, e.g. AlertDialog is now just a composable and can be clearly modeled as part of state.
On the other side, AFAIK, Navigation Compose is still event-based. Is this code guaranteed to be called only once?
LaunchedEffect(viewModel.uiState) {
if (viewModel.uiState.isUserLoggedIn) {
currentOnUserLogIn()
}
}
Another point that bothers me is the suggestion to handle "UI behavior logic" directly in UI, bypassing ViewModel. This goes against the "single source of truth" principle. It also complicates handling configuration changes, which becomes a larger issue on Android 12.
BTW, one phrase caught my eye: "business logic remains the same for the same app on different mobile platforms". Is Google starting to push us towards KMP? ;)
BTW, one phrase caught my eye: "business logic remains the same for the same app on different mobile platforms". Is Google starting to push us towards KMP? ;)
In 2024, your expectations are materializing.
Thanks for the suggestion!
As far as I could test,
mapNotNull
works correctly. Personally, I find consuming inside a "shared" flow (technically it is not aSharedFlow
aftermap
) a bit counter-intuitive.I'm not sure if
consume()?.let { ... }
is a boilerplate, I see it as a clear indication that we're dealing with events here, not some state. My main concern is that forEvent<Unit>
one can easily forget to addconsume()
, I've hit this a couple of times.Having multiple primary (consuming) observers for a single flow is tricky, because it's hard to predict which one will process the event. Of course, if it doesn't matter, good for you. ;)
Nowadays, I prefer to expose a single
StateFlow<SomeViewState>
to UI, including all possible events. Event wrapper plays nice with it.But all this is a matter of taste. If
mapNotNull { it.consume() }
looks and works well for you, great! :)